Add LilyGO T-Deck Max (e-paper) base board support (#552)
This commit is contained in:
@@ -57,6 +57,7 @@ jobs:
|
||||
{ id: guition-jc8048w550c, arch: esp32s3 },
|
||||
{ id: heltec-wifi-lora-32-v3, arch: esp32s3 },
|
||||
{ id: lilygo-tdeck, arch: esp32s3 },
|
||||
{ id: lilygo-tdeck-max, arch: esp32s3 },
|
||||
{ id: lilygo-thmi, arch: esp32s3 },
|
||||
{ id: lilygo-tdongle-s3, arch: esp32s3 },
|
||||
{ id: lilygo-tdisplay-s3, arch: esp32s3 },
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
INCLUDE_DIRS "Source"
|
||||
REQUIRES Tactility GDEQ031T10 TCA8418 driver
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
#include "devices/Display.h"
|
||||
#include "devices/TdeckmaxKeyboard.h"
|
||||
|
||||
#include <Tactility/hal/Configuration.h>
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/delay.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/esp32_spi.h>
|
||||
#include <tactility/drivers/gpio_controller.h>
|
||||
|
||||
using namespace tt::hal;
|
||||
|
||||
// Reset lines routed through the XL9555 IO expander (P-numbers from the vendor
|
||||
// lib/TDeckMaxBoard/src/TDeckMaxBoard.h). Both are active low.
|
||||
constexpr auto XL9555_PIN_TOUCH_RST = 7; // P07
|
||||
constexpr auto XL9555_PIN_KEY_RST = 9; // P11
|
||||
|
||||
// Release the touch and keyboard reset lines held by the XL9555. Without this
|
||||
// the touch controller may stay in a half-powered state and the keyboard's
|
||||
// TCA8418 is held in reset, so neither responds. Mirrors the vendor factory
|
||||
// firmware's XL9555 init (examples/factory/factory.ino).
|
||||
static void initIoExpander() {
|
||||
auto* xl9555 = device_find_by_name("xl9555");
|
||||
if (xl9555 == nullptr) {
|
||||
// Boot anyway; display works without the expander, but touch/keyboard won't.
|
||||
return;
|
||||
}
|
||||
|
||||
auto* touch_rst = gpio_descriptor_acquire(xl9555, XL9555_PIN_TOUCH_RST, GPIO_OWNER_GPIO);
|
||||
auto* key_rst = gpio_descriptor_acquire(xl9555, XL9555_PIN_KEY_RST, GPIO_OWNER_GPIO);
|
||||
|
||||
if (touch_rst != nullptr) {
|
||||
gpio_descriptor_set_flags(touch_rst, GPIO_FLAG_DIRECTION_OUTPUT);
|
||||
gpio_descriptor_set_level(touch_rst, false);
|
||||
}
|
||||
if (key_rst != nullptr) {
|
||||
gpio_descriptor_set_flags(key_rst, GPIO_FLAG_DIRECTION_OUTPUT);
|
||||
gpio_descriptor_set_level(key_rst, false);
|
||||
}
|
||||
|
||||
delay_millis(20);
|
||||
|
||||
// Release both resets and give the controllers time to boot before they're probed.
|
||||
if (touch_rst != nullptr) {
|
||||
gpio_descriptor_set_level(touch_rst, true);
|
||||
gpio_descriptor_release(touch_rst);
|
||||
}
|
||||
if (key_rst != nullptr) {
|
||||
gpio_descriptor_set_level(key_rst, true);
|
||||
gpio_descriptor_release(key_rst);
|
||||
}
|
||||
|
||||
delay_millis(60);
|
||||
}
|
||||
|
||||
static bool initBoot() {
|
||||
// The LoRa and SD chip-selects share the EPD's SPI bus but aren't wired up
|
||||
// yet. spi0's start() already drives every cs-gpio high during kernel init;
|
||||
// re-assert deselection before the EPD driver first transacts, as a cheap
|
||||
// guard against either chip latching stray EPD command bytes (same pattern
|
||||
// as the esp32_sdspi mount path).
|
||||
auto* spi0 = device_find_by_name("spi0");
|
||||
check(spi0 != nullptr);
|
||||
esp32_spi_deselect_all_cs(spi0);
|
||||
|
||||
initIoExpander();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static DeviceVector createDevices() {
|
||||
auto* i2c = device_find_by_name("i2c0");
|
||||
check(i2c != nullptr);
|
||||
|
||||
// SD card is not created here: it's an espressif,esp32-sdspi node in the
|
||||
// devicetree (sdcard@1 under spi0), started by Hal::init's mountAll().
|
||||
DeviceVector devices = {
|
||||
createDisplay()
|
||||
};
|
||||
|
||||
auto keypad = std::make_shared<Tca8418>(i2c);
|
||||
devices.push_back(keypad);
|
||||
devices.push_back(std::make_shared<TdeckmaxKeyboard>(keypad));
|
||||
|
||||
return devices;
|
||||
}
|
||||
|
||||
extern const Configuration hardwareConfiguration = {
|
||||
.initBoot = initBoot,
|
||||
.createDevices = createDevices
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
#include "Cst66xxTouch.h"
|
||||
|
||||
#include <tactility/log.h>
|
||||
#include <Tactility/app/App.h>
|
||||
#include <tactility/drivers/gpio_controller.h>
|
||||
#include <tactility/drivers/i2c_controller.h>
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
|
||||
// Touch reset is wired to XL9555 P07 (active low).
|
||||
static constexpr uint32_t XL9555_PIN_TOUCH_RST = 7;
|
||||
|
||||
constexpr auto* TAG = "CST66xx";
|
||||
|
||||
static constexpr TickType_t I2C_TIMEOUT = pdMS_TO_TICKS(20);
|
||||
|
||||
// The protocol uses 4-byte register addresses, taken from the vendor's
|
||||
// lib/HynTouch/src/hyn_cst66xx.c.
|
||||
|
||||
// Normal-mode setup sequence (cst66xx_set_workmode, NOMAL_MODE).
|
||||
static constexpr uint8_t CMD_DISABLE_LP_I2C[4] = {0xD0, 0x00, 0x04, 0x00};
|
||||
static constexpr uint8_t CMD_NORMAL_A[4] = {0xD0, 0x00, 0x00, 0x00};
|
||||
static constexpr uint8_t CMD_NORMAL_B[4] = {0xD0, 0x00, 0x0C, 0x00};
|
||||
static constexpr uint8_t CMD_NORMAL_C[4] = {0xD0, 0x00, 0x01, 0x00};
|
||||
// Read-point register: write these 4 bytes, then read the touch frame.
|
||||
static constexpr uint8_t REG_READ_POINT[4] = {0xD0, 0x07, 0x00, 0x00};
|
||||
// Acknowledge/clear after each read.
|
||||
static constexpr uint8_t CMD_ACK[4] = {0xD0, 0x00, 0x02, 0xAB};
|
||||
// Identity/config register (cst66xx_updata_tpinfo): read 50 bytes; buf[2]==0xCA
|
||||
// && buf[3]==0xCA confirms a CST66xx.
|
||||
static constexpr uint8_t REG_INFO[4] = {0xD0, 0x03, 0x00, 0x00};
|
||||
|
||||
static void setNormalMode(::Device* i2c, uint8_t address) {
|
||||
i2c_controller_write(i2c, address, CMD_DISABLE_LP_I2C, sizeof(CMD_DISABLE_LP_I2C), I2C_TIMEOUT);
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
i2c_controller_write(i2c, address, CMD_DISABLE_LP_I2C, sizeof(CMD_DISABLE_LP_I2C), I2C_TIMEOUT);
|
||||
i2c_controller_write(i2c, address, CMD_NORMAL_A, sizeof(CMD_NORMAL_A), I2C_TIMEOUT);
|
||||
i2c_controller_write(i2c, address, CMD_NORMAL_B, sizeof(CMD_NORMAL_B), I2C_TIMEOUT);
|
||||
i2c_controller_write(i2c, address, CMD_NORMAL_C, sizeof(CMD_NORMAL_C), I2C_TIMEOUT);
|
||||
}
|
||||
|
||||
bool Cst66xxTouch::start() {
|
||||
// Reset the controller (XL9555 P07, active low). The chip only re-initialises
|
||||
// into normal reporting mode after a clean reset pulse.
|
||||
auto* xl9555 = device_find_by_name("xl9555");
|
||||
if (xl9555 != nullptr) {
|
||||
auto* rst = gpio_descriptor_acquire(xl9555, XL9555_PIN_TOUCH_RST, GPIO_OWNER_GPIO);
|
||||
if (rst != nullptr) {
|
||||
gpio_descriptor_set_flags(rst, GPIO_FLAG_DIRECTION_OUTPUT);
|
||||
gpio_descriptor_set_level(rst, false);
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
gpio_descriptor_set_level(rst, true);
|
||||
gpio_descriptor_release(rst);
|
||||
}
|
||||
}
|
||||
// The reset pulse exits boot mode; give the controller time to re-init.
|
||||
vTaskDelay(pdMS_TO_TICKS(80));
|
||||
|
||||
// Put the controller into normal reporting mode and verify the identity. The
|
||||
// first read after reset can miss, so retry like the vendor's
|
||||
// cst66xx_updata_tpinfo (read 0xD0030000, expect buf[2]==buf[3]==0xCA).
|
||||
auto* i2c = configuration.i2cController;
|
||||
const uint8_t address = configuration.address;
|
||||
bool confirmed = false;
|
||||
for (int attempt = 0; attempt < 5 && !confirmed; ++attempt) {
|
||||
setNormalMode(i2c, address);
|
||||
uint8_t info[50] = {};
|
||||
if (i2c_controller_write(i2c, address, REG_INFO, sizeof(REG_INFO), I2C_TIMEOUT) == ERROR_NONE &&
|
||||
i2c_controller_read(i2c, address, info, sizeof(info), I2C_TIMEOUT) == ERROR_NONE &&
|
||||
info[2] == 0xCA && info[3] == 0xCA) {
|
||||
confirmed = true;
|
||||
} else {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
}
|
||||
if (confirmed) {
|
||||
LOG_I(TAG, "CST66xx initialised");
|
||||
} else {
|
||||
LOG_W(TAG, "CST66xx identity not confirmed (continuing)");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Cst66xxTouch::stop() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Cst66xxTouch::readPoint(int16_t& x, int16_t& y) {
|
||||
// CST66xx frame (cst66xx_report): write the read-point register, then read 9
|
||||
// bytes. buf[2]=report type (0xFF=position/key), buf[3] low nibble = finger
|
||||
// count, high nibble = key count. The first 5-byte slot (buf[4..8]) is a key
|
||||
// slot when key count > 0, otherwise the first finger; further fingers, if
|
||||
// any, follow at buf[index+...].
|
||||
uint8_t buf[9] = {};
|
||||
error_t err = i2c_controller_write(configuration.i2cController, configuration.address, REG_READ_POINT, sizeof(REG_READ_POINT), I2C_TIMEOUT);
|
||||
if (err == ERROR_NONE) {
|
||||
err = i2c_controller_read(configuration.i2cController, configuration.address, buf, sizeof(buf), I2C_TIMEOUT);
|
||||
}
|
||||
if (err != ERROR_NONE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Acknowledge/clear the frame after every read.
|
||||
i2c_controller_write(configuration.i2cController, configuration.address, CMD_ACK, sizeof(CMD_ACK), I2C_TIMEOUT);
|
||||
|
||||
const uint8_t reportType = buf[2];
|
||||
const uint8_t fingerCount = buf[3] & 0x0F;
|
||||
const uint8_t keyCount = (buf[3] & 0xF0) >> 4;
|
||||
|
||||
// Bezel touch-keys are reported in the first slot (buf[8]): low nibble = key
|
||||
// id, high nibble = state (non-zero = pressed). The controller reports keys
|
||||
// mutually exclusively with finger coordinates, so a key frame never carries
|
||||
// a touch point.
|
||||
if (reportType == 0xFF && keyCount > 0) {
|
||||
const uint8_t keyByte = buf[8];
|
||||
handleBezelKey(keyByte & 0x0F, (keyByte >> 4) != 0);
|
||||
return false;
|
||||
}
|
||||
// No key in this frame: clear the latch so the next press fires once.
|
||||
bezelKeyDown = false;
|
||||
|
||||
if (reportType != 0xFF || fingerCount < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// First finger's slot follows any key slots.
|
||||
const uint8_t index = keyCount * 5;
|
||||
const uint8_t event = buf[index + 8] >> 4; // 0 = up
|
||||
if (event == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int16_t rawX = static_cast<int16_t>(buf[index + 4] | (static_cast<uint16_t>(buf[index + 7] & 0x0F) << 8));
|
||||
int16_t rawY = static_cast<int16_t>(buf[index + 5] | (static_cast<uint16_t>(buf[index + 7] & 0xF0) << 4));
|
||||
|
||||
if (configuration.swapXy) {
|
||||
std::swap(rawX, rawY);
|
||||
}
|
||||
if (configuration.mirrorX) {
|
||||
rawX = static_cast<int16_t>(configuration.width - 1 - rawX);
|
||||
}
|
||||
if (configuration.mirrorY) {
|
||||
rawY = static_cast<int16_t>(configuration.height - 1 - rawY);
|
||||
}
|
||||
|
||||
x = rawX;
|
||||
y = rawY;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bezel touch-key ids as reported by the CST66xx, left to right (confirmed on
|
||||
// hardware). To re-map a button, change the action in the switch below — the
|
||||
// mapping is also documented in NOTES.md.
|
||||
static constexpr uint8_t BEZEL_KEY_HEART = 0; // left
|
||||
static constexpr uint8_t BEZEL_KEY_SPEECH = 1; // centre
|
||||
static constexpr uint8_t BEZEL_KEY_AIRPLANE = 2; // right
|
||||
|
||||
void Cst66xxTouch::handleBezelKey(uint8_t keyId, bool pressed) {
|
||||
if (!pressed) {
|
||||
bezelKeyDown = false;
|
||||
return;
|
||||
}
|
||||
if (bezelKeyDown) {
|
||||
return; // still held; the press edge was already handled
|
||||
}
|
||||
bezelKeyDown = true;
|
||||
|
||||
LOG_D(TAG, "bezel key %u", keyId);
|
||||
|
||||
// Bezel button -> navigation action. Edit these cases to customise:
|
||||
switch (keyId) {
|
||||
case BEZEL_KEY_HEART: // Back: stop the current app (no-op at the launcher root)
|
||||
tt::app::stop();
|
||||
break;
|
||||
// Home (BEZEL_KEY_SPEECH) and Recents (BEZEL_KEY_AIRPLANE) are intentionally
|
||||
// unbound: tt::app::start() always pushes a new instance onto the app
|
||||
// stack, so repeated presses would keep growing it (Launcher/AppList
|
||||
// instances never get cleaned up) and leak memory over time. The app
|
||||
// stack has no API yet to collapse back to an existing instance instead
|
||||
// of pushing a new one; wire these up once that exists.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Cst66xxTouch::readCallback(lv_indev_t* indev, lv_indev_data_t* data) {
|
||||
auto* self = static_cast<Cst66xxTouch*>(lv_indev_get_user_data(indev));
|
||||
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
if (self->readPoint(x, y)) {
|
||||
self->lastX = x;
|
||||
self->lastY = y;
|
||||
data->point.x = x;
|
||||
data->point.y = y;
|
||||
data->state = LV_INDEV_STATE_PRESSED;
|
||||
} else {
|
||||
// Report the release at the last known position.
|
||||
data->point.x = self->lastX;
|
||||
data->point.y = self->lastY;
|
||||
data->state = LV_INDEV_STATE_RELEASED;
|
||||
}
|
||||
}
|
||||
|
||||
bool Cst66xxTouch::startLvgl(lv_display_t* display) {
|
||||
if (indev != nullptr) {
|
||||
return true;
|
||||
}
|
||||
|
||||
indev = lv_indev_create();
|
||||
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
|
||||
lv_indev_set_read_cb(indev, &readCallback);
|
||||
lv_indev_set_display(indev, display);
|
||||
lv_indev_set_user_data(indev, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Cst66xxTouch::stopLvgl() {
|
||||
if (indev == nullptr) {
|
||||
return true;
|
||||
}
|
||||
lv_indev_delete(indev);
|
||||
indev = nullptr;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/hal/touch/TouchDevice.h>
|
||||
#include <tactility/device.h>
|
||||
|
||||
// Capacitive touch for the LilyGO T-Deck Max's Hynitron CST66xx controller at
|
||||
// I2C 0x1A. (The vendor docs label it "CST328", but the vendor HynTouch driver
|
||||
// probes cst66xx first and that is what answers here.) This is a minimal,
|
||||
// single-touch port of the vendor's hyn_cst66xx.c protocol for LVGL pointer input.
|
||||
class Cst66xxTouch final : public tt::hal::touch::TouchDevice {
|
||||
|
||||
public:
|
||||
|
||||
struct Configuration {
|
||||
::Device* i2cController;
|
||||
uint8_t address;
|
||||
uint16_t width;
|
||||
uint16_t height;
|
||||
bool swapXy;
|
||||
bool mirrorX;
|
||||
bool mirrorY;
|
||||
};
|
||||
|
||||
explicit Cst66xxTouch(const Configuration& configuration) : configuration(configuration) {}
|
||||
|
||||
std::string getName() const override { return "CST66xx"; }
|
||||
std::string getDescription() const override { return "CST66xx I2C capacitive touch"; }
|
||||
|
||||
bool start() override;
|
||||
bool stop() override;
|
||||
|
||||
bool supportsLvgl() const override { return true; }
|
||||
bool startLvgl(lv_display_t* display) override;
|
||||
bool stopLvgl() override;
|
||||
lv_indev_t* getLvglIndev() override { return indev; }
|
||||
|
||||
bool supportsTouchDriver() override { return false; }
|
||||
std::shared_ptr<tt::hal::touch::TouchDriver> getTouchDriver() override { return nullptr; }
|
||||
|
||||
private:
|
||||
|
||||
Configuration configuration;
|
||||
lv_indev_t* indev = nullptr;
|
||||
int16_t lastX = 0;
|
||||
int16_t lastY = 0;
|
||||
// The CST66xx also reports the three bezel touch-keys (heart / speech-bubble /
|
||||
// paper-airplane). bezelKeyDown latches a press so we act once on the rising
|
||||
// edge rather than every poll while the key is held.
|
||||
bool bezelKeyDown = false;
|
||||
|
||||
bool readPoint(int16_t& x, int16_t& y);
|
||||
void handleBezelKey(uint8_t keyId, bool pressed);
|
||||
static void readCallback(lv_indev_t* indev, lv_indev_data_t* data);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
#include "Display.h"
|
||||
#include "Cst66xxTouch.h"
|
||||
|
||||
#include <Gdeq031t10Display.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/device.h>
|
||||
|
||||
constexpr auto* TAG = "TdeckmaxDisplay";
|
||||
|
||||
// Pins and I2C address from Xinyuan-LilyGO/T-Deck-MAX's
|
||||
// lib/TDeckMaxBoard/src/TDeckMaxBoard.h and docs/pinmap.md.
|
||||
constexpr auto EPD_SPI_HOST = SPI2_HOST;
|
||||
constexpr auto EPD_PIN_CS = GPIO_NUM_34;
|
||||
constexpr auto EPD_PIN_DC = GPIO_NUM_35;
|
||||
constexpr auto EPD_PIN_RST = GPIO_NUM_9;
|
||||
constexpr auto EPD_PIN_BUSY = GPIO_NUM_37;
|
||||
constexpr uint16_t TOUCH_I2C_ADDRESS = 0x1A;
|
||||
|
||||
static std::shared_ptr<tt::hal::touch::TouchDevice> createTouch() {
|
||||
auto* i2c = device_find_by_name("i2c0");
|
||||
if (i2c == nullptr) {
|
||||
LOG_E(TAG, "i2c0 not found, booting without touch");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The touch reset line is released earlier via the XL9555 expander (see
|
||||
// Configuration.cpp). Orientation flags are starting guesses; adjust after
|
||||
// checking on hardware.
|
||||
const Cst66xxTouch::Configuration configuration = {
|
||||
.i2cController = i2c,
|
||||
.address = TOUCH_I2C_ADDRESS,
|
||||
.width = Gdeq031t10Display::WIDTH,
|
||||
.height = Gdeq031t10Display::HEIGHT,
|
||||
.swapXy = false,
|
||||
.mirrorX = false,
|
||||
.mirrorY = false,
|
||||
};
|
||||
|
||||
return std::make_shared<Cst66xxTouch>(configuration);
|
||||
}
|
||||
|
||||
std::shared_ptr<tt::hal::display::DisplayDevice> createDisplay() {
|
||||
auto configuration = std::make_unique<Gdeq031t10Display::Configuration>(
|
||||
EPD_SPI_HOST,
|
||||
EPD_PIN_CS,
|
||||
EPD_PIN_DC,
|
||||
EPD_PIN_RST,
|
||||
EPD_PIN_BUSY,
|
||||
createTouch(),
|
||||
10'000'000,
|
||||
// Default to a fast (~1s) refresh for the automatic ghost-clears so the
|
||||
// full-screen flash they cause is as short as possible.
|
||||
Gdeq031t10Display::RefreshMode::Fast
|
||||
);
|
||||
|
||||
return std::make_shared<Gdeq031t10Display>(std::move(configuration));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/hal/display/DisplayDevice.h>
|
||||
|
||||
std::shared_ptr<tt::hal::display::DisplayDevice> createDisplay();
|
||||
@@ -0,0 +1,249 @@
|
||||
#include "TdeckmaxKeyboard.h"
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <driver/i2c.h>
|
||||
#include <driver/gpio.h>
|
||||
|
||||
constexpr auto* TAG = "TdeckmaxKeyboard";
|
||||
|
||||
// Keyboard backlight (LED_PWM), GPIO42 on the T-Deck Max.
|
||||
constexpr auto BACKLIGHT = GPIO_NUM_42;
|
||||
|
||||
constexpr auto KB_ROWS = 4;
|
||||
constexpr auto KB_COLS = 10;
|
||||
|
||||
// Keymaps are written in the vendor's row/column orientation
|
||||
// (Xinyuan-LilyGO/T-Deck-MAX examples/factory/peri_keypad.cpp). The TCA8418
|
||||
// columns are wired in reverse, so the vendor reads keymap[row][9 - col]. We do
|
||||
// the same reversal in processKeyboard(), which keeps these tables a direct,
|
||||
// verifiable copy of the vendor layout.
|
||||
//
|
||||
// Modifier keys (ALT and SYM) are blanked here ('\0') and handled by position:
|
||||
// ALT -> shift/uppercase, at vendor column 0 of row 2
|
||||
// SYM -> symbol layer, at vendor column 8 of row 3
|
||||
|
||||
// Lowercase (base) layer
|
||||
static constexpr char keymap_lc[KB_ROWS][KB_COLS] = {
|
||||
{'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'},
|
||||
{'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', LV_KEY_BACKSPACE},
|
||||
{'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '$', LV_KEY_ENTER},
|
||||
{'\0', '\0', '\0', '\0', '\0', LV_KEY_PREV, '0', ' ', '\0', LV_KEY_NEXT}
|
||||
};
|
||||
|
||||
// Uppercase layer (ALT held or caps toggled)
|
||||
static constexpr char keymap_uc[KB_ROWS][KB_COLS] = {
|
||||
{'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'},
|
||||
{'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', LV_KEY_BACKSPACE},
|
||||
{'\0', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '$', LV_KEY_ENTER},
|
||||
{'\0', '\0', '\0', '\0', '\0', LV_KEY_PREV, '0', ' ', '\0', LV_KEY_NEXT}
|
||||
};
|
||||
|
||||
// Symbol layer (SYM held). The T-Deck Max silkscreen for the symbol layer is
|
||||
// not documented in the vendor sources, so these are sensible defaults; adjust
|
||||
// to match the printed keys once verified on hardware.
|
||||
static constexpr char keymap_sy[KB_ROWS][KB_COLS] = {
|
||||
{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'},
|
||||
{'@', '#', '+', '-', '*', '/', '(', ')', '_', LV_KEY_BACKSPACE},
|
||||
{'\0', '!', '?', ';', ':', '\'', '"', ',', '.', LV_KEY_ENTER},
|
||||
{'\0', '\0', '\0', '\0', '\0', LV_KEY_PREV, '0', ' ', '\0', LV_KEY_NEXT}
|
||||
};
|
||||
|
||||
void TdeckmaxKeyboard::readCallback(lv_indev_t* indev, lv_indev_data_t* data) {
|
||||
auto keyboard = static_cast<TdeckmaxKeyboard*>(lv_indev_get_user_data(indev));
|
||||
|
||||
// Emit the release edge for the previous key first: LVGL's keypad handling
|
||||
// only delivers a key on a RELEASED->PRESSED transition, so two different
|
||||
// keys reported PRESSED back-to-back would swallow the second one.
|
||||
if (keyboard->lastKeyNeedsRelease) {
|
||||
keyboard->lastKeyNeedsRelease = false;
|
||||
data->key = keyboard->lastKey;
|
||||
data->state = LV_INDEV_STATE_RELEASED;
|
||||
data->continue_reading = uxQueueMessagesWaiting(keyboard->queue) > 0;
|
||||
return;
|
||||
}
|
||||
|
||||
char keypress = 0;
|
||||
if (xQueueReceive(keyboard->queue, &keypress, 0) == pdPASS) {
|
||||
keyboard->lastKey = static_cast<uint32_t>(keypress);
|
||||
keyboard->lastKeyNeedsRelease = true;
|
||||
data->key = keyboard->lastKey;
|
||||
data->state = LV_INDEV_STATE_PRESSED;
|
||||
// Drain the whole queue in this read cycle so a typing burst lands in
|
||||
// one render pass (and thus a single e-paper refresh).
|
||||
data->continue_reading = true;
|
||||
} else {
|
||||
data->key = 0;
|
||||
data->state = LV_INDEV_STATE_RELEASED;
|
||||
}
|
||||
}
|
||||
|
||||
void TdeckmaxKeyboard::processKeyboard() {
|
||||
bool anykey_pressed = false;
|
||||
|
||||
// Each update() pops one event from the TCA8418's FIFO. Drain it fully per
|
||||
// poll (bounded, in case of a misbehaving bus) so a fast typing burst isn't
|
||||
// throttled to one event per poll period. Handling each event as a discrete
|
||||
// press/release edge also means a key held across another key's press (fast
|
||||
// typing rollover) no longer re-sends its character.
|
||||
for (int drained = 0; drained < 16 && keypad->update(); drained++) {
|
||||
if (keypad->released_key_count > 0) {
|
||||
// Release event: only modifier state cares about releases.
|
||||
auto row = keypad->released_list[0].row;
|
||||
auto vcol = (KB_COLS - 1) - keypad->released_list[0].col;
|
||||
|
||||
if ((row == 2) && (vcol == 0)) {
|
||||
shiftPressed = false; // ALT key
|
||||
}
|
||||
if ((row == 3) && (vcol == 8)) {
|
||||
symPressed = false; // SYM key
|
||||
}
|
||||
} else if (keypad->pressed_key_count > 0) {
|
||||
// Press event: the key that caused it is the newest list entry.
|
||||
auto row = keypad->pressed_list[keypad->pressed_key_count - 1].row;
|
||||
auto vcol = (KB_COLS - 1) - keypad->pressed_list[keypad->pressed_key_count - 1].col;
|
||||
anykey_pressed = true;
|
||||
|
||||
if ((row == 2) && (vcol == 0)) {
|
||||
shiftPressed = true; // ALT key
|
||||
} else if ((row == 3) && (vcol == 8)) {
|
||||
symPressed = true; // SYM key
|
||||
} else {
|
||||
char chr;
|
||||
if (symPressed) {
|
||||
chr = keymap_sy[row][vcol];
|
||||
} else if (shiftPressed || capToggle) {
|
||||
chr = keymap_uc[row][vcol];
|
||||
} else {
|
||||
chr = keymap_lc[row][vcol];
|
||||
}
|
||||
|
||||
// Non-blocking: this runs on the periodic input timer, so a full
|
||||
// queue (reader stalled) must drop the keystroke rather than
|
||||
// stall this callback and the timer's other periodic work.
|
||||
if (chr != '\0' && xQueueSend(queue, &chr, 0) != pdPASS) {
|
||||
LOG_W(TAG, "Keyboard queue full, dropping keystroke");
|
||||
}
|
||||
}
|
||||
|
||||
if ((symPressed && shiftPressed) && capToggleArmed) {
|
||||
capToggle = !capToggle;
|
||||
capToggleArmed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!symPressed && !shiftPressed) && !capToggleArmed) {
|
||||
capToggleArmed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anykey_pressed) {
|
||||
makeBacklightImpulse();
|
||||
}
|
||||
}
|
||||
|
||||
bool TdeckmaxKeyboard::startLvgl(lv_display_t* display) {
|
||||
backlightOkay = initBacklight(BACKLIGHT, 30000, LEDC_TIMER_0, LEDC_CHANNEL_1);
|
||||
keypad->init(KB_ROWS, KB_COLS);
|
||||
|
||||
assert(inputTimer == nullptr);
|
||||
inputTimer = std::make_unique<tt::Timer>(tt::Timer::Type::Periodic, tt::kernel::millisToTicks(20), [this] {
|
||||
processKeyboard();
|
||||
});
|
||||
|
||||
assert(backlightImpulseTimer == nullptr);
|
||||
backlightImpulseTimer = std::make_unique<tt::Timer>(tt::Timer::Type::Periodic, tt::kernel::millisToTicks(50), [this] {
|
||||
processBacklightImpulse();
|
||||
});
|
||||
|
||||
kbHandle = lv_indev_create();
|
||||
lv_indev_set_type(kbHandle, LV_INDEV_TYPE_KEYPAD);
|
||||
lv_indev_set_read_cb(kbHandle, &readCallback);
|
||||
lv_indev_set_display(kbHandle, display);
|
||||
lv_indev_set_user_data(kbHandle, this);
|
||||
|
||||
inputTimer->start();
|
||||
backlightImpulseTimer->start();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TdeckmaxKeyboard::stopLvgl() {
|
||||
assert(inputTimer);
|
||||
inputTimer->stop();
|
||||
inputTimer = nullptr;
|
||||
|
||||
assert(backlightImpulseTimer);
|
||||
backlightImpulseTimer->stop();
|
||||
backlightImpulseTimer = nullptr;
|
||||
|
||||
lv_indev_delete(kbHandle);
|
||||
kbHandle = nullptr;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TdeckmaxKeyboard::isAttached() const {
|
||||
return i2c_controller_has_device_at_address(keypad->getController(), keypad->getAddress(), 100) == ERROR_NONE;
|
||||
}
|
||||
|
||||
bool TdeckmaxKeyboard::initBacklight(gpio_num_t pin, uint32_t frequencyHz, ledc_timer_t timer, ledc_channel_t channel) {
|
||||
backlightPin = pin;
|
||||
backlightTimer = timer;
|
||||
backlightChannel = channel;
|
||||
|
||||
ledc_timer_config_t ledc_timer = {
|
||||
.speed_mode = LEDC_LOW_SPEED_MODE,
|
||||
.duty_resolution = LEDC_TIMER_8_BIT,
|
||||
.timer_num = backlightTimer,
|
||||
.freq_hz = frequencyHz,
|
||||
.clk_cfg = LEDC_AUTO_CLK,
|
||||
.deconfigure = false
|
||||
};
|
||||
|
||||
if (ledc_timer_config(&ledc_timer) != ESP_OK) {
|
||||
LOG_E(TAG, "Backlight timer config failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
ledc_channel_config_t ledc_channel = {
|
||||
.gpio_num = backlightPin,
|
||||
.speed_mode = LEDC_LOW_SPEED_MODE,
|
||||
.channel = backlightChannel,
|
||||
.intr_type = LEDC_INTR_DISABLE,
|
||||
.timer_sel = backlightTimer,
|
||||
.duty = 0,
|
||||
.hpoint = 0,
|
||||
.sleep_mode = LEDC_SLEEP_MODE_NO_ALIVE_NO_PD,
|
||||
.flags = {
|
||||
.output_invert = 0
|
||||
}
|
||||
};
|
||||
|
||||
if (ledc_channel_config(&ledc_channel) != ESP_OK) {
|
||||
LOG_E(TAG, "Backlight channel config failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TdeckmaxKeyboard::setBacklightDuty(uint8_t duty) {
|
||||
if (!backlightOkay) {
|
||||
LOG_E(TAG, "Backlight not ready");
|
||||
return false;
|
||||
}
|
||||
return (ledc_set_duty(LEDC_LOW_SPEED_MODE, backlightChannel, duty) == ESP_OK) &&
|
||||
(ledc_update_duty(LEDC_LOW_SPEED_MODE, backlightChannel) == ESP_OK);
|
||||
}
|
||||
|
||||
void TdeckmaxKeyboard::makeBacklightImpulse() {
|
||||
backlightImpulseDuty = 255;
|
||||
setBacklightDuty(backlightImpulseDuty);
|
||||
}
|
||||
|
||||
void TdeckmaxKeyboard::processBacklightImpulse() {
|
||||
if (backlightImpulseDuty > 0) {
|
||||
backlightImpulseDuty--;
|
||||
setBacklightDuty(backlightImpulseDuty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/hal/keyboard/KeyboardDevice.h>
|
||||
#include <Tactility/Timer.h>
|
||||
|
||||
#include <Tca8418.h>
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/ledc.h>
|
||||
#include <freertos/queue.h>
|
||||
|
||||
// QWERTY keyboard for the LilyGO T-Deck (Pro) Max.
|
||||
//
|
||||
// The keyboard is a TCA8418 4x10 matrix scanner on the shared I2C bus at 0x34.
|
||||
// The keyboard reset line is wired through the XL9555 IO expander (P11), so it
|
||||
// must be released before this device is started (see Configuration.cpp).
|
||||
//
|
||||
// Pins, address and keymap are taken from Xinyuan-LilyGO/T-Deck-MAX:
|
||||
// examples/factory/peri_keypad.cpp and lib/TDeckMaxBoard/src/TDeckMaxBoard.h.
|
||||
class TdeckmaxKeyboard final : public tt::hal::keyboard::KeyboardDevice {
|
||||
|
||||
lv_indev_t* kbHandle = nullptr;
|
||||
gpio_num_t backlightPin = GPIO_NUM_NC;
|
||||
ledc_timer_t backlightTimer;
|
||||
ledc_channel_t backlightChannel;
|
||||
bool backlightOkay = false;
|
||||
int backlightImpulseDuty = 0;
|
||||
QueueHandle_t queue = nullptr;
|
||||
// LVGL only registers a key on a RELEASED->PRESSED edge, so readCallback
|
||||
// alternates press/release per queued key; this tracks the pending release.
|
||||
uint32_t lastKey = 0;
|
||||
bool lastKeyNeedsRelease = false;
|
||||
|
||||
std::shared_ptr<Tca8418> keypad;
|
||||
std::unique_ptr<tt::Timer> inputTimer;
|
||||
std::unique_ptr<tt::Timer> backlightImpulseTimer;
|
||||
|
||||
bool shiftPressed = false;
|
||||
bool symPressed = false;
|
||||
bool capToggle = false;
|
||||
bool capToggleArmed = true;
|
||||
|
||||
bool initBacklight(gpio_num_t pin, uint32_t frequencyHz, ledc_timer_t timer, ledc_channel_t channel);
|
||||
void processKeyboard();
|
||||
void processBacklightImpulse();
|
||||
|
||||
static void readCallback(lv_indev_t* indev, lv_indev_data_t* data);
|
||||
|
||||
public:
|
||||
|
||||
explicit TdeckmaxKeyboard(const std::shared_ptr<Tca8418>& tca) : keypad(tca) {
|
||||
queue = xQueueCreate(20, sizeof(char));
|
||||
}
|
||||
|
||||
~TdeckmaxKeyboard() override {
|
||||
vQueueDelete(queue);
|
||||
}
|
||||
|
||||
std::string getName() const override { return "T-Deck Max Keyboard"; }
|
||||
std::string getDescription() const override { return "T-Deck Max TCA8418 I2C matrix keyboard"; }
|
||||
|
||||
bool startLvgl(lv_display_t* display) override;
|
||||
bool stopLvgl() override;
|
||||
|
||||
bool isAttached() const override;
|
||||
lv_indev_t* getLvglIndev() override { return kbHandle; }
|
||||
|
||||
bool setBacklightDuty(uint8_t duty);
|
||||
void makeBacklightImpulse();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
#include <tactility/module.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
static error_t start() {
|
||||
// Empty for now
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static error_t stop() {
|
||||
// Empty for now
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
struct Module lilygo_tdeck_max_module = {
|
||||
.name = "lilygo-tdeck-max",
|
||||
.start = start,
|
||||
.stop = stop,
|
||||
.symbols = nullptr,
|
||||
.internal = nullptr
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
general.vendor=LilyGO
|
||||
general.name=T-Deck Max
|
||||
# SD card support is not yet working; remove once it is functional.
|
||||
general.incubating=true
|
||||
|
||||
apps.launcherAppId=Launcher
|
||||
|
||||
hardware.target=ESP32S3
|
||||
hardware.flashSize=16MB
|
||||
hardware.flashMode=DIO
|
||||
hardware.spiRam=true
|
||||
hardware.spiRamMode=AUTO
|
||||
hardware.spiRamSpeed=80M
|
||||
hardware.tinyUsb=true
|
||||
hardware.esptoolFlashFreq=80M
|
||||
hardware.bluetooth=true
|
||||
|
||||
# Internal until the SD card is verified working on this board; switch to SD then.
|
||||
storage.userDataLocation=Internal
|
||||
|
||||
display.size=3.1"
|
||||
display.shape=rectangle
|
||||
display.dpi=128
|
||||
|
||||
# The GDEQ031T10 e-paper is monochrome, but its driver renders via
|
||||
# LV_COLOR_FORMAT_I1 on its own display. The global LVGL color depth must stay
|
||||
# at 16: esp_lvgl_port refuses to compile with LV_COLOR_DEPTH_1 set.
|
||||
lvgl.colorDepth=16
|
||||
# Monochrome theme: the default colour theme renders accent-coloured UI that
|
||||
# thresholds to invisible on a 1bpp panel (enables CONFIG_LV_USE_THEME_MONO).
|
||||
lvgl.theme=Mono
|
||||
@@ -0,0 +1,4 @@
|
||||
dependencies:
|
||||
- Platforms/platform-esp32
|
||||
- Drivers/xl9555-module
|
||||
dts: lilygo,tdeck-max.dts
|
||||
@@ -0,0 +1,58 @@
|
||||
/dts-v1/;
|
||||
|
||||
#include <tactility/bindings/root.h>
|
||||
#include <tactility/bindings/esp32_ble.h>
|
||||
#include <tactility/bindings/esp32_gpio.h>
|
||||
#include <tactility/bindings/esp32_i2c_master.h>
|
||||
#include <tactility/bindings/esp32_spi.h>
|
||||
#include <tactility/bindings/esp32_sdspi.h>
|
||||
#include <bindings/xl9555.h>
|
||||
|
||||
// Reference: https://github.com/Xinyuan-LilyGO/T-Deck-MAX
|
||||
// lib/TDeckMaxBoard/src/TDeckMaxBoard.h, docs/pinmap.md
|
||||
/ {
|
||||
compatible = "root";
|
||||
model = "LilyGO T-Deck Max";
|
||||
|
||||
ble0 {
|
||||
compatible = "espressif,esp32-ble";
|
||||
};
|
||||
|
||||
gpio0 {
|
||||
compatible = "espressif,esp32-gpio";
|
||||
gpio-count = <49>;
|
||||
};
|
||||
|
||||
i2c0 {
|
||||
compatible = "espressif,esp32-i2c-master";
|
||||
port = <I2C_NUM_0>;
|
||||
clock-frequency = <100000>;
|
||||
pin-sda = <&gpio0 13 GPIO_FLAG_NONE>;
|
||||
pin-scl = <&gpio0 14 GPIO_FLAG_NONE>;
|
||||
|
||||
xl9555 {
|
||||
compatible = "xlsemi,xl9555";
|
||||
reg = <0x20>;
|
||||
};
|
||||
};
|
||||
|
||||
spi0 {
|
||||
compatible = "espressif,esp32-spi";
|
||||
host = <SPI2_HOST>;
|
||||
cs-gpios = <&gpio0 34 GPIO_FLAG_NONE>, // 0: EPD display
|
||||
<&gpio0 48 GPIO_FLAG_NONE>, // 1: SD card
|
||||
<&gpio0 3 GPIO_FLAG_NONE>; // 2: LoRa radio (SX1262, not wired up yet)
|
||||
pin-mosi = <&gpio0 33 GPIO_FLAG_NONE>;
|
||||
pin-miso = <&gpio0 47 GPIO_FLAG_NONE>;
|
||||
pin-sclk = <&gpio0 36 GPIO_FLAG_NONE>;
|
||||
// The EPD pushes its whole 9600-byte framebuffer in one SPI write, which
|
||||
// exceeds the default ~4 KB transfer limit. Raise the bus limit to fit it.
|
||||
max-transfer-size = <65536>;
|
||||
|
||||
sdcard@1 {
|
||||
compatible = "espressif,esp32-sdspi";
|
||||
status = "disabled";
|
||||
frequency-khz = <20000>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRC_DIRS "Source"
|
||||
INCLUDE_DIRS "Source"
|
||||
REQUIRES Tactility driver
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
# GDEQ031T10
|
||||
|
||||
Display driver for the GoodDisplay GDEQ031T10, a 3.1" 320x240 SPI e-paper panel
|
||||
(UC8253-family controller) used by the LilyGO T-Deck Pro/Max.
|
||||
|
||||
The command sequence (panel setting, power on/off, fast/partial LUT timings, deep
|
||||
sleep) is ported from the vendor reference driver in
|
||||
[Xinyuan-LilyGO/T-Deck-MAX](https://github.com/Xinyuan-LilyGO/T-Deck-MAX)
|
||||
(`examples/Elink_paper/GDEQ031T10_Arduino/Display_EPD_W21.cpp`).
|
||||
|
||||
Unlike `EPDiyDisplay` (which targets parallel-bus "EPD47"-style panels via the
|
||||
`epdiy` library), this panel uses a simple 4-wire SPI interface (CS/DC/RST/BUSY +
|
||||
SCK/MOSI), so it's driven directly with ESP-IDF's `spi_master` and `gpio` APIs
|
||||
rather than `epdiy` or `esp_lcd_panel`.
|
||||
|
||||
Each LVGL flush is staged and handed off to a dedicated refresh task (the physical
|
||||
refresh takes hundreds of ms to over a second, too long to run in LVGL's flush
|
||||
callback without stalling input). The task diffs the new frame against a shadow of
|
||||
what the panel currently holds and, where possible, performs a windowed partial
|
||||
refresh of just the changed region instead of redrawing the whole panel. A full
|
||||
refresh is still done on the first draw, on an explicit `requestFullRefresh()`,
|
||||
when a change covers most of the screen, or periodically to clear the ghosting
|
||||
that partial updates accumulate (gated by `MAX_PARTIAL_REFRESHES`, with a
|
||||
localized ghost-clear preferred over a full flash when the accumulated changes are
|
||||
confined to a small region). The panel's charge pump is powered off once the
|
||||
refresh task's queue goes idle, not after every single refresh. Four refresh
|
||||
modes are available, trading speed for quality:
|
||||
|
||||
- `Full` (~3s) - best quality, used by default
|
||||
- `Fast` (~1.0s)
|
||||
- `Slow` (~1.5s)
|
||||
- `Partial` (~0.5s) - most ghosting
|
||||
|
||||
`requestFullRefresh()` forces the next flush to use `Full` mode, useful for
|
||||
clearing up ghosting accumulated from a run of fast/partial refreshes.
|
||||
@@ -0,0 +1,638 @@
|
||||
#include "Gdeq031t10Display.h"
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <themes/mono/lv_theme_mono.h>
|
||||
// Full lv_theme_t definition (opaque in public lvgl.h) — needed to extend the
|
||||
// mono theme with a custom apply callback, per LVGL's theme-extension pattern.
|
||||
#include <themes/lv_theme_private.h>
|
||||
|
||||
constexpr auto* TAG = "GDEQ031T10";
|
||||
|
||||
// UC8253-family commands, ported from Xinyuan-LilyGO/T-Deck-MAX's
|
||||
// Display_EPD_W21.cpp reference driver.
|
||||
namespace {
|
||||
constexpr uint8_t CMD_PANEL_SETTING = 0x00;
|
||||
constexpr uint8_t CMD_POWER_ON_OFF = 0x02; // shared opcode: power on when followed by 0x04, power off as standalone 0x02
|
||||
constexpr uint8_t CMD_POWER_ON = 0x04;
|
||||
constexpr uint8_t CMD_DEEP_SLEEP = 0x07;
|
||||
constexpr uint8_t CMD_DATA_START_OLD = 0x10;
|
||||
constexpr uint8_t CMD_DISPLAY_REFRESH = 0x12;
|
||||
constexpr uint8_t CMD_DATA_START_NEW = 0x13;
|
||||
constexpr uint8_t CMD_VCOM_DATA_INTERVAL = 0x50;
|
||||
constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90;
|
||||
constexpr uint8_t CMD_PARTIAL_IN = 0x91;
|
||||
constexpr uint8_t CMD_PARTIAL_OUT = 0x92;
|
||||
constexpr uint8_t CMD_FAST_MODE_ENABLE = 0xE0;
|
||||
constexpr uint8_t CMD_FAST_MODE_TIMING = 0xE5;
|
||||
|
||||
constexpr uint8_t DEEP_SLEEP_CHECK_CODE = 0xA5;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::writeCommand(uint8_t command) {
|
||||
gpio_set_level(configuration->pinDc, 0);
|
||||
spi_transaction_t transaction = {};
|
||||
transaction.length = 8;
|
||||
transaction.tx_buffer = &command;
|
||||
if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) {
|
||||
LOG_E(TAG, "SPI command transfer failed");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::writeData(const uint8_t* data, size_t length) {
|
||||
gpio_set_level(configuration->pinDc, 1);
|
||||
spi_transaction_t transaction = {};
|
||||
transaction.length = length * 8;
|
||||
transaction.tx_buffer = data;
|
||||
if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) {
|
||||
LOG_E(TAG, "SPI data transfer failed");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::waitWhileBusy() const {
|
||||
// BUSY pin reads high when the controller is idle/ready.
|
||||
constexpr TickType_t timeout = pdMS_TO_TICKS(5000);
|
||||
const TickType_t start = xTaskGetTickCount();
|
||||
while (gpio_get_level(configuration->pinBusy) != 1) {
|
||||
if (xTaskGetTickCount() - start > timeout) {
|
||||
LOG_E(TAG, "Timed out waiting for panel BUSY");
|
||||
return false;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(2));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::reset() const {
|
||||
gpio_set_level(configuration->pinReset, 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
gpio_set_level(configuration->pinReset, 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::initFull() {
|
||||
reset();
|
||||
bool ok = writeCommand(CMD_PANEL_SETTING);
|
||||
ok = ok && writeData(configuration->mirror180 ? 0x13 : 0x1F);
|
||||
ok = ok && writeCommand(CMD_POWER_ON);
|
||||
ok = ok && waitWhileBusy();
|
||||
if (!ok) {
|
||||
LOG_E(TAG, "Full init failed");
|
||||
return false;
|
||||
}
|
||||
panelMode = RefreshMode::Full;
|
||||
panelPowerOn = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::initFast() {
|
||||
if (!initFull()) {
|
||||
return false;
|
||||
}
|
||||
bool ok = writeCommand(CMD_FAST_MODE_ENABLE);
|
||||
ok = ok && writeData(0x02);
|
||||
ok = ok && writeCommand(CMD_FAST_MODE_TIMING);
|
||||
ok = ok && writeData(0x5A); // ~1.0s
|
||||
if (!ok) {
|
||||
LOG_E(TAG, "Fast mode init failed");
|
||||
return false;
|
||||
}
|
||||
panelMode = RefreshMode::Fast;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::initSlow() {
|
||||
if (!initFull()) {
|
||||
return false;
|
||||
}
|
||||
bool ok = writeCommand(CMD_FAST_MODE_ENABLE);
|
||||
ok = ok && writeData(0x02);
|
||||
ok = ok && writeCommand(CMD_FAST_MODE_TIMING);
|
||||
ok = ok && writeData(0x6E); // ~1.5s
|
||||
if (!ok) {
|
||||
LOG_E(TAG, "Slow mode init failed");
|
||||
return false;
|
||||
}
|
||||
panelMode = RefreshMode::Slow;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::initPartial() {
|
||||
if (!initFull()) {
|
||||
return false;
|
||||
}
|
||||
bool ok = writeCommand(CMD_FAST_MODE_ENABLE);
|
||||
ok = ok && writeData(0x02);
|
||||
ok = ok && writeCommand(CMD_FAST_MODE_TIMING);
|
||||
ok = ok && writeData(0x79);
|
||||
ok = ok && writeCommand(CMD_VCOM_DATA_INTERVAL);
|
||||
ok = ok && writeData(0xD7);
|
||||
if (!ok) {
|
||||
LOG_E(TAG, "Partial mode init failed");
|
||||
return false;
|
||||
}
|
||||
panelMode = RefreshMode::Partial;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::ensurePanelReady(RefreshMode mode) {
|
||||
if (panelMode != mode) {
|
||||
// A mode change needs the full init path: it starts with reset(), which
|
||||
// restores register defaults (required when leaving partial mode, whose
|
||||
// VCOM/data-interval setting would otherwise linger).
|
||||
switch (mode) {
|
||||
case RefreshMode::Full: return initFull();
|
||||
case RefreshMode::Fast: return initFast();
|
||||
case RefreshMode::Slow: return initSlow();
|
||||
case RefreshMode::Partial: return initPartial();
|
||||
}
|
||||
return false;
|
||||
} else if (!panelPowerOn) {
|
||||
// Registers still hold the mode; only the charge pump was idled.
|
||||
if (!writeCommand(CMD_POWER_ON) || !waitWhileBusy()) {
|
||||
LOG_E(TAG, "Panel did not become ready after power-on");
|
||||
return false;
|
||||
}
|
||||
panelPowerOn = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::powerOff() {
|
||||
// Command the panel off regardless of whether BUSY confirms it: retrying
|
||||
// forever here would just as likely hang, and a stuck-BUSY panel is
|
||||
// already unusable either way.
|
||||
bool ok = writeCommand(CMD_POWER_ON_OFF); // 0x02 standalone = power off
|
||||
if (!ok || !waitWhileBusy()) {
|
||||
LOG_E(TAG, "Panel did not confirm power-off");
|
||||
ok = false;
|
||||
}
|
||||
panelPowerOn = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::queueRefresh() {
|
||||
xSemaphoreTake(bufferMutex, portMAX_DELAY);
|
||||
// renderFramebuffer always holds a complete frame (single buffer, full
|
||||
// render mode), so the staged copy is self-contained.
|
||||
std::memcpy(pendingFramebuffer.get(), renderFramebuffer.get() + LVGL_I1_PALETTE_SIZE, FRAMEBUFFER_SIZE);
|
||||
framePending = true;
|
||||
xSemaphoreGive(bufferMutex);
|
||||
xTaskNotifyGive(refreshTask);
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::refreshTaskMain(void* parameter) {
|
||||
auto* self = static_cast<Gdeq031t10Display*>(parameter);
|
||||
self->runRefreshTask();
|
||||
xSemaphoreGive(self->refreshTaskExited);
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::runRefreshTask() {
|
||||
while (true) {
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
if (refreshTaskShouldExit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain staged frames latest-wins: everything LVGL flushed while the
|
||||
// previous refresh was in progress collapses into one panel update.
|
||||
// A frame staged while the panel is off stays staged; the power-on
|
||||
// path kicks this task to draw it.
|
||||
while (!refreshTaskShouldExit && powered) {
|
||||
xSemaphoreTake(bufferMutex, portMAX_DELAY);
|
||||
const bool havePending = framePending;
|
||||
if (havePending) {
|
||||
std::swap(pendingFramebuffer, taskFramebuffer);
|
||||
framePending = false;
|
||||
}
|
||||
xSemaphoreGive(bufferMutex);
|
||||
|
||||
if (!havePending) {
|
||||
break;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
// The pipeline went idle: drop the charge pump now rather than on a
|
||||
// timer. The mode registers survive a power-off, so the next refresh
|
||||
// only pays a short power-on wait (on this task, invisible to the UI).
|
||||
xSemaphoreTake(panelMutex, portMAX_DELAY);
|
||||
if (initialized && powered && panelPowerOn) {
|
||||
powerOff();
|
||||
}
|
||||
xSemaphoreGive(panelMutex);
|
||||
}
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::refresh() {
|
||||
const uint8_t* renderBitmap = taskFramebuffer.get();
|
||||
constexpr int BYTES_PER_ROW = WIDTH / 8;
|
||||
|
||||
// Find the bounding box of bytes that differ from what the panel holds. The
|
||||
// panel stores the inverted (shadow) polarity, so compare against ~render.
|
||||
int firstCol = BYTES_PER_ROW, lastCol = -1, firstRow = HEIGHT, lastRow = -1;
|
||||
for (int row = 0; row < HEIGHT; row++) {
|
||||
const size_t base = static_cast<size_t>(row) * BYTES_PER_ROW;
|
||||
for (int col = 0; col < BYTES_PER_ROW; col++) {
|
||||
if (static_cast<uint8_t>(~renderBitmap[base + col]) != shadowFramebuffer[base + col]) {
|
||||
if (col < firstCol) firstCol = col;
|
||||
if (col > lastCol) lastCol = col;
|
||||
if (row < firstRow) firstRow = row;
|
||||
if (row > lastRow) lastRow = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bool nothingChanged = (lastCol < 0);
|
||||
if (nothingChanged && !forceFullRefresh) {
|
||||
return; // panel already shows this frame; don't refresh needlessly
|
||||
}
|
||||
|
||||
// A change covering most of the screen or an explicit request warrants a
|
||||
// full refresh; otherwise refresh just the box.
|
||||
const int changedRows = nothingChanged ? 0 : (lastRow - firstRow + 1);
|
||||
const bool largeChange = changedRows > (HEIGHT * 3 / 4);
|
||||
|
||||
xSemaphoreTake(panelMutex, portMAX_DELAY);
|
||||
if (!powered || !initialized) {
|
||||
// The display was turned off/stopped while this frame was staged; the
|
||||
// shadow mismatch makes the frame redraw after the next power-on.
|
||||
xSemaphoreGive(panelMutex);
|
||||
return;
|
||||
}
|
||||
if (forceFullRefresh || largeChange || nothingChanged) {
|
||||
// Explicit requests get the best-quality LUT; automatic ghost-clears use
|
||||
// the configured (typically faster) mode.
|
||||
refreshFull(forceFullRefresh ? RefreshMode::Full : currentRefreshMode.load());
|
||||
forceFullRefresh = false;
|
||||
partialRefreshCount = 0;
|
||||
ghostAreaValid = false;
|
||||
xSemaphoreGive(panelMutex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Grow the ghost union with this change so the gate sees where partial
|
||||
// updates have accumulated since the last clear.
|
||||
if (!ghostAreaValid) {
|
||||
ghostFirstCol = firstCol;
|
||||
ghostLastCol = lastCol;
|
||||
ghostFirstRow = firstRow;
|
||||
ghostLastRow = lastRow;
|
||||
ghostAreaValid = true;
|
||||
} else {
|
||||
ghostFirstCol = std::min(ghostFirstCol, firstCol);
|
||||
ghostLastCol = std::max(ghostLastCol, lastCol);
|
||||
ghostFirstRow = std::min(ghostFirstRow, firstRow);
|
||||
ghostLastRow = std::max(ghostLastRow, lastRow);
|
||||
}
|
||||
|
||||
if (partialRefreshCount >= MAX_PARTIAL_REFRESHES) {
|
||||
// Ghost-clear gate. If the accumulated churn is confined to a small
|
||||
// region (a blinking cursor, a line being typed into), scrub just that
|
||||
// window — a full-screen flash there is needless and distracting. Only
|
||||
// widespread churn earns a whole-panel refresh.
|
||||
const int unionWidth = (ghostLastCol - ghostFirstCol + 1) * 8;
|
||||
const int unionHeight = ghostLastRow - ghostFirstRow + 1;
|
||||
const bool unionIsLarge = unionWidth * unionHeight * 4 >= WIDTH * HEIGHT; // >= 25% of the panel
|
||||
if (unionIsLarge) {
|
||||
refreshFull(currentRefreshMode);
|
||||
} else {
|
||||
refreshWindow(ghostFirstCol, ghostLastCol, ghostFirstRow, ghostLastRow, true);
|
||||
}
|
||||
partialRefreshCount = 0;
|
||||
ghostAreaValid = false;
|
||||
} else {
|
||||
refreshWindow(firstCol, lastCol, firstRow, lastRow, false);
|
||||
partialRefreshCount++;
|
||||
}
|
||||
xSemaphoreGive(panelMutex);
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::refreshFull(RefreshMode mode) {
|
||||
if (!ensurePanelReady(mode)) {
|
||||
LOG_E(TAG, "Skipping full refresh: panel not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t* renderBitmap = taskFramebuffer.get();
|
||||
|
||||
// shadowFramebuffer keeps the panel-polarity copy of the last frame for the
|
||||
// controller's old/new differential refresh; LVGL's I1 polarity is inverted.
|
||||
if (!writeCommand(CMD_DATA_START_OLD) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) {
|
||||
LOG_E(TAG, "Failed to send old frame data");
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < FRAMEBUFFER_SIZE; i++) {
|
||||
shadowFramebuffer[i] = static_cast<uint8_t>(~renderBitmap[i]);
|
||||
}
|
||||
if (!writeCommand(CMD_DATA_START_NEW) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) {
|
||||
LOG_E(TAG, "Failed to send new frame data");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!writeCommand(CMD_DISPLAY_REFRESH)) {
|
||||
LOG_E(TAG, "Failed to trigger display refresh");
|
||||
return;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(1)); // datasheet requires >=200us settle before polling BUSY
|
||||
if (!waitWhileBusy()) {
|
||||
LOG_E(TAG, "Full refresh did not complete");
|
||||
}
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::refreshWindow(int firstByteCol, int lastByteCol, int firstRow, int lastRow, bool scrubGhosts) {
|
||||
// Partial LUT (fast waveform via temperature force) on a sub-region only. The
|
||||
// RAM window must be byte-aligned in X, which it already is (byte columns).
|
||||
if (!ensurePanelReady(RefreshMode::Partial)) {
|
||||
LOG_E(TAG, "Skipping window refresh: panel not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t* renderBitmap = taskFramebuffer.get();
|
||||
constexpr int BYTES_PER_ROW = WIDTH / 8;
|
||||
const int widthBytes = lastByteCol - firstByteCol + 1;
|
||||
|
||||
const uint16_t x = static_cast<uint16_t>(firstByteCol * 8);
|
||||
const uint16_t xe = static_cast<uint16_t>(lastByteCol * 8 + 7);
|
||||
const uint16_t y = static_cast<uint16_t>(firstRow);
|
||||
const uint16_t ye = static_cast<uint16_t>(lastRow);
|
||||
|
||||
// Set the partial RAM window (GxEPD2 GDEQ031T10 sequence).
|
||||
bool ok = writeCommand(CMD_PARTIAL_IN);
|
||||
ok = ok && writeCommand(CMD_PARTIAL_WINDOW);
|
||||
ok = ok && writeData(static_cast<uint8_t>(x));
|
||||
ok = ok && writeData(static_cast<uint8_t>(xe));
|
||||
ok = ok && writeData(static_cast<uint8_t>(y >> 8));
|
||||
ok = ok && writeData(static_cast<uint8_t>(y & 0xFF));
|
||||
ok = ok && writeData(static_cast<uint8_t>(ye >> 8));
|
||||
ok = ok && writeData(static_cast<uint8_t>(ye & 0xFF));
|
||||
ok = ok && writeData(0x01);
|
||||
if (!ok) {
|
||||
LOG_E(TAG, "Failed to set partial refresh window");
|
||||
writeCommand(CMD_PARTIAL_OUT); // best-effort: leave partial-window mode
|
||||
return;
|
||||
}
|
||||
|
||||
// Old region: normally the region's current panel contents (shadow),
|
||||
// gathered contiguously. For a ghost scrub, feed the complement of the new
|
||||
// data instead: the controller then sees every pixel as changed and drives
|
||||
// each one through a transition, clearing accumulated partial-refresh
|
||||
// ghosting in this window only.
|
||||
size_t n = 0;
|
||||
for (int row = firstRow; row <= lastRow; row++) {
|
||||
const size_t base = static_cast<size_t>(row) * BYTES_PER_ROW + firstByteCol;
|
||||
if (scrubGhosts) {
|
||||
// New panel data is ~renderBitmap, so its complement is renderBitmap.
|
||||
std::memcpy(®ionBuffer[n], &renderBitmap[base], widthBytes);
|
||||
} else {
|
||||
std::memcpy(®ionBuffer[n], &shadowFramebuffer[base], widthBytes);
|
||||
}
|
||||
n += widthBytes;
|
||||
}
|
||||
if (!writeCommand(CMD_DATA_START_OLD) || !writeData(regionBuffer.get(), n)) {
|
||||
LOG_E(TAG, "Failed to send old window data");
|
||||
writeCommand(CMD_PARTIAL_OUT);
|
||||
return;
|
||||
}
|
||||
|
||||
// New region: inverted render, and update the shadow for this region as we go.
|
||||
n = 0;
|
||||
for (int row = firstRow; row <= lastRow; row++) {
|
||||
const size_t base = static_cast<size_t>(row) * BYTES_PER_ROW + firstByteCol;
|
||||
for (int c = 0; c < widthBytes; c++) {
|
||||
const uint8_t value = static_cast<uint8_t>(~renderBitmap[base + c]);
|
||||
regionBuffer[n++] = value;
|
||||
shadowFramebuffer[base + c] = value;
|
||||
}
|
||||
}
|
||||
if (!writeCommand(CMD_DATA_START_NEW) || !writeData(regionBuffer.get(), n)) {
|
||||
LOG_E(TAG, "Failed to send new window data");
|
||||
writeCommand(CMD_PARTIAL_OUT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (writeCommand(CMD_DISPLAY_REFRESH)) {
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
if (!waitWhileBusy()) {
|
||||
LOG_E(TAG, "Window refresh did not complete");
|
||||
}
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to trigger window refresh");
|
||||
}
|
||||
writeCommand(CMD_PARTIAL_OUT);
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::flushCallback(lv_display_t* display, const lv_area_t* area, uint8_t* pixelMap) {
|
||||
// pixelMap points into renderFramebuffer (it was passed directly to
|
||||
// lv_display_set_buffers). Only stage the frame here: the physical refresh
|
||||
// takes up to ~1s, so it runs on the driver's refresh task instead of
|
||||
// blocking the LVGL task (which would stall all input handling).
|
||||
auto* self = static_cast<Gdeq031t10Display*>(lv_display_get_user_data(display));
|
||||
|
||||
if (lv_display_flush_is_last(display)) {
|
||||
self->queueRefresh();
|
||||
}
|
||||
|
||||
lv_display_flush_ready(display);
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::themeApplyCallback(lv_theme_t* /*theme*/, lv_obj_t* obj) {
|
||||
// Keep textarea cursors solid (no blink): on e-paper each blink toggle is a
|
||||
// panel refresh. anim_duration 0 makes lv_textarea skip the blink animation.
|
||||
if (lv_obj_check_type(obj, &lv_textarea_class)) {
|
||||
lv_obj_set_style_anim_duration(obj, 0, LV_PART_CURSOR);
|
||||
}
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::start() {
|
||||
if (initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
gpio_config_t outputConfig = {
|
||||
.pin_bit_mask = (1ULL << configuration->pinDc) | (1ULL << configuration->pinReset),
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
.pull_up_en = GPIO_PULLUP_DISABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
};
|
||||
gpio_config(&outputConfig);
|
||||
|
||||
gpio_config_t busyConfig = {
|
||||
.pin_bit_mask = 1ULL << configuration->pinBusy,
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
.pull_up_en = GPIO_PULLUP_DISABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
};
|
||||
gpio_config(&busyConfig);
|
||||
|
||||
spi_device_interface_config_t deviceConfig = {
|
||||
.mode = 0,
|
||||
.clock_speed_hz = configuration->clockSpeedHz,
|
||||
.spics_io_num = configuration->pinCs,
|
||||
.queue_size = 1,
|
||||
};
|
||||
|
||||
if (spi_bus_add_device(configuration->spiHost, &deviceConfig, &spiDevice) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to add SPI device");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (panelMutex == nullptr) {
|
||||
panelMutex = xSemaphoreCreateMutex();
|
||||
}
|
||||
if (bufferMutex == nullptr) {
|
||||
bufferMutex = xSemaphoreCreateMutex();
|
||||
}
|
||||
if (refreshTaskExited == nullptr) {
|
||||
refreshTaskExited = xSemaphoreCreateBinary();
|
||||
}
|
||||
|
||||
shadowFramebuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
|
||||
// LVGL needs room for the 8-byte I1 palette ahead of the 1bpp bitmap.
|
||||
renderFramebuffer = std::make_unique<uint8_t[]>(LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE);
|
||||
// Scratch for gathering a windowed region (worst case = the whole frame).
|
||||
regionBuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
|
||||
pendingFramebuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
|
||||
taskFramebuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
|
||||
std::memset(shadowFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE);
|
||||
std::memset(renderFramebuffer.get(), 0xFF, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE);
|
||||
std::memset(pendingFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE);
|
||||
std::memset(taskFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE);
|
||||
|
||||
currentRefreshMode = configuration->defaultRefreshMode;
|
||||
initFull();
|
||||
|
||||
refreshTaskShouldExit = false;
|
||||
if (xTaskCreate(&refreshTaskMain, "epd_refresh", REFRESH_TASK_STACK_SIZE, this, REFRESH_TASK_PRIORITY, &refreshTask) != pdPASS) {
|
||||
LOG_E(TAG, "Failed to create refresh task");
|
||||
spi_bus_remove_device(spiDevice);
|
||||
spiDevice = nullptr;
|
||||
shadowFramebuffer.reset();
|
||||
renderFramebuffer.reset();
|
||||
regionBuffer.reset();
|
||||
pendingFramebuffer.reset();
|
||||
taskFramebuffer.reset();
|
||||
return false;
|
||||
}
|
||||
powered = true;
|
||||
initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::stop() {
|
||||
if (!initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
stopLvgl();
|
||||
|
||||
// Join the refresh task before touching the panel: it may be mid-refresh.
|
||||
refreshTaskShouldExit = true;
|
||||
xTaskNotifyGive(refreshTask);
|
||||
xSemaphoreTake(refreshTaskExited, portMAX_DELAY);
|
||||
refreshTask = nullptr;
|
||||
|
||||
setPowerOn(false);
|
||||
|
||||
xSemaphoreTake(panelMutex, portMAX_DELAY);
|
||||
initialized = false;
|
||||
spi_bus_remove_device(spiDevice);
|
||||
spiDevice = nullptr;
|
||||
shadowFramebuffer.reset();
|
||||
renderFramebuffer.reset();
|
||||
regionBuffer.reset();
|
||||
pendingFramebuffer.reset();
|
||||
taskFramebuffer.reset();
|
||||
framePending = false;
|
||||
xSemaphoreGive(panelMutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::setPowerOn(bool turnOn) {
|
||||
if (turnOn == powered) {
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreTake(panelMutex, portMAX_DELAY);
|
||||
if (turnOn) {
|
||||
initFull(); // toggling RST also wakes the panel from deep sleep
|
||||
} else {
|
||||
if (panelPowerOn) {
|
||||
powerOff();
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
writeCommand(CMD_DEEP_SLEEP);
|
||||
writeData(DEEP_SLEEP_CHECK_CODE);
|
||||
// Deep sleep needs a reset to wake, which restores register defaults.
|
||||
panelMode.reset();
|
||||
}
|
||||
|
||||
powered = turnOn;
|
||||
xSemaphoreGive(panelMutex);
|
||||
|
||||
if (turnOn && refreshTask != nullptr) {
|
||||
// initFull left the charge pump on. Kick the refresh task: it redraws
|
||||
// any frame staged while the panel was off, then idles the pump down.
|
||||
xTaskNotifyGive(refreshTask);
|
||||
}
|
||||
}
|
||||
|
||||
void Gdeq031t10Display::requestFullRefresh() {
|
||||
forceFullRefresh = true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::startLvgl() {
|
||||
if (lvglDisplay != nullptr) {
|
||||
return true;
|
||||
}
|
||||
|
||||
lvglDisplay = lv_display_create(Gdeq031t10Display::WIDTH, Gdeq031t10Display::HEIGHT);
|
||||
if (lvglDisplay == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lv_display_set_user_data(lvglDisplay, this);
|
||||
lv_display_set_color_format(lvglDisplay, LV_COLOR_FORMAT_I1);
|
||||
|
||||
// The default colour theme renders accent-coloured text/icons that threshold
|
||||
// to near-invisible on a 1bpp panel. Apply LVGL's monochrome theme (light
|
||||
// background, dark foreground) so UI content shows as solid black on white.
|
||||
lv_theme_t* baseTheme = lv_theme_mono_init(lvglDisplay, false, LV_FONT_DEFAULT);
|
||||
// Chain a theme on top of the mono theme that disables the textarea cursor
|
||||
// blink. A blinking cursor invalidates its region ~twice a second, and on
|
||||
// e-paper every invalidation is a panel refresh, so text-entry screens (e.g.
|
||||
// the Wi-Fi password field) flash continuously. The mono theme still applies
|
||||
// first (it's the parent); themeApplyCallback only pins the cursor solid.
|
||||
static lv_theme_t epaperTheme;
|
||||
epaperTheme = *baseTheme;
|
||||
lv_theme_set_parent(&epaperTheme, baseTheme);
|
||||
lv_theme_set_apply_cb(&epaperTheme, &Gdeq031t10Display::themeApplyCallback);
|
||||
lv_display_set_theme(lvglDisplay, &epaperTheme);
|
||||
lv_display_set_render_mode(lvglDisplay, LV_DISPLAY_RENDER_MODE_FULL);
|
||||
lv_display_set_buffers(lvglDisplay, renderFramebuffer.get(), nullptr, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE, LV_DISPLAY_RENDER_MODE_FULL);
|
||||
lv_display_set_flush_cb(lvglDisplay, flushCallback);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Gdeq031t10Display::stopLvgl() {
|
||||
if (lvglDisplay == nullptr) {
|
||||
return true;
|
||||
}
|
||||
|
||||
lv_display_delete(lvglDisplay);
|
||||
lvglDisplay = nullptr;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/hal/display/DisplayDevice.h>
|
||||
#include <Tactility/hal/touch/TouchDevice.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/spi_master.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
#include <lvgl.h>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
/**
|
||||
* Driver for the GoodDisplay GDEQ031T10 (UC8253-family controller) 3.1" 320x240
|
||||
* SPI e-paper panel, as used on the LilyGO T-Deck Pro/Max.
|
||||
*
|
||||
* Command sequence and timings are ported from the vendor reference driver in
|
||||
* Xinyuan-LilyGO/T-Deck-MAX (examples/Elink_paper/GDEQ031T10_Arduino).
|
||||
*/
|
||||
class Gdeq031t10Display final : public tt::hal::display::DisplayDevice {
|
||||
|
||||
public:
|
||||
|
||||
enum class RefreshMode {
|
||||
Full, // ~3s, best quality
|
||||
Fast, // ~1.0s
|
||||
Slow, // ~1.5s, fast LUT with extra settling
|
||||
Partial // ~0.5s, full-frame partial-mode refresh (more ghosting)
|
||||
};
|
||||
|
||||
class Configuration {
|
||||
public:
|
||||
|
||||
Configuration(
|
||||
spi_host_device_t spiHost,
|
||||
gpio_num_t pinCs,
|
||||
gpio_num_t pinDc,
|
||||
gpio_num_t pinReset,
|
||||
gpio_num_t pinBusy,
|
||||
std::shared_ptr<tt::hal::touch::TouchDevice> touch = nullptr,
|
||||
int clockSpeedHz = 4'000'000,
|
||||
RefreshMode defaultRefreshMode = RefreshMode::Full,
|
||||
bool mirror180 = false
|
||||
) : spiHost(spiHost),
|
||||
pinCs(pinCs),
|
||||
pinDc(pinDc),
|
||||
pinReset(pinReset),
|
||||
pinBusy(pinBusy),
|
||||
touch(std::move(touch)),
|
||||
clockSpeedHz(clockSpeedHz),
|
||||
defaultRefreshMode(defaultRefreshMode),
|
||||
mirror180(mirror180)
|
||||
{}
|
||||
|
||||
spi_host_device_t spiHost;
|
||||
gpio_num_t pinCs;
|
||||
gpio_num_t pinDc;
|
||||
gpio_num_t pinReset;
|
||||
gpio_num_t pinBusy;
|
||||
std::shared_ptr<tt::hal::touch::TouchDevice> touch;
|
||||
int clockSpeedHz;
|
||||
RefreshMode defaultRefreshMode;
|
||||
/** Panel is mounted upside down relative to the reference orientation */
|
||||
bool mirror180;
|
||||
};
|
||||
|
||||
static constexpr uint16_t WIDTH = 240;
|
||||
static constexpr uint16_t HEIGHT = 320;
|
||||
static constexpr size_t FRAMEBUFFER_SIZE = (WIDTH * HEIGHT) / 8; // 1 bpp packed
|
||||
// LVGL 9 stores a 2-colour palette (2 x lv_color32_t = 8 bytes) at the start
|
||||
// of an LV_COLOR_FORMAT_I1 buffer, before the packed 1bpp bitmap.
|
||||
static constexpr size_t LVGL_I1_PALETTE_SIZE = 8;
|
||||
|
||||
private:
|
||||
|
||||
std::unique_ptr<Configuration> configuration;
|
||||
spi_device_handle_t spiDevice = nullptr;
|
||||
lv_display_t* _Nullable lvglDisplay = nullptr;
|
||||
/** Mirrors what the panel currently holds, required by the controller's
|
||||
* "old data" + "new data" double-buffered refresh protocol. */
|
||||
std::unique_ptr<uint8_t[]> shadowFramebuffer;
|
||||
/** Render target for LVGL; copied to the panel on flush */
|
||||
std::unique_ptr<uint8_t[]> renderFramebuffer;
|
||||
bool initialized = false;
|
||||
std::atomic<bool> powered = false;
|
||||
std::atomic<RefreshMode> currentRefreshMode = RefreshMode::Full;
|
||||
/** Number of windowed partial refreshes since the last full refresh. A full
|
||||
* refresh is forced once this reaches MAX_PARTIAL_REFRESHES to clear the
|
||||
* ghosting that partial updates accumulate. */
|
||||
uint8_t partialRefreshCount = 0;
|
||||
/** Forces the next refresh to be a full-screen refresh (set at boot and by
|
||||
* requestFullRefresh, read on the refresh task). */
|
||||
std::atomic<bool> forceFullRefresh = true;
|
||||
/** Scratch buffer used to gather a windowed region's bytes for one SPI write. */
|
||||
std::unique_ptr<uint8_t[]> regionBuffer;
|
||||
static constexpr uint8_t MAX_PARTIAL_REFRESHES = 8;
|
||||
/** Union bounding box (byte-column/row space) of all windowed partial
|
||||
* refreshes since the last ghost-clear. When the partial-refresh gate
|
||||
* expires, this decides between a localized scrub and a full-screen
|
||||
* refresh: small repeated updates (a text cursor, a typing line) shouldn't
|
||||
* flash the whole panel. */
|
||||
bool ghostAreaValid = false;
|
||||
int ghostFirstCol = 0;
|
||||
int ghostLastCol = 0;
|
||||
int ghostFirstRow = 0;
|
||||
int ghostLastRow = 0;
|
||||
/** Waveform mode the panel registers currently hold. Empty when the
|
||||
* registers are in an unknown state (before first init, or after deep sleep,
|
||||
* which requires a reset that restores defaults). */
|
||||
std::optional<RefreshMode> panelMode;
|
||||
/** True while the panel's charge pump is on (CMD_POWER_ON issued, no
|
||||
* power-off since). */
|
||||
bool panelPowerOn = false;
|
||||
/** Serializes panel/SPI access between the refresh task and external
|
||||
* callers (setPowerOn, stop). */
|
||||
SemaphoreHandle_t panelMutex = nullptr;
|
||||
|
||||
// The physical refresh takes hundreds of ms to over a second of ink
|
||||
// movement. Running it inside LVGL's flush callback would block the LVGL
|
||||
// task (and with it all input handling) for that long, so flushes only
|
||||
// stage the frame and a dedicated task drives the panel. Frames coalesce
|
||||
// latest-wins: however many flushes arrive during a refresh, only the
|
||||
// newest staged frame is drawn next.
|
||||
static constexpr uint32_t REFRESH_TASK_PRIORITY = 3; // below LVGL (6), above idle
|
||||
static constexpr uint32_t REFRESH_TASK_STACK_SIZE = 4096;
|
||||
TaskHandle_t refreshTask = nullptr;
|
||||
/** Signalled by the refresh task right before it exits; stop() joins on it. */
|
||||
SemaphoreHandle_t refreshTaskExited = nullptr;
|
||||
/** Guards pendingFramebuffer and framePending (LVGL flush vs refresh task). */
|
||||
SemaphoreHandle_t bufferMutex = nullptr;
|
||||
/** Latest complete frame staged by the LVGL flush callback. */
|
||||
std::unique_ptr<uint8_t[]> pendingFramebuffer;
|
||||
/** The refresh task's working copy of the frame it is drawing (swapped with
|
||||
* pendingFramebuffer under bufferMutex; no copy on the task side). */
|
||||
std::unique_ptr<uint8_t[]> taskFramebuffer;
|
||||
bool framePending = false;
|
||||
std::atomic<bool> refreshTaskShouldExit = false;
|
||||
|
||||
/** Returns false if the SPI transfer failed; the panel/shadow state should
|
||||
* not be advanced as if it succeeded. */
|
||||
bool writeCommand(uint8_t command);
|
||||
bool writeData(const uint8_t* data, size_t length);
|
||||
bool writeData(uint8_t data) { return writeData(&data, 1); }
|
||||
/** Waits for the BUSY pin to report ready, up to a fixed timeout. Returns
|
||||
* false (and logs) if the panel never became ready, e.g. bad wiring or no
|
||||
* panel attached — callers must not assume the operation completed. */
|
||||
bool waitWhileBusy() const;
|
||||
void reset() const;
|
||||
|
||||
bool initFull();
|
||||
bool initFast();
|
||||
bool initSlow();
|
||||
bool initPartial();
|
||||
|
||||
/** Puts the panel in the given waveform mode with the charge pump on,
|
||||
* skipping the (expensive) init sequence when it already is. */
|
||||
bool ensurePanelReady(RefreshMode mode);
|
||||
|
||||
bool powerOff();
|
||||
/** Stages the LVGL-rendered frame for the refresh task (called on flush). */
|
||||
void queueRefresh();
|
||||
static void refreshTaskMain(void* parameter);
|
||||
void runRefreshTask();
|
||||
void refresh();
|
||||
void refreshFull(RefreshMode mode);
|
||||
/** Windowed partial refresh. With scrubGhosts the controller is fed "old"
|
||||
* data that is the complement of the new frame, so it drives every pixel in
|
||||
* the window through a transition — a localized ghost-clear that only
|
||||
* flashes the window itself. */
|
||||
void refreshWindow(int firstByteCol, int lastByteCol, int firstRow, int lastRow, bool scrubGhosts);
|
||||
|
||||
static void flushCallback(lv_display_t* display, const lv_area_t* area, uint8_t* pixelMap);
|
||||
/** Theme hook: disables the textarea cursor blink (see startLvgl). */
|
||||
static void themeApplyCallback(lv_theme_t* theme, lv_obj_t* obj);
|
||||
|
||||
public:
|
||||
|
||||
explicit Gdeq031t10Display(std::unique_ptr<Configuration> inConfiguration) : configuration(std::move(inConfiguration)) {
|
||||
assert(configuration != nullptr);
|
||||
}
|
||||
|
||||
~Gdeq031t10Display() override {
|
||||
if (panelMutex != nullptr) {
|
||||
vSemaphoreDelete(panelMutex);
|
||||
}
|
||||
if (bufferMutex != nullptr) {
|
||||
vSemaphoreDelete(bufferMutex);
|
||||
}
|
||||
if (refreshTaskExited != nullptr) {
|
||||
vSemaphoreDelete(refreshTaskExited);
|
||||
}
|
||||
}
|
||||
|
||||
std::string getName() const override { return "GDEQ031T10"; }
|
||||
|
||||
std::string getDescription() const override { return "GoodDisplay GDEQ031T10 e-paper display"; }
|
||||
|
||||
bool start() override;
|
||||
bool stop() override;
|
||||
|
||||
void setPowerOn(bool turnOn) override;
|
||||
bool isPoweredOn() const override { return powered; }
|
||||
bool supportsPowerControl() const override { return true; }
|
||||
|
||||
void requestFullRefresh() override;
|
||||
|
||||
std::shared_ptr<tt::hal::touch::TouchDevice> _Nullable getTouchDevice() override {
|
||||
return configuration->touch;
|
||||
}
|
||||
|
||||
bool supportsLvgl() const override { return true; }
|
||||
bool startLvgl() override;
|
||||
bool stopLvgl() override;
|
||||
lv_display_t* _Nullable getLvglDisplay() const override { return lvglDisplay; }
|
||||
|
||||
bool supportsDisplayDriver() const override { return false; }
|
||||
std::shared_ptr<tt::hal::display::DisplayDriver> _Nullable getDisplayDriver() override { return nullptr; }
|
||||
|
||||
/** Sets the refresh mode used for automatic (non-full) refreshes from now on,
|
||||
* until changed again by a subsequent call. */
|
||||
void setRefreshMode(RefreshMode mode) { currentRefreshMode = mode; }
|
||||
};
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
Apache License
|
||||
==============
|
||||
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# XL9555 I/O expander
|
||||
|
||||
A driver for the `XL9555` 16-bit I2C-bus I/O expander, used by several LilyGO boards
|
||||
(e.g. T-Deck Pro Max) for power-rail enables, reset lines, and antenna/audio routing.
|
||||
Its register map is PCA9555-compatible: two 8-bit ports for input, output, polarity
|
||||
inversion, and direction, addressed as pins 0-15 (port 0 = pins 0-7, port 1 = pins 8-15).
|
||||
|
||||
It does not support pull-up/down resistors or high-impedance outputs; requesting those
|
||||
flags returns `ERROR_NOT_SUPPORTED`.
|
||||
|
||||
License: [Apache v2.0](LICENSE-Apache-2.0.md)
|
||||
@@ -0,0 +1,5 @@
|
||||
description: XLSEMI XL9555 16-bit I2C-bus I/O expander (PCA9555-register-compatible)
|
||||
|
||||
include: ["i2c-device.yaml"]
|
||||
|
||||
compatible: "xlsemi,xl9555"
|
||||
@@ -0,0 +1,3 @@
|
||||
dependencies:
|
||||
- TactilityKernel
|
||||
bindings: bindings
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <tactility/bindings/bindings.h>
|
||||
#include <drivers/xl9555.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
DEFINE_DEVICETREE(xl9555, struct Xl9555Config)
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
struct Xl9555Config {
|
||||
/** Address on bus */
|
||||
uint8_t address;
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <tactility/module.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
extern struct Module xl9555_module;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/driver.h>
|
||||
#include <tactility/module.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
extern Driver xl9555_driver;
|
||||
|
||||
static error_t start() {
|
||||
/* We crash when construct fails, because if a single driver fails to construct,
|
||||
* there is no guarantee that the previously constructed drivers can be destroyed */
|
||||
check(driver_construct_add(&xl9555_driver) == ERROR_NONE);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static error_t stop() {
|
||||
/* We crash when destruct fails, because if a single driver fails to destruct,
|
||||
* there is no guarantee that the previously destroyed drivers can be recovered */
|
||||
check(driver_remove_destruct(&xl9555_driver) == ERROR_NONE);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
Module xl9555_module = {
|
||||
.name = "xl9555",
|
||||
.start = start,
|
||||
.stop = stop,
|
||||
.symbols = nullptr,
|
||||
.internal = nullptr
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include <drivers/xl9555.h>
|
||||
#include <xl9555_module.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/driver.h>
|
||||
#include <tactility/drivers/gpio_controller.h>
|
||||
#include <tactility/drivers/gpio_descriptor.h>
|
||||
#include <tactility/drivers/i2c_controller.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#define TAG "XL9555"
|
||||
|
||||
#define GET_CONFIG(device) (static_cast<const Xl9555Config*>((device)->config))
|
||||
|
||||
// PCA9555-compatible register map: one register per function per 8-pin port.
|
||||
constexpr auto XL9555_REGISTER_INPUT_PORT0 = 0x00;
|
||||
constexpr auto XL9555_REGISTER_OUTPUT_PORT0 = 0x02;
|
||||
constexpr auto XL9555_REGISTER_POLARITY_PORT0 = 0x04;
|
||||
constexpr auto XL9555_REGISTER_CONFIG_PORT0 = 0x06;
|
||||
|
||||
static inline uint8_t port_of(GpioDescriptor* descriptor) {
|
||||
return descriptor->pin >> 3;
|
||||
}
|
||||
|
||||
static inline uint8_t bit_of(GpioDescriptor* descriptor) {
|
||||
return 1 << (descriptor->pin & 0x7);
|
||||
}
|
||||
|
||||
static error_t start(Device* device) {
|
||||
auto* parent = device_get_parent(device);
|
||||
check(device_get_type(parent) == &I2C_CONTROLLER_TYPE);
|
||||
|
||||
return gpio_controller_init_descriptors(device, 16, nullptr);
|
||||
}
|
||||
|
||||
static error_t stop(Device* device) {
|
||||
check(gpio_controller_deinit_descriptors(device) == ERROR_NONE);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
static error_t set_level(GpioDescriptor* descriptor, bool high) {
|
||||
auto* device = descriptor->controller;
|
||||
auto* parent = device_get_parent(device);
|
||||
auto address = GET_CONFIG(device)->address;
|
||||
auto reg = static_cast<uint8_t>(XL9555_REGISTER_OUTPUT_PORT0 + port_of(descriptor));
|
||||
auto bit = bit_of(descriptor);
|
||||
|
||||
// i2c_controller_register8_{set,reset}_bits() do a separate read then
|
||||
// write; without this lock, concurrent updates to different pins on the
|
||||
// same output port register can clobber each other.
|
||||
device_lock(device);
|
||||
error_t err = high
|
||||
? i2c_controller_register8_set_bits(parent, address, reg, bit, portMAX_DELAY)
|
||||
: i2c_controller_register8_reset_bits(parent, address, reg, bit, portMAX_DELAY);
|
||||
device_unlock(device);
|
||||
return err;
|
||||
}
|
||||
|
||||
static error_t get_level(GpioDescriptor* descriptor, bool* high) {
|
||||
auto* device = descriptor->controller;
|
||||
auto* parent = device_get_parent(device);
|
||||
auto address = GET_CONFIG(device)->address;
|
||||
auto reg = static_cast<uint8_t>(XL9555_REGISTER_INPUT_PORT0 + port_of(descriptor));
|
||||
uint8_t bits;
|
||||
|
||||
error_t err = i2c_controller_register8_get(parent, address, reg, &bits, portMAX_DELAY);
|
||||
if (err != ERROR_NONE) {
|
||||
return err;
|
||||
}
|
||||
|
||||
*high = (bits & bit_of(descriptor)) != 0;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static error_t set_flags(GpioDescriptor* descriptor, gpio_flags_t flags) {
|
||||
// The XL9555 only supports direction and polarity inversion. Pull-up/down and
|
||||
// high-impedance are not present in its PCA9555-compatible register map.
|
||||
if (flags & (GPIO_FLAG_PULL_UP | GPIO_FLAG_PULL_DOWN | GPIO_FLAG_HIGH_IMPEDANCE)) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
// The polarity register only inverts what's read back from an input pin;
|
||||
// set_level() still drives outputs at the raw level. Accepting ACTIVE_LOW
|
||||
// on an output would silently not do what it implies.
|
||||
if ((flags & GPIO_FLAG_ACTIVE_LOW) && (flags & GPIO_FLAG_DIRECTION_OUTPUT)) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
auto* device = descriptor->controller;
|
||||
auto* parent = device_get_parent(device);
|
||||
auto address = GET_CONFIG(device)->address;
|
||||
auto config_reg = static_cast<uint8_t>(XL9555_REGISTER_CONFIG_PORT0 + port_of(descriptor));
|
||||
auto polarity_reg = static_cast<uint8_t>(XL9555_REGISTER_POLARITY_PORT0 + port_of(descriptor));
|
||||
auto bit = bit_of(descriptor);
|
||||
error_t err;
|
||||
|
||||
// Locked as a whole: direction and polarity are two separate RMW register
|
||||
// writes, and both should apply atomically with respect to other set_flags
|
||||
// / set_level calls on this device.
|
||||
device_lock(device);
|
||||
|
||||
// Direction: configuration bit is 1 for input, 0 for output.
|
||||
if (flags & GPIO_FLAG_DIRECTION_OUTPUT) {
|
||||
err = i2c_controller_register8_reset_bits(parent, address, config_reg, bit, portMAX_DELAY);
|
||||
} else {
|
||||
err = i2c_controller_register8_set_bits(parent, address, config_reg, bit, portMAX_DELAY);
|
||||
}
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
device_unlock(device);
|
||||
return err;
|
||||
}
|
||||
|
||||
// Polarity inversion (mainly relevant for active-low inputs).
|
||||
if (flags & GPIO_FLAG_ACTIVE_LOW) {
|
||||
err = i2c_controller_register8_set_bits(parent, address, polarity_reg, bit, portMAX_DELAY);
|
||||
} else {
|
||||
err = i2c_controller_register8_reset_bits(parent, address, polarity_reg, bit, portMAX_DELAY);
|
||||
}
|
||||
|
||||
device_unlock(device);
|
||||
return err;
|
||||
}
|
||||
|
||||
static error_t get_flags(GpioDescriptor* descriptor, gpio_flags_t* flags) {
|
||||
auto* device = descriptor->controller;
|
||||
auto* parent = device_get_parent(device);
|
||||
auto address = GET_CONFIG(device)->address;
|
||||
auto config_reg = static_cast<uint8_t>(XL9555_REGISTER_CONFIG_PORT0 + port_of(descriptor));
|
||||
auto polarity_reg = static_cast<uint8_t>(XL9555_REGISTER_POLARITY_PORT0 + port_of(descriptor));
|
||||
auto bit = bit_of(descriptor);
|
||||
uint8_t val;
|
||||
error_t err;
|
||||
|
||||
gpio_flags_t f = GPIO_FLAG_NONE;
|
||||
|
||||
err = i2c_controller_register8_get(parent, address, config_reg, &val, portMAX_DELAY);
|
||||
if (err != ERROR_NONE) return err;
|
||||
f |= (val & bit) ? GPIO_FLAG_DIRECTION_INPUT : GPIO_FLAG_DIRECTION_OUTPUT;
|
||||
|
||||
err = i2c_controller_register8_get(parent, address, polarity_reg, &val, portMAX_DELAY);
|
||||
if (err != ERROR_NONE) return err;
|
||||
f |= (val & bit) ? GPIO_FLAG_ACTIVE_LOW : GPIO_FLAG_ACTIVE_HIGH;
|
||||
|
||||
*flags = f;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static error_t get_native_pin_number(GpioDescriptor* descriptor, void* pin_number) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
static error_t add_callback(GpioDescriptor* descriptor, void (*callback)(void*), void* arg) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
static error_t remove_callback(GpioDescriptor* descriptor) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
static error_t enable_interrupt(GpioDescriptor* descriptor) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
static error_t disable_interrupt(GpioDescriptor* descriptor) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const static GpioControllerApi xl9555_gpio_api = {
|
||||
.set_level = set_level,
|
||||
.get_level = get_level,
|
||||
.set_flags = set_flags,
|
||||
.get_flags = get_flags,
|
||||
.get_native_pin_number = get_native_pin_number,
|
||||
.add_callback = add_callback,
|
||||
.remove_callback = remove_callback,
|
||||
.enable_interrupt = enable_interrupt,
|
||||
.disable_interrupt = disable_interrupt
|
||||
};
|
||||
|
||||
Driver xl9555_driver = {
|
||||
.name = "xl9555",
|
||||
.compatible = (const char*[]) { "xlsemi,xl9555", nullptr },
|
||||
.start_device = start,
|
||||
.stop_device = stop,
|
||||
.api = static_cast<const void*>(&xl9555_gpio_api),
|
||||
.device_type = &GPIO_CONTROLLER_TYPE,
|
||||
.owner = &xl9555_module,
|
||||
.internal = nullptr
|
||||
};
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user