Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s

Server: Frame.panel_type (new column + migration) is auto-derived from
the device's reported board (X-Frame-Board), never user-set -- the
panel is a property of the hardware, not a picker in the UI.
image_pipeline's packing/render pipeline is parameterized by panel
geometry instead of hardcoded 800x480 globals, with the real confirmed
13.3in geometry (1600x1200) registered alongside the original 7.3in
panel. Existing 7.3in frames are unaffected (column default + board
mapping both resolve to the original panel).

Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/
xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO
module -- "xiao" alone stopped disambiguating hardware. The server
keeps accepting the legacy bare names indefinitely for already-flashed
devices.

Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
This commit is contained in:
2026-08-04 20:08:22 +00:00
parent 1d39e439ff
commit 474b92a282
44 changed files with 1287 additions and 172 deletions
@@ -0,0 +1,13 @@
# SRCS is conditional on which board's panel this build targets -- see
# epd7in3e/CMakeLists.txt's identical comment (the two components mirror
# each other: exactly one contributes actual object files/symbols to any
# given build, the other is required but empty).
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "epd13in3e.c")
else()
set(srcs "")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
+65
View File
@@ -0,0 +1,65 @@
menu "E-Paper Display (epd13in3e) Configuration"
config EPD_PIN_CLK
int "SPI CLK (SCLK) GPIO"
default 7
help
Defaults sourced from a community-verified ESPHome
integration for this exact board
(github.com/rkaramandi/esphome-seeed-ee02) -- NOT an
official Waveshare/Seeed reference driver (see
firmware/components/epd13in3e/epd13in3e.c's top comment,
which is about the still-unknown panel init/LUT/refresh
register sequence, a separate and larger unknown than this
pinout). Override if your own board wiring differs.
config EPD_PIN_MOSI
int "SPI MOSI (DIN) GPIO"
default 9
config EPD_PIN_CS_MASTER
int "SPI CS (master half) GPIO"
default 44
help
Unlike epd7in3e's single-CS interface, this panel is driven
as two halves over one shared CLK/MOSI/DC/RST/BUSY bus with
two independent chip-selects (master/slave) -- confirmed by
the same community ESPHome integration, not yet by this
component's own driver code (still unimplemented, see
epd13in3e.c).
config EPD_PIN_CS_SLAVE
int "SPI CS (slave half) GPIO"
default 41
config EPD_PIN_DC
int "Data/Command GPIO"
default 10
config EPD_PIN_RST
int "Reset GPIO"
default 38
config EPD_PIN_BUSY
int "Busy GPIO"
default 4
config EPD_PIN_POWER_EN
int "Panel power-enable GPIO"
default 43
help
No equivalent pin on epd7in3e's board -- the EE02 apparently
gates the panel's own power rail separately from the ESP32-S3
module's. Source: same community integration as the other
pins above.
config EPD_SPI_CLOCK_HZ
int "SPI clock speed (Hz)"
default 2000000
help
2MHz, not epd7in3e's 4MHz default -- the same community
integration notes higher rates were unreliable on this
panel/board combo. Revisit once wiring is confirmed on real
hardware.
endmenu
+292
View File
@@ -0,0 +1,292 @@
#include <string.h>
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_rom_crc.h"
#include "epd13in3e.h"
/* This component only ever gets compiled in when CONFIG_FRAME_PANEL_EE02_
* 13IN3=y selects it as main/CMakeLists.txt's linked EPD driver (see
* main/epd_board.h) -- i.e. only when someone deliberately builds for the
* EE02 board. epd7in3e.c's own top comment explains why its exact
* command bytes/register values are trustworthy: they're a line-for-line
* transcription of Waveshare's own reference driver, since this class of
* panel controller has no public datasheet. No equivalent reference
* driver for the 13.3" panel + EE02 exists in this tree yet, and guessing
* at register/LUT/timing values is not a safe substitute -- wrong values
* can under-refresh (ghosting) or over-drive a real panel. Get Waveshare's
* or Seeed's official demo/reference code for this exact panel+board
* combo, port its command sequences the same way epd7in3e.c's were
* ported, and remove this #error as part of that. The SPI/GPIO plumbing
* below (bus init, busy-wait, chunked writes, the streaming/CRC contract)
* is NOT panel-specific and should carry over unchanged once that
* happens -- only the register sequences inside epd_init()/
* epd_turn_on_display()/epd_sleep() need real vendor values.
*
* One more thing the real driver logic will need to account for, beyond
* epd7in3e.c's shape: this panel is driven as two halves sharing one
* CLK/MOSI/DC/RST/BUSY bus but with two independent chip-selects
* (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE, see Kconfig) -- epd_send_command/
* epd_send_data below still only assert CS_MASTER, which is wrong for
* whichever commands/data need to go to the slave half instead. Source
* for the dual-CS pinout itself (not the command sequence) is a
* community-verified ESPHome integration for this exact board
* (github.com/rkaramandi/esphome-seeed-ee02), not an official Seeed/
* Waveshare reference -- treat it as a reasonable starting point, not
* gospel, until confirmed against real hardware. */
#error "epd13in3e: panel init/LUT/refresh register sequence not yet ported from vendor demo code -- see this file's top comment"
#define EPD_SPI_HOST SPI2_HOST
#define EPD_SPI_CHUNK_SIZE 4096
static const char *TAG = "epd13in3e";
#define EPD_CHECK(expr) ESP_RETURN_ON_ERROR((expr), TAG, #expr)
static spi_device_handle_t s_spi;
static void epd_delay_ms(uint32_t ms)
{
vTaskDelay(pdMS_TO_TICKS(ms));
}
/* BUSY: LOW = busy, HIGH = idle -- same polarity convention as epd7in3e.c;
* confirm against the vendor demo code once it exists (some EPD
* controllers invert this). See epd7in3e.c's own comment for why this
* polls in >= 1 FreeRTOS tick increments rather than a tight spin. */
static void epd_wait_busy(void)
{
while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) {
epd_delay_ms(20);
}
}
static esp_err_t epd_spi_write(const uint8_t *data, size_t len)
{
while (len > 0) {
size_t n = len > EPD_SPI_CHUNK_SIZE ? EPD_SPI_CHUNK_SIZE : len;
spi_transaction_t t = {
.length = n * 8,
.tx_buffer = data,
};
EPD_CHECK(spi_device_polling_transmit(s_spi, &t));
data += n;
len -= n;
}
return ESP_OK;
}
static esp_err_t epd_send_command(uint8_t cmd)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_spi_write(&cmd, 1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
return err;
}
static esp_err_t epd_send_data(const uint8_t *data, size_t len)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_spi_write(data, len);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
return err;
}
static esp_err_t epd_send_data_byte(uint8_t data)
{
return epd_send_data(&data, 1);
}
static void epd_reset(void)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(20);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
epd_delay_ms(2);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(20);
}
/* TODO(epd13in3e): power-on/refresh/power-off register sequence -- see
* this file's top #error. epd7in3e.c's epd_turn_on_display() is the
* shape to mirror once the real command bytes are known. */
esp_err_t epd_turn_on_display(void)
{
(void)epd_send_command;
(void)epd_send_data;
(void)epd_send_data_byte;
(void)epd_wait_busy;
return ESP_ERR_NOT_SUPPORTED;
}
esp_err_t epd_init(void)
{
/* CS_SLAVE and POWER_EN are configured as outputs here (safe,
* mechanical) but not yet driven anywhere below -- the dual-CS
* command routing and whatever power-on-vs-reset sequencing
* POWER_EN needs are both part of the still-unported vendor
* register sequence (see this file's top comment), not something
* to guess at. */
gpio_config_t out_cfg = {
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_DC) | (1ULL << CONFIG_EPD_PIN_RST) |
(1ULL << CONFIG_EPD_PIN_CS_MASTER) | (1ULL << CONFIG_EPD_PIN_CS_SLAVE) |
(1ULL << CONFIG_EPD_PIN_POWER_EN),
.mode = GPIO_MODE_OUTPUT,
};
EPD_CHECK(gpio_config(&out_cfg));
gpio_config_t busy_cfg = {
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_BUSY),
.mode = GPIO_MODE_INPUT,
};
EPD_CHECK(gpio_config(&busy_cfg));
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
spi_bus_config_t bus_cfg = {
.mosi_io_num = CONFIG_EPD_PIN_MOSI,
.miso_io_num = -1,
.sclk_io_num = CONFIG_EPD_PIN_CLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = EPD_SPI_CHUNK_SIZE,
};
EPD_CHECK(spi_bus_initialize(EPD_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO));
spi_device_interface_config_t dev_cfg = {
.clock_speed_hz = CONFIG_EPD_SPI_CLOCK_HZ,
.mode = 0,
.spics_io_num = -1,
.queue_size = 1,
};
EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
epd_reset();
epd_wait_busy();
epd_delay_ms(30);
/* TODO(epd13in3e): panel-specific power-on register sequence goes
* here, see this file's top #error. */
ESP_LOGE(TAG, "epd_init: panel register sequence not yet ported, EPD will not actually work");
return ESP_ERR_NOT_SUPPORTED;
}
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32)
{
ESP_RETURN_ON_FALSE(read_fn != NULL, ESP_ERR_INVALID_ARG, TAG, "read_fn required");
EPD_CHECK(epd_send_command(0x10));
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
/* Static rather than a stack local -- see epd7in3e.c's identical
* comment on why (default main task stack is smaller than this
* chunk buffer alone). */
static uint8_t chunk[EPD_SPI_CHUNK_SIZE];
size_t total = 0;
uint32_t crc = 0;
size_t n;
esp_err_t err = ESP_OK;
while ((n = read_fn(chunk, sizeof(chunk), ctx)) > 0) {
err = epd_spi_write(chunk, n);
if (err != ESP_OK) {
break;
}
crc = esp_rom_crc32_le(crc, chunk, n);
total += n;
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
EPD_CHECK(err);
if (total != EPD_FRAME_BYTES) {
/* Same invariant as epd7in3e.c: never trigger a refresh on a
* short/wrong-size stream. */
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
(unsigned)total, (unsigned)EPD_FRAME_BYTES);
return ESP_ERR_INVALID_SIZE;
}
if (out_crc32 != NULL) {
*out_crc32 = crc;
}
return ESP_OK;
}
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx)
{
esp_err_t err = epd_write_frame(read_fn, ctx, NULL);
if (err != ESP_OK) {
return err;
}
return epd_turn_on_display();
}
typedef struct {
const uint8_t *data;
size_t len;
size_t pos;
} epd_buf_ctx_t;
static size_t epd_buf_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
epd_buf_ctx_t *c = (epd_buf_ctx_t *)ctx_;
size_t remaining = c->len - c->pos;
size_t n = remaining < chunk_size ? remaining : chunk_size;
if (n == 0) {
return 0;
}
memcpy(chunk, c->data + c->pos, n);
c->pos += n;
return n;
}
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len)
{
epd_buf_ctx_t buf_ctx = { .data = frame, .len = len, .pos = 0 };
return epd_display_stream(epd_buf_read, &buf_ctx);
}
typedef struct {
uint8_t fill_byte;
size_t remaining;
} epd_fill_ctx_t;
static size_t epd_fill_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
epd_fill_ctx_t *c = (epd_fill_ctx_t *)ctx_;
size_t n = c->remaining < chunk_size ? c->remaining : chunk_size;
if (n == 0) {
return 0;
}
memset(chunk, c->fill_byte, n);
c->remaining -= n;
return n;
}
esp_err_t epd_clear(epd_color_t color)
{
epd_fill_ctx_t fill_ctx = {
.fill_byte = (uint8_t)((color << 4) | color),
.remaining = EPD_FRAME_BYTES,
};
return epd_display_stream(epd_fill_read, &fill_ctx);
}
/* TODO(epd13in3e): power-off/deep-sleep register sequence -- see this
* file's top #error. */
esp_err_t epd_sleep(void)
{
return ESP_ERR_NOT_SUPPORTED;
}
@@ -0,0 +1,89 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
/* Waveshare 13.3" e-Paper (E) Spectra 6 panel, driven by Seeed's EE02
* board (XIAO ESP32-S3 Plus): 1600x1200, 4 bits/pixel packed
* 2-pixels-per-byte -- same packing convention and public function
* shapes as epd7in3e.h, just a different resolution, so main's own
* sources don't need to branch on which panel is active beyond
* main/epd_board.h's
* header selection. Confirmed from Waveshare's/Seeed's public product
* pages (270.40x202.80mm, 1600x1200px). NOT yet confirmed against the
* vendor's own reference driver code, which doesn't exist in this tree
* yet -- see epd13in3e.c's top comment and epd_init()'s stub. The panel
* is also driven as two halves over a shared bus with two independent
* chip-selects (see Kconfig's EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE), unlike
* epd7in3e's single-CS interface -- that's an implementation detail of
* epd13in3e.c, not something callers of this header need to know about. */
#define EPD_WIDTH 1600
#define EPD_HEIGHT 1200
#define EPD_BYTES_PER_ROW ((EPD_WIDTH + 1) / 2)
#define EPD_FRAME_BYTES (EPD_BYTES_PER_ROW * EPD_HEIGHT)
/* Same 6-ink Spectra family as the 7.3" panel, so the same 6 named
* colors -- but whether this panel's controller uses the SAME nibble
* values as epd7in3e.h's epd_color_t is UNCONFIRMED (see this file's own
* top comment). Left identical to epd7in3e.h's values as the working
* assumption; correct these against the vendor demo code once it exists,
* alongside server/app/image_pipeline.py's PANEL_CODES if they turn out
* to differ (see that file's own comment on PANEL_CODES). */
typedef enum {
EPD_COLOR_BLACK = 0x0,
EPD_COLOR_WHITE = 0x1,
EPD_COLOR_YELLOW = 0x2,
EPD_COLOR_RED = 0x3,
EPD_COLOR_BLUE = 0x5,
EPD_COLOR_GREEN = 0x6,
} epd_color_t;
/** Configures SPI + GPIO and runs the panel's power-on register init sequence. */
esp_err_t epd_init(void);
/** Fills the whole panel with a single color and refreshes. */
esp_err_t epd_clear(epd_color_t color);
/**
* Called repeatedly by epd_display_stream() to fill up to chunk_size bytes
* into chunk. Must return the number of bytes written, or 0 once exhausted.
*/
typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx);
/**
* Streams a full frame (EPD_FRAME_BYTES bytes, packed 2 pixels/byte) to the
* panel via read_fn and refreshes. Pulling from a caller-supplied source
* instead of a single buffer lets callers feed the panel directly from an
* HTTP response without holding the whole ~960KB frame in RAM.
*/
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx);
/**
* Like epd_display_stream(), but writes the frame into the panel's
* internal buffer over SPI WITHOUT triggering the physical refresh (the
* visible flash/flicker) -- call epd_turn_on_display() separately to make
* it visible. Returns ESP_ERR_INVALID_SIZE if read_fn didn't supply
* exactly EPD_FRAME_BYTES, same as epd_display_stream(); either way
* nothing is refreshed, so the visible screen is left untouched on
* error.
*
* If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written --
* lets a caller compare against the last-displayed frame's CRC and skip
* the refresh entirely when nothing actually changed (e.g. redisplaying
* the same photo after a reboot).
*/
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32);
/**
* Triggers the panel's physical refresh cycle (power on, refresh, power
* off) -- the visible flash/flicker sequence. Call after epd_write_frame()
* to make the written buffer visible.
*/
esp_err_t epd_turn_on_display(void);
/** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
/** Puts the panel into deep sleep to minimize power draw between refreshes. */
esp_err_t epd_sleep(void);
+13 -1
View File
@@ -1,3 +1,15 @@
idf_component_register(SRCS "epd7in3e.c"
# SRCS is conditional on which board's panel this build targets (see
# main/CMakeLists.txt's comment on why REQUIRES/PRIV_REQUIRES itself
# can't be) -- an ee02 build still always requires this component (so
# its Kconfig menu/include dir exist), but contributes zero object
# files/symbols to it, since epd13in3e.c provides the real epd_init()
# etc. for that board instead.
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "")
else()
set(srcs "epd7in3e.c")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)