M5Stack PaperS3 implementation (#478)

Selectively imported code from @juicecultus [fork](https://github.com/juicecultus/Tactility) with some changes/additions from me.
This commit is contained in:
Ken Van Hoeylandt 2026-02-03 21:22:40 +01:00 committed by GitHub
parent a935410f82
commit 9cc96fd32b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 541 additions and 87 deletions

View File

@ -61,6 +61,7 @@ jobs:
{ id: m5stack-cardputer-adv, arch: esp32s3 },
{ id: m5stack-core2, arch: esp32 },
{ id: m5stack-cores3, arch: esp32s3 },
{ id: m5stack-papers3, arch: esp32s3 },
{ id: m5stack-stickc-plus, arch: esp32 },
{ id: m5stack-stickc-plus2, arch: esp32 },
{ id: m5stack-tab5, arch: esp32p4 },

View File

@ -0,0 +1,6 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
idf_component_register(SRCS ${SOURCE_FILES}
INCLUDE_DIRS "Source"
REQUIRES FastEpdDisplay GT911 TactilityCore
)

View File

@ -0,0 +1,45 @@
#include "devices/Display.h"
#include "devices/SdCard.h"
#include <Tactility/hal/Configuration.h>
#include <Tactility/lvgl/LvglSync.h>
using namespace tt::hal;
static DeviceVector createDevices() {
auto touch = createTouch();
return {
createSdCard(),
createDisplay(touch)
};
}
extern const Configuration hardwareConfiguration = {
.initBoot = nullptr,
.createDevices = createDevices,
.spi {
spi::Configuration {
.device = SPI2_HOST,
.dma = SPI_DMA_CH_AUTO,
.config = {
.mosi_io_num = GPIO_NUM_38,
.miso_io_num = GPIO_NUM_40,
.sclk_io_num = GPIO_NUM_39,
.quadwp_io_num = GPIO_NUM_NC,
.quadhd_io_num = GPIO_NUM_NC,
.data4_io_num = GPIO_NUM_NC,
.data5_io_num = GPIO_NUM_NC,
.data6_io_num = GPIO_NUM_NC,
.data7_io_num = GPIO_NUM_NC,
.data_io_default_level = false,
.max_transfer_sz = 4096,
.flags = 0,
.isr_cpu_id = ESP_INTR_CPU_AFFINITY_AUTO,
.intr_flags = 0
},
.initMode = spi::InitMode::ByTactility,
.isMutable = false,
.lock = tt::lvgl::getSyncLock()
}
}
};

View File

@ -0,0 +1,34 @@
#include "Display.h"
#include <Gt911Touch.h>
#include <FastEpdDisplay.h>
#include <Tactility/lvgl/LvglSync.h>
std::shared_ptr<tt::hal::touch::TouchDevice> createTouch() {
auto configuration = std::make_unique<Gt911Touch::Configuration>(
I2C_NUM_0,
540,
960,
false, // swapXy
false, // mirrorX
false, // mirrorY
GPIO_NUM_NC, // pinReset
GPIO_NUM_NC // pinInterrupt
);
auto touch = std::make_shared<Gt911Touch>(std::move(configuration));
return std::static_pointer_cast<tt::hal::touch::TouchDevice>(touch);
}
std::shared_ptr<tt::hal::display::DisplayDevice> createDisplay(std::shared_ptr<tt::hal::touch::TouchDevice> touch) {
FastEpdDisplay::Configuration configuration = {
.horizontalResolution = 540,
.verticalResolution = 960,
.touch = std::move(touch),
.busSpeedHz = 20000000,
.rotationDegrees = 90,
.use4bppGrayscale = false,
.fullRefreshEveryNFlushes = 40,
};
return std::make_shared<FastEpdDisplay>(configuration, tt::lvgl::getSyncLock());
}

View File

@ -0,0 +1,7 @@
#pragma once
#include <Tactility/hal/display/DisplayDevice.h>
#include <Tactility/hal/touch/TouchDevice.h>
std::shared_ptr<tt::hal::touch::TouchDevice> createTouch();
std::shared_ptr<tt::hal::display::DisplayDevice> createDisplay(std::shared_ptr<tt::hal::touch::TouchDevice> touch);

View File

@ -0,0 +1,25 @@
#include "SdCard.h"
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/hal/sdcard/SpiSdCardDevice.h>
constexpr auto PAPERS3_SDCARD_PIN_CS = GPIO_NUM_47;
using tt::hal::sdcard::SpiSdCardDevice;
std::shared_ptr<SdCardDevice> createSdCard() {
auto configuration = std::make_unique<SpiSdCardDevice::Config>(
PAPERS3_SDCARD_PIN_CS,
GPIO_NUM_NC,
GPIO_NUM_NC,
GPIO_NUM_NC,
SdCardDevice::MountBehaviour::AtBoot,
tt::lvgl::getSyncLock(),
std::vector<gpio_num_t>{},
SPI2_HOST
);
return std::make_shared<SpiSdCardDevice>(
std::move(configuration)
);
}

View File

@ -0,0 +1,7 @@
#pragma once
#include "Tactility/hal/sdcard/SdCardDevice.h"
using tt::hal::sdcard::SdCardDevice;
std::shared_ptr<SdCardDevice> createSdCard();

View File

@ -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;
}
/** @warning The variable name must be exactly "device_module" */
struct Module device_module = {
.name = "m5stack-papers3",
.start = start,
.stop = stop,
.symbols = nullptr
};
}

View File

@ -0,0 +1,27 @@
[general]
vendor=M5Stack
name=Paper S3
incubating=true
[hardware]
target=esp32s3
flashSize=16MB
spiRam=true
spiRamMode=OPI
spiRamSpeed=80M
esptoolFlashFreq=80M
tinyUsb=true
[display]
size=4.7"
shape=rectangle
dpi=235
[lvgl]
colorDepth=8
theme=Mono
[sdkconfig]
CONFIG_EPD_DISPLAY_TYPE_ED047TC2=y

View File

@ -0,0 +1,3 @@
dependencies:
- Platforms/PlatformEsp32
dts: m5stack,papers3.dts

View File

@ -0,0 +1,25 @@
/dts-v1/;
#include <tactility/bindings/root.h>
#include <tactility/bindings/esp32_gpio.h>
#include <tactility/bindings/esp32_i2c.h>
/ {
compatible = "root";
model = "M5Stack PaperS3";
gpio0 {
compatible = "espressif,esp32-gpio";
gpio-count = <49>;
};
i2c_internal {
compatible = "espressif,esp32-i2c";
port = <I2C_NUM_0>;
clock-frequency = <400000>;
pin-sda = <41>;
pin-scl = <42>;
pin-sda-pullup;
pin-scl-pullup;
};
};

View File

@ -0,0 +1,5 @@
idf_component_register(
SRC_DIRS "Source"
INCLUDE_DIRS "Source"
REQUIRES FastEPD TactilityCore Tactility
)

View File

@ -0,0 +1,256 @@
#include "FastEpdDisplay.h"
#include <tactility/log.h>
#include <cstring>
#ifdef ESP_PLATFORM
#include <esp_heap_caps.h>
#endif
#define TAG "FastEpdDisplay"
FastEpdDisplay::~FastEpdDisplay() {
stop();
}
void FastEpdDisplay::flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) {
auto* self = static_cast<FastEpdDisplay*>(lv_display_get_user_data(disp));
static uint32_t s_flush_log_counter = 0;
const int32_t width = area->x2 - area->x1 + 1;
const bool grayscale4bpp = self->configuration.use4bppGrayscale;
// LVGL logical resolution is portrait (540x960). FastEPD PaperS3 native is landscape (960x540).
// Keep FastEPD at rotation 0 and do the coordinate transform ourselves.
// For a 90° clockwise transform:
// x_native = y
// y_native = (native_height - 1) - x
const int native_width = self->epd.width();
const int native_height = self->epd.height();
// Compute the native line range that will be affected by this flush.
// With our mapping y_native = (native_height - 1) - x
// So x range maps to y_native range.
int start_line = (native_height - 1) - (int)area->x2;
int end_line = (native_height - 1) - (int)area->x1;
if (start_line > end_line) {
const int tmp = start_line;
start_line = end_line;
end_line = tmp;
}
if (start_line < 0) start_line = 0;
if (end_line >= native_height) end_line = native_height - 1;
for (int32_t y = area->y1; y <= area->y2; y++) {
for (int32_t x = area->x1; x <= area->x2; x++) {
const uint8_t gray8 = px_map[(y - area->y1) * width + (x - area->x1)];
const uint8_t color = grayscale4bpp ? (uint8_t)(gray8 >> 4) : (uint8_t)((gray8 > 127) ? BBEP_BLACK : BBEP_WHITE);
const int x_native = y;
const int y_native = (native_height - 1) - x;
// Be defensive: any out-of-range drawPixelFast will corrupt memory.
if (x_native < 0 || x_native >= native_width || y_native < 0 || y_native >= native_height) {
continue;
}
self->epd.drawPixelFast(x_native, y_native, color);
}
}
if (start_line <= end_line) {
(void)self->epd.einkPower(1);
self->flushCount++;
const uint32_t cadence = self->configuration.fullRefreshEveryNFlushes;
const bool requested_full = self->forceNextFullRefresh.exchange(false);
const bool do_full = requested_full || ((cadence > 0) && (self->flushCount % cadence == 0));
const bool should_log = ((++s_flush_log_counter % 25U) == 0U);
if (should_log) {
LOG_I(TAG, "flush #%lu area=(%ld,%ld)-(%ld,%ld) lines=[%d..%d] full=%d",
(unsigned long)self->flushCount,
(long)area->x1, (long)area->y1, (long)area->x2, (long)area->y2,
start_line, end_line, (int)do_full);
}
if (do_full) {
const int rc = self->epd.fullUpdate(CLEAR_FAST, true, nullptr);
if (should_log) {
LOG_I(TAG, "fullUpdate rc=%d", rc);
}
// After a full update, keep FastEPD's previous/current buffers in sync so that
// subsequent partial updates compute correct diffs.
const int w = self->epd.width();
const int h = self->epd.height();
const size_t bytes_per_row = grayscale4bpp
? (size_t)(w + 1) / 2
: (size_t)(w + 7) / 8;
const size_t plane_size = bytes_per_row * (size_t)h;
if (self->epd.currentBuffer() && self->epd.previousBuffer()) {
memcpy(self->epd.previousBuffer(), self->epd.currentBuffer(), plane_size);
}
} else {
if (grayscale4bpp) {
// FastEPD partialUpdate only supports 1bpp mode.
// For 4bpp we currently do a fullUpdate. Region-based updates are tricky here because
// we also manually rotate/transform pixels in flush_cb; a mismatched rect can refresh
// the wrong strip of the panel (seen as "split" buttons on the final refresh).
const int rc = self->epd.fullUpdate(CLEAR_FAST, true, nullptr);
if (should_log) {
LOG_I(TAG, "fullUpdate(4bpp) rc=%d", rc);
}
} else {
const int rc = self->epd.partialUpdate(true, start_line, end_line);
// Keep FastEPD's previous/current buffers in sync after partial updates as well.
// This avoids stale diffs where subsequent updates don't visibly apply.
const int w = self->epd.width();
const int h = self->epd.height();
const size_t bytes_per_row = (size_t)(w + 7) / 8;
const size_t plane_size = bytes_per_row * (size_t)h;
if (rc == BBEP_SUCCESS && self->epd.currentBuffer() && self->epd.previousBuffer()) {
memcpy(self->epd.previousBuffer(), self->epd.currentBuffer(), plane_size);
}
if (should_log) {
LOG_I(TAG, "partialUpdate rc=%d", rc);
}
}
}
}
lv_display_flush_ready(disp);
}
bool FastEpdDisplay::start() {
if (initialized) {
return true;
}
const int rc = epd.initPanel(BB_PANEL_M5PAPERS3, configuration.busSpeedHz);
if (rc != BBEP_SUCCESS) {
LOG_E(TAG, "FastEPD initPanel failed rc=%d", rc);
return false;
}
LOG_I(TAG, "FastEPD native size %dx%d", epd.width(), epd.height());
const int desired_mode = configuration.use4bppGrayscale ? BB_MODE_4BPP : BB_MODE_1BPP;
if (epd.setMode(desired_mode) != BBEP_SUCCESS) {
LOG_E(TAG, "FastEPD setMode(%d) failed", desired_mode);
epd.deInit();
return false;
}
// Keep FastEPD at rotation 0. LVGL-to-native mapping is handled in flush_cb.
// Ensure previous/current buffers are in sync and the panel starts from a known state.
if (epd.einkPower(1) != BBEP_SUCCESS) {
LOG_W(TAG, "FastEPD einkPower(1) failed");
} else {
epd.fillScreen(configuration.use4bppGrayscale ? 0x0F : BBEP_WHITE);
const int native_width = epd.width();
const int native_height = epd.height();
const size_t bytes_per_row = configuration.use4bppGrayscale
? (size_t)(native_width + 1) / 2
: (size_t)(native_width + 7) / 8;
const size_t plane_size = bytes_per_row * (size_t)native_height;
if (epd.currentBuffer() && epd.previousBuffer()) {
memcpy(epd.previousBuffer(), epd.currentBuffer(), plane_size);
}
if (epd.fullUpdate(CLEAR_FAST, true, nullptr) != BBEP_SUCCESS) {
LOG_W(TAG, "FastEPD fullUpdate failed");
}
}
initialized = true;
return true;
}
bool FastEpdDisplay::stop() {
if (lvglDisplay) {
stopLvgl();
}
if (initialized) {
epd.deInit();
initialized = false;
}
return true;
}
bool FastEpdDisplay::startLvgl() {
if (lvglDisplay != nullptr) {
return true;
}
lvglDisplay = lv_display_create(configuration.horizontalResolution, configuration.verticalResolution);
if (lvglDisplay == nullptr) {
return false;
}
lv_display_set_color_format(lvglDisplay, LV_COLOR_FORMAT_L8);
if (lv_display_get_rotation(lvglDisplay) != LV_DISPLAY_ROTATION_0) {
lv_display_set_rotation(lvglDisplay, LV_DISPLAY_ROTATION_0);
}
const uint32_t pixel_count = (uint32_t)(configuration.horizontalResolution * configuration.verticalResolution / 10);
const uint32_t buf_size = pixel_count * (uint32_t)lv_color_format_get_size(LV_COLOR_FORMAT_L8);
lvglBufSize = buf_size;
#ifdef ESP_PLATFORM
lvglBuf1 = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
#else
lvglBuf1 = malloc(buf_size);
#endif
if (lvglBuf1 == nullptr) {
lv_display_delete(lvglDisplay);
lvglDisplay = nullptr;
return false;
}
lvglBuf2 = nullptr;
lv_display_set_buffers(lvglDisplay, lvglBuf1, lvglBuf2, buf_size, LV_DISPLAY_RENDER_MODE_PARTIAL);
lv_display_set_user_data(lvglDisplay, this);
lv_display_set_flush_cb(lvglDisplay, FastEpdDisplay::flush_cb);
if (configuration.touch && configuration.touch->supportsLvgl()) {
configuration.touch->startLvgl(lvglDisplay);
}
return true;
}
bool FastEpdDisplay::stopLvgl() {
if (lvglDisplay) {
if (configuration.touch) {
configuration.touch->stopLvgl();
}
lv_display_delete(lvglDisplay);
lvglDisplay = nullptr;
}
if (lvglBuf1 != nullptr) {
free(lvglBuf1);
lvglBuf1 = nullptr;
}
if (lvglBuf2 != nullptr) {
free(lvglBuf2);
lvglBuf2 = nullptr;
}
lvglBufSize = 0;
return true;
}

View File

@ -0,0 +1,65 @@
#pragma once
#include <Tactility/Lock.h>
#include <Tactility/hal/display/DisplayDevice.h>
#include <Tactility/hal/touch/TouchDevice.h>
#include <atomic>
#include <FastEPD.h>
#include <lvgl.h>
class FastEpdDisplay final : public tt::hal::display::DisplayDevice {
public:
struct Configuration final {
int horizontalResolution;
int verticalResolution;
std::shared_ptr<tt::hal::touch::TouchDevice> touch;
uint32_t busSpeedHz = 20000000;
int rotationDegrees = 90;
bool use4bppGrayscale = false;
uint32_t fullRefreshEveryNFlushes = 0;
};
private:
Configuration configuration;
std::shared_ptr<tt::Lock> lock;
lv_display_t* lvglDisplay = nullptr;
void* lvglBuf1 = nullptr;
void* lvglBuf2 = nullptr;
uint32_t lvglBufSize = 0;
FASTEPD epd;
bool initialized = false;
uint32_t flushCount = 0;
std::atomic_bool forceNextFullRefresh{false};
static void flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map);
public:
FastEpdDisplay(Configuration configuration, std::shared_ptr<tt::Lock> lock)
: configuration(std::move(configuration)), lock(std::move(lock)) {}
~FastEpdDisplay() override;
void requestFullRefresh() override { forceNextFullRefresh.store(true); }
std::string getName() const override { return "FastEpdDisplay"; }
std::string getDescription() const override { return "FastEPD (bitbank2) E-Ink display driver"; }
bool start() override;
bool stop() override;
bool supportsLvgl() const override { return true; }
bool startLvgl() override;
bool stopLvgl() override;
lv_display_t* getLvglDisplay() const override { return lvglDisplay; }
bool supportsDisplayDriver() const override { return false; }
std::shared_ptr<tt::hal::display::DisplayDriver> getDisplayDriver() override { return nullptr; }
std::shared_ptr<tt::hal::touch::TouchDevice> getTouchDevice() override {
return configuration.touch;
}
};

View File

@ -6,93 +6,6 @@ menu "Tactility App"
config TT_DEVICE_ID
string "Device Identifier"
default ""
choice
prompt "Device"
default TT_DEVICE_CUSTOM
config TT_DEVICE_CUSTOM
bool "Custom"
config TT_DEVICE_BTT_PANDA_TOUCH
bool "BigTreeTech Panda Touch"
config TT_DEVICE_CYD_2432S024C
bool "CYD 2432S024C"
config TT_DEVICE_CYD_2432S028R
bool "CYD 2432S028R"
config TT_DEVICE_CYD_2432S028RV3
bool "CYD 2432S028RV3"
config TT_DEVICE_CYD_E32R28T
bool "CYD E32R28T"
config TT_DEVICE_CYD_E32R32P
bool "CYD E32R32P"
config TT_DEVICE_CYD_2432S032C
bool "CYD 2432S032C"
config TT_DEVICE_CYD_8048S043C
bool "CYD 8048S043C"
config TT_DEVICE_CYD_4848S040C
bool "CYD 4848S040C"
config TT_DEVICE_ELECROW_CROWPANEL_ADVANCE_28
bool "Elecrow CrowPanel Advance 2.8"
config TT_DEVICE_ELECROW_CROWPANEL_ADVANCE_35
bool "Elecrow CrowPanel Advance 3.5"
config TT_DEVICE_ELECROW_CROWPANEL_ADVANCE_50
bool "Elecrow CrowPanel Advance 5.0"
config TT_DEVICE_ELECROW_CROWPANEL_BASIC_28
bool "Elecrow CrowPanel Basic 2.8"
config TT_DEVICE_ELECROW_CROWPANEL_BASIC_35
bool "Elecrow CrowPanel Basic 3.5"
config TT_DEVICE_ELECROW_CROWPANEL_BASIC_50
bool "Elecrow CrowPanel Basic 5.0"
config TT_DEVICE_GUITION_JC1060P470CIWY
bool "Guition JC1060P470CIWY"
config TT_DEVICE_GUITION_JC2432W328C
bool "Guition JC2432W328C"
config TT_DEVICE_GUITION_JC3248W535C
bool "Guition JC3248W535C"
config TT_DEVICE_GUITION_JC8048W550C
bool "Guition JC8048W550C"
config TT_DEVICE_HELTEC_V3
bool "Heltec v3"
config TT_DEVICE_LILYGO_TDECK
bool "LilyGo T-Deck"
config TT_DEVICE_LILYGO_TDONGLE_S3
bool "LilyGo T-Dongle S3"
config TT_DEVICE_LILYGO_TLORA_PAGER
bool "LilyGo T-Lora Pager"
config TT_DEVICE_LILYGO_TDISPLAY
bool "LilyGo T-Display"
config TT_DEVICE_M5STACK_CARDPUTER
bool "M5Stack Cardputer"
config TT_DEVICE_M5STACK_CARDPUTER_ADV
bool "M5Stack Cardputer Adv"
config TT_DEVICE_M5STACK_CORE2
bool "M5Stack Core2"
config TT_DEVICE_M5STACK_CORES3
bool "M5Stack CoreS3"
config TT_DEVICE_M5STACK_STICKC_PLUS
bool "M5Stack StickC Plus"
config TT_DEVICE_M5STACK_STICKC_PLUS2
bool "M5Stack StickC Plus2"
config TT_DEVICE_M5STACK_TAB5
bool "M5Stack Tab5"
config TT_DEVICE_UNPHONE
bool "unPhone"
config TT_DEVICE_WAVESHARE_ESP32_S3_GEEK
bool "Waveshare ESP32 S3 GEEK"
config TT_DEVICE_WAVESHARE_S3_TOUCH_43
bool "Waveshare ESP32 S3 Touch LCD 4.3"
config TT_DEVICE_WAVESHARE_S3_TOUCH_LCD_147
bool "Waveshare ESP32 S3 Touch LCD 1.47"
config TT_DEVICE_WAVESHARE_S3_TOUCH_LCD_128
bool "Waveshare ESP32 S3 Touch LCD 1.28"
config TT_DEVICE_WAVESHARE_S3_LCD_13
bool "Waveshare ESP32 S3 LCD 1.3"
config TT_DEVICE_WIRELESS_TAG_WT32_SC01_PLUS
bool "Wireless Tag WT32-SC01 Plus"
help
Select a device.
Use TT_DEVICE_CUSTOM if you will manually configure the device in your project.
endchoice
config TT_SPLASH_DURATION
int "Splash Duration (ms)"
default 1000

View File

@ -54,4 +54,11 @@ dependencies:
version: "1.7.6~1"
rules:
- if: "target == esp32s3"
FastEPD:
git: https://github.com/bitbank2/FastEPD.git
version: 1.4.2
rules:
# More hardware might be supported - enable as needed
- if: "target in [esp32s3]"
idf: '5.5'

View File

@ -26,6 +26,9 @@ public:
virtual bool isPoweredOn() const { return true; }
virtual bool supportsPowerControl() const { return false; }
/** For e-paper screens */
virtual void requestFullRefresh() {}
/** Could return nullptr if not started */
virtual std::shared_ptr<touch::TouchDevice> getTouchDevice() = 0;

View File

@ -148,6 +148,8 @@ def write_spiram_variables(output_file, device_properties: ConfigParser):
output_file.write("CONFIG_SPIRAM=y\n")
output_file.write(f"CONFIG_{idf_target.upper()}_SPIRAM_SUPPORT=y\n")
mode = get_property_or_exit(device_properties, "hardware", "spiRamMode")
if mode == "OPI":
mode = "OCT"
# Mode
if mode != "AUTO":
output_file.write(f"CONFIG_SPIRAM_MODE_{mode}=y\n")