From 454c03586e3b3f2ca665922a06000a4cbc336a66 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 4 Aug 2026 22:00:18 +0000 Subject: [PATCH] Port real epd13in3e driver from vendor code; fix wire-raster stride bug Vendored the panel's init/LUT/refresh register sequence from three independent Waveshare reference drivers for this exact panel+controller (RaspberryPi/c, ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example), which all agree byte-for-byte. The epd13in3e.c #error is gone; it compiles clean and links (verified via /build-firmware ee02). That vendor code also revealed the panel's SPI wire raster is a native 1200x1600 (portrait), not 1600x1200 as previously assumed -- rotated 90 degrees from the panel's landscape mount/marketing size. The old assumption wasn't just a rotation bug: 1600x1200 and 1200x1600 don't share a row stride, so packing at the wrong one would have shredded images into a repeating diagonal garble on real hardware, not just displayed them sideways. Fixed with a new PANEL_WIRE_TRANSPOSE in image_pipeline.py, applied after the existing per-frame ORIENTATION_TRANSPOSE, with a direction-agnostic regression test that catches the stride bug specifically (a byte-count check alone can't, since both orientations pack to the same total size). A full ee02 build still fails, but no longer because of this driver -- main/{back,next,combo}_button.c call an ESP32-C6-only deep-sleep GPIO-wakeup API with no ESP32-S3 fallback, a separate pre-existing gap that was simply hidden behind the panel driver's old #error. See docs/hardware.md for details; CI's continue-on-error on this board stays in place until that's fixed too. --- docs/hardware.md | 82 +++-- firmware/README.md | 37 +- firmware/components/epd13in3e/epd13in3e.c | 324 +++++++++++++----- .../components/epd13in3e/include/epd13in3e.h | 55 +-- server/app/image_pipeline.py | 72 +++- server/tests/test_render_size_invariants.py | 54 +++ 6 files changed, 464 insertions(+), 160 deletions(-) diff --git a/docs/hardware.md b/docs/hardware.md index c4d6668..d1939f6 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -163,30 +163,65 @@ an ESP32-S3) existed and "xiao" alone stopped disambiguating hardware. The server keeps accepting the old bare names indefinitely, since already-flashed devices can't be retroactively renamed. -## 13.3" Spectra 6 panel on Seeed's EE02 board (driver not yet functional) +## 13.3" Spectra 6 panel on Seeed's EE02 board (panel driver ported; board still doesn't build end-to-end) A second panel size is supported server-side (the web UI shows a read-only "Panel: 13.3\" Spectra 6" once a frame's device reports -itself as `ee02`) but **the firmware driver for it does not work yet** --- `firmware/components/epd13in3e`'s panel init/LUT/refresh register -sequence hasn't been ported from Waveshare's/Seeed's own reference code -(which wasn't available at the time this was scaffolded), so a build for -this board deliberately fails to compile (`#error`) rather than risk -sending wrong register/LUT/timing values to real hardware. Confirmed so -far, from Waveshare's/Seeed's public product pages and a community -ESPHome integration (not an official vendor reference driver): +itself as `ee02`), and **the panel driver itself is now real and +compiles clean** -- `firmware/components/epd13in3e`'s init/LUT/refresh +register sequence is a line-for-line port of Waveshare's own reference +drivers for this exact panel+controller, confirmed identically across +three independent vendor sources (Waveshare's RaspberryPi/c and ESP32 +drivers for this panel, plus Waveshare's own ESP-IDF example for their +ESP32-S3-ePaper-13.3E6 driver board -- a different carrier than EE02, +but the same panel/controller, hence the same command bytes). See that +component's own top comment for details, and +`server/app/image_pipeline.py`'s `PANEL_WIRE_TRANSPOSE` for a load-bearing +correction that came with it: the panel's SPI wire raster is a *native +1200x1600 (portrait)* raster, rotated 90 degrees from the panel's +1600x1200 landscape mount/marketing size -- getting that backwards +doesn't just rotate the image, it shreds it (1600x1200 and 1200x1600 +don't share a row stride). + +**A full `ee02` build still fails**, but for an unrelated, pre-existing +reason now that the panel driver's own `#error` no longer stops the +build early: `firmware/main/{back,next,combo}_button.c` call +`esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown()`, an ESP32-C6-only +deep-sleep GPIO-wakeup API (gated by `SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`, +which ESP32-S3's `soc_caps.h` doesn't define) with no ESP32-S3 fallback +path. This was always broken for `ee02`, just masked behind the panel +driver's own compile failure -- see the note two paragraphs up about the +button GPIO range not yet being verified against the ESP32-S3, which was +already flagging this same gap before it had an actual compile error +attached to it. ESP32-S3 does support GPIO deep-sleep wakeup via +`esp_sleep_enable_ext1_wakeup()`/`esp_sleep_get_ext1_wakeup_status()` +(and so, notably, does ESP32-C6 -- `SOC_PM_SUPPORT_EXT1_WAKEUP` is set on +both chips), but EXT1 wakeup takes one combined GPIO mask/mode across all +three button files' independent calls rather than each button +registering its own -- porting to it is a real (if likely small) +cross-board refactor, not a mechanical swap, and hasn't been done. CI's +`firmware-build-check.yml`/`firmware-release-build.yml` `continue-on-error` +on this board's step stays in place until it is. + +Confirmed so far: - Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) -- - 1600x1200, 270.40x202.80mm, same 6-ink Spectra family as the 7.3" - panel. Full refresh ~19s. + 1600x1200 mount size, 270.40x202.80mm, same 6-ink Spectra family as + the 7.3" panel (and, now vendor-confirmed, the identical 4-bit nibble + color codes). Full refresh ~19s. SPI wire raster is 1200x1600 (see + above). - Board: [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html) -- a XIAO ESP32-S3 Plus (16MB flash, 8MB PSRAM) socketed into a dedicated driver PCB, one reset + three user buttons, JST 2.0mm battery connector with built-in charging IC. - Wiring (source: [github.com/rkaramandi/esphome-seeed-ee02](https://github.com/rkaramandi/esphome-seeed-ee02), a community integration, not Seeed's own schematic -- - treat as a starting point, confirm before relying on it): unlike - epd7in3e's single chip-select, this panel is driven as two halves - sharing one CLK/MOSI/DC/RST/BUSY bus with independent chip-selects. + treat as a starting point, confirm before relying on it; Waveshare's + own ESP32-S3-ePaper-13.3E6 example uses different GPIO numbers, but + that's for Waveshare's own driver board, a different carrier than + EE02, so it doesn't apply here). Unlike epd7in3e's single chip-select, + this panel is driven as two halves sharing one CLK/MOSI/DC/RST/BUSY bus + with independent chip-selects -- now confirmed by the real driver code + too (master = left half, slave = right half of each row). | Signal | GPIO | Kconfig option | | --- | --- | --- | @@ -203,10 +238,17 @@ ESPHome integration (not an official vendor reference driver): maps to which logical role (next/back/menu) isn't confirmed, and the firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc., `firmware/main/Kconfig.projbuild`) still range-limit to GPIO 0-7 -- - the ESP32-C6's deep-sleep-wakeup-capable set, not yet verified against - the ESP32-S3's. SPI clock is reportedly reliable only up to 2MHz on - this panel/board (vs. epd7in3e's 4MHz default) -- see - `firmware/sdkconfig.ee02`. + the ESP32-C6's deep-sleep-wakeup-capable set (see the build-blocker + note above). SPI clock is reportedly reliable only up to 2MHz on this + panel/board per the community ESPHome integration (vs. epd7in3e's 4MHz + default) -- see `firmware/sdkconfig.ee02`. Waveshare's own + ESP32-S3-ePaper-13.3E6 example defaults to 10MHz, but that's a + different carrier board, so it's a data point to try once real EE02 + hardware exists, not a reason to bump the current conservative default + blind. -See `firmware/components/epd13in3e/epd13in3e.c`'s own top comment for -what's needed to actually make this board work. +Once the button-wakeup gap above is fixed, remaining unknowns before +trusting this on real hardware: the wiring table (community-sourced, not +official), and `PANEL_WIRE_TRANSPOSE`'s rotation *direction* +(`ROTATE_90` vs `ROTATE_270` -- a physical-assembly fact no vendor driver +encodes, see that dict's own comment in `image_pipeline.py`). diff --git a/firmware/README.md b/firmware/README.md index acc382d..6e681fe 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -2,9 +2,9 @@ ESP-IDF firmware for the ESP32-C6 (devkit/xiao boards, 7.3" panel) or ESP32-S3 (ee02 board, 13.3" panel -- see -[Building for Seeed's EE02](#building-for-seeeds-ee02-esp32-s3--133-panel-driver-not-yet-functional) -below; the driver for that panel doesn't work yet). On first boot it -provisions itself over +[Building for Seeed's EE02](#building-for-seeeds-ee02-esp32-s3--133-panel-driver-ported-board-still-doesnt-build-end-to-end) +below; the panel driver itself works now, but the board doesn't build +end-to-end yet for an unrelated reason). On first boot it provisions itself over a WiFi captive portal; after that it wakes on a timer, fetches an already-processed frame from the [server](../server/), streams it straight to the panel over SPI, and goes back to deep sleep. @@ -52,7 +52,7 @@ never clobbers the other: (`./build_for_board.sh devkit ...` does the same for the dev board -- equivalent to a plain `idf.py`, just consistent with the XIAO invocation.) -### Building for Seeed's EE02 (ESP32-S3 + 13.3" panel, driver not yet functional) +### Building for Seeed's EE02 (ESP32-S3 + 13.3" panel, driver ported; board still doesn't build end-to-end) EE02 is a different chip (ESP32-S3, not C6), so it needs `set-target esp32s3` instead of `esp32c6`, and its own partition table/flash-size @@ -64,16 +64,25 @@ Kconfig sized for its 16MB flash ./build_for_board.sh ee02 build ``` -**This will fail to compile.** `firmware/components/epd13in3e`'s panel -init/LUT/refresh register sequence hasn't been ported from vendor demo -code yet (see that file's own top comment and -[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-driver-not-yet-functional)) --- a deliberate `#error`, not a bug in this build path. Everything -around it (target selection, Kconfig, partition table, sdkconfig -layering, `main/CMakeLists.txt`'s component selection) is in place and -exercised by CI (`.gitea/workflows/firmware-build-check.yml`/ -`firmware-release-build.yml`, both with `continue-on-error` on this -board's step until the driver is real). +**This still fails to compile, but no longer because of the panel +driver.** `firmware/components/epd13in3e`'s panel init/LUT/refresh +register sequence is now a real, vendor-confirmed port (see that +component's own top comment and +[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-panel-driver-ported-board-still-doesnt-build-end-to-end) +for the vendor sources and the load-bearing native-raster-orientation +correction that came with it) -- it compiles clean on its own. The build +now fails one step later, in `main/{back,next,combo}_button.c`: they call +an ESP32-C6-only deep-sleep GPIO-wakeup API with no ESP32-S3 fallback, a +pre-existing gap that the panel driver's old `#error` simply stopped the +build before reaching. See `docs/hardware.md`'s same section for what a +real fix looks like (not a mechanical swap -- ESP32-S3's equivalent API +takes one combined mask across all three button files' independent +calls). Everything else around the panel driver (target selection, +Kconfig, partition table, sdkconfig layering, `main/CMakeLists.txt`'s +component selection) is in place and exercised by CI +(`.gitea/workflows/firmware-build-check.yml`/`firmware-release-build.yml`, +both with `continue-on-error` on this board's step until a full build is +actually green). ## Configuration (`idf.py menuconfig`) diff --git a/firmware/components/epd13in3e/epd13in3e.c b/firmware/components/epd13in3e/epd13in3e.c index b51ec7e..88f3b13 100644 --- a/firmware/components/epd13in3e/epd13in3e.c +++ b/firmware/components/epd13in3e/epd13in3e.c @@ -2,6 +2,7 @@ #include "driver/gpio.h" #include "driver/spi_master.h" +#include "esp_heap_caps.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_check.h" @@ -10,36 +11,31 @@ #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. +/* Command bytes/register values below are a line-for-line transcription of + * Waveshare's official reference drivers for this exact panel+controller -- + * confirmed identical across three independent sources (RaspberryPi/c, + * ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example; see this + * component's header for repo paths). Same "don't clean these up" rule as + * epd7in3e.c: this class of panel controller has no public datasheet, so + * the vendor driver is the source of truth for every byte. * - * 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" + * Unlike epd7in3e's single chip-select, this panel is driven as two + * independent controllers sharing one CLK/MOSI/DC/RST/BUSY bus but with + * separate chip-selects (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE) -- most init + * commands broadcast to both (CS_ALL), a handful of power/boost commands + * go only to the master (which owns the shared analog rails), and actual + * frame data is split per-row into a left half (master) and right half + * (slave), 300 bytes each out of each 600-byte row. That split is why + * epd_write_frame below buffers the whole frame in PSRAM before sending + * anything (every row needs slicing in half before either half can go out), + * unlike epd7in3e.c's straight single-CS passthrough streaming. + * + * Pin numbers themselves are NOT from this vendor code -- Waveshare's + * ESP32-S3-ePaper-13.3E6 example targets Waveshare's own driver board, a + * different carrier than Seeed's EE02 this project actually uses, so its + * GPIO numbers don't apply here. EE02's pins remain sourced from a + * community-verified ESPHome integration (see this component's Kconfig), + * not an official reference. */ #define EPD_SPI_HOST SPI2_HOST #define EPD_SPI_CHUNK_SIZE 4096 @@ -48,6 +44,49 @@ static const char *TAG = "epd13in3e"; #define EPD_CHECK(expr) ESP_RETURN_ON_ERROR((expr), TAG, #expr) +/* --- panel command opcodes --- */ +#define PSR 0x00 +#define PWR 0x01 +#define POF 0x02 +#define PON 0x04 +#define BTST_N 0x05 +#define BTST_P 0x06 +#define DTM 0x10 /* data transfer (frame data) */ +#define DRF 0x12 /* display refresh */ +#define CDI 0x50 +#define TCON 0x60 +#define TRES 0x61 +#define AN_TM 0x74 +#define AGID 0x86 +#define BUCK_BOOST_VDDN 0xB0 +#define TFT_VCOM_POWER 0xB1 +#define EN_BUF 0xB6 +#define BOOST_VDDP_EN 0xB7 +#define CCSET 0xE0 +#define PWS 0xE3 +#define CMD66 0xF0 +#define DEEP_SLEEP 0x07 + +/* --- canned init parameter blobs (do NOT edit -- see top comment) --- */ +static const uint8_t PSR_V[] = {0xDF, 0x69}; +static const uint8_t PWR_V[] = {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38}; +static const uint8_t POF_V[] = {0x00}; +static const uint8_t DRF_V[] = {0x00}; +static const uint8_t CDI_V[] = {0xF7}; +static const uint8_t TCON_V[] = {0x03, 0x03}; +static const uint8_t TRES_V[] = {0x04, 0xB0, 0x03, 0x20}; +static const uint8_t CMD66_V[] = {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10}; +static const uint8_t EN_BUF_V[] = {0x07}; +static const uint8_t CCSET_V[] = {0x01}; +static const uint8_t PWS_V[] = {0x22}; +static const uint8_t AN_TM_V[] = {0xC0, 0x1C, 0x1C, 0xCC, 0xCC, 0xCC, 0x15, 0x15, 0x55}; +static const uint8_t AGID_V[] = {0x10}; +static const uint8_t BTST_P_V[] = {0xE8, 0x28}; +static const uint8_t BOOST_VDDP_EN_V[] = {0x01}; +static const uint8_t BTST_N_V[] = {0xE8, 0x28}; +static const uint8_t BUCK_BOOST_VDDN_V[] = {0x01}; +static const uint8_t TFT_VCOM_POWER_V[] = {0x02}; + static spi_device_handle_t s_spi; static void epd_delay_ms(uint32_t ms) @@ -55,10 +94,9 @@ 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. */ +/* BUSY: LOW = busy, HIGH = idle -- same polarity/poll-interval reasoning + * as epd7in3e.c's identical comment (a tight 1ms-rounds-to-0-ticks poll + * starves the idle task badly enough to trip the watchdog). */ static void epd_wait_busy(void) { while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) { @@ -81,22 +119,20 @@ static esp_err_t epd_spi_write(const uint8_t *data, size_t len) return ESP_OK; } +/* Unlike epd7in3e.c's send_command/send_data, these do NOT touch CS -- + * this panel's two independent chip-selects (and the "broadcast to both" + * vs "master only" split the init sequence needs) mean CS bracketing has + * to be the caller's decision, not baked into the byte-send primitive. */ 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; + return epd_spi_write(&cmd, 1); } 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; + return epd_spi_write(data, len); } static esp_err_t epd_send_data_byte(uint8_t data) @@ -104,36 +140,74 @@ static esp_err_t epd_send_data_byte(uint8_t data) return epd_send_data(&data, 1); } +static void epd_cs_both(int level) +{ + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, level); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, level); +} + +/* Sends `cmd` + its data blob to both controllers at once (most of the + * init sequence -- shared display-timing/power registers). */ +static esp_err_t epd_cmd_both(uint8_t cmd, const uint8_t *data, size_t len) +{ + epd_cs_both(0); + esp_err_t err = epd_send_command(cmd); + if (err == ESP_OK && data != NULL) { + err = epd_send_data(data, len); + } + epd_cs_both(1); + return err; +} + +/* Sends `cmd` + its data blob to the master controller only -- the boost/ + * VCOM power registers the master alone owns. */ +static esp_err_t epd_cmd_master(uint8_t cmd, const uint8_t *data, size_t len) +{ + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0); + esp_err_t err = epd_send_command(cmd); + if (err == ESP_OK && data != NULL) { + err = epd_send_data(data, len); + } + epd_cs_both(1); + return err; +} + +/* 5-edge reset sequence (30ms each) -- per-vendor-source exact, more edges + * than epd7in3e.c's 3-edge/20ms reset. */ static void epd_reset(void) { gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1); - epd_delay_ms(20); + epd_delay_ms(30); gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0); - epd_delay_ms(2); + epd_delay_ms(30); gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1); - epd_delay_ms(20); + epd_delay_ms(30); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0); + epd_delay_ms(30); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1); + epd_delay_ms(30); } -/* 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. */ +/* Power on, refresh, power off -- mirrors EPD_TurnOnDisplay()/ + * EPD_13IN3E_TurnOnDisplay() in the reference drivers. */ 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; + EPD_CHECK(epd_cmd_both(PON, NULL, 0)); + epd_wait_busy(); + + epd_delay_ms(50); + EPD_CHECK(epd_cmd_both(DRF, DRF_V, sizeof(DRF_V))); + epd_wait_busy(); + + epd_delay_ms(50); + EPD_CHECK(epd_cmd_both(POF, POF_V, sizeof(POF_V))); + /* No busy-wait after POF -- matches every reference driver. */ + + return ESP_OK; } 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) | @@ -148,8 +222,9 @@ esp_err_t epd_init(void) }; 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); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1); + epd_cs_both(1); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0); spi_bus_config_t bus_cfg = { .mosi_io_num = CONFIG_EPD_PIN_MOSI, @@ -164,59 +239,123 @@ esp_err_t epd_init(void) spi_device_interface_config_t dev_cfg = { .clock_speed_hz = CONFIG_EPD_SPI_CLOCK_HZ, .mode = 0, - .spics_io_num = -1, + .spics_io_num = -1, /* both chip-selects are bit-banged by hand above */ .queue_size = 1, }; EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi)); + /* Panel power-enable rail (no equivalent on epd7in3e's board -- EE02 + * gates it separately from the ESP32-S3 module's own supply). */ + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 1); + epd_delay_ms(10); + 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"); + /* Master-only: shared analog-timing register. */ + EPD_CHECK(epd_cmd_master(AN_TM, AN_TM_V, sizeof(AN_TM_V))); - return ESP_ERR_NOT_SUPPORTED; + /* Broadcast: display-timing/power-sequencing registers both + * controllers need identically. */ + EPD_CHECK(epd_cmd_both(CMD66, CMD66_V, sizeof(CMD66_V))); + EPD_CHECK(epd_cmd_both(PSR, PSR_V, sizeof(PSR_V))); + EPD_CHECK(epd_cmd_both(CDI, CDI_V, sizeof(CDI_V))); + EPD_CHECK(epd_cmd_both(TCON, TCON_V, sizeof(TCON_V))); + EPD_CHECK(epd_cmd_both(AGID, AGID_V, sizeof(AGID_V))); + EPD_CHECK(epd_cmd_both(PWS, PWS_V, sizeof(PWS_V))); + EPD_CHECK(epd_cmd_both(CCSET, CCSET_V, sizeof(CCSET_V))); + EPD_CHECK(epd_cmd_both(TRES, TRES_V, sizeof(TRES_V))); + + /* Master-only: boost/VCOM power programming. */ + EPD_CHECK(epd_cmd_master(PWR, PWR_V, sizeof(PWR_V))); + EPD_CHECK(epd_cmd_master(EN_BUF, EN_BUF_V, sizeof(EN_BUF_V))); + EPD_CHECK(epd_cmd_master(BTST_P, BTST_P_V, sizeof(BTST_P_V))); + EPD_CHECK(epd_cmd_master(BOOST_VDDP_EN, BOOST_VDDP_EN_V, sizeof(BOOST_VDDP_EN_V))); + EPD_CHECK(epd_cmd_master(BTST_N, BTST_N_V, sizeof(BTST_N_V))); + EPD_CHECK(epd_cmd_master(BUCK_BOOST_VDDN, BUCK_BOOST_VDDN_V, sizeof(BUCK_BOOST_VDDN_V))); + EPD_CHECK(epd_cmd_master(TFT_VCOM_POWER, TFT_VCOM_POWER_V, sizeof(TFT_VCOM_POWER_V))); + + ESP_LOGI(TAG, "EPD initialized (CLK=%d MOSI=%d CS_M=%d CS_S=%d DC=%d RST=%d BUSY=%d PWR_EN=%d)", + CONFIG_EPD_PIN_CLK, CONFIG_EPD_PIN_MOSI, CONFIG_EPD_PIN_CS_MASTER, + CONFIG_EPD_PIN_CS_SLAVE, CONFIG_EPD_PIN_DC, CONFIG_EPD_PIN_RST, + CONFIG_EPD_PIN_BUSY, CONFIG_EPD_PIN_POWER_EN); + + return ESP_OK; } 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)); + /* Every row has to be sliced into a left (master) and right (slave) + * half before either half can go out over SPI, so -- unlike + * epd7in3e.c's single-CS passthrough -- bytes can't be forwarded to + * the wire as they arrive. Buffer the whole ~938KB frame in PSRAM + * first (EE02's XIAO ESP32-S3 Plus has 8MB of it). */ + uint8_t *frame = heap_caps_malloc(EPD_FRAME_BYTES, MALLOC_CAP_SPIRAM); + if (frame == NULL) { + ESP_LOGE(TAG, "OOM allocating %u-byte frame buffer", (unsigned)EPD_FRAME_BYTES); + return ESP_ERR_NO_MEM; + } - 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); + while (total < EPD_FRAME_BYTES && + (n = read_fn(frame + total, EPD_FRAME_BYTES - total, ctx)) > 0) { + crc = esp_rom_crc32_le(crc, frame + total, 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. */ + /* Same invariant as epd7in3e.c: never touch the panel on a + * short/wrong-size stream -- the visible screen is left exactly + * as it was. */ ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh", (unsigned)total, (unsigned)EPD_FRAME_BYTES); + free(frame); return ESP_ERR_INVALID_SIZE; } + /* De-interleave into one half-buffer at a time and DMA it out as a + * single contiguous transfer (chunked internally by epd_spi_write) -- + * far fewer, far larger SPI transactions than sending 1600 separate + * 300-byte rows per side. */ + const size_t HALF_ROW = EPD_BYTES_PER_ROW / 2; /* 300 */ + const size_t HALF_BUF = HALF_ROW * EPD_HEIGHT; /* 480000 */ + uint8_t *half = heap_caps_malloc(HALF_BUF, MALLOC_CAP_SPIRAM); + if (half == NULL) { + ESP_LOGE(TAG, "OOM allocating %u-byte half-frame scratch buffer", (unsigned)HALF_BUF); + free(frame); + return ESP_ERR_NO_MEM; + } + + for (size_t r = 0; r < EPD_HEIGHT; r++) { + memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW, HALF_ROW); + } + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0); + esp_err_t err = epd_send_command(DTM); + if (err == ESP_OK) { + err = epd_send_data(half, HALF_BUF); + } + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1); + + if (err == ESP_OK) { + for (size_t r = 0; r < EPD_HEIGHT; r++) { + memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW + HALF_ROW, HALF_ROW); + } + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 0); + err = epd_send_command(DTM); + if (err == ESP_OK) { + err = epd_send_data(half, HALF_BUF); + } + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1); + } + + free(half); + free(frame); + EPD_CHECK(err); + if (out_crc32 != NULL) { *out_crc32 = crc; } @@ -284,9 +423,16 @@ esp_err_t epd_clear(epd_color_t color) 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; + epd_cs_both(0); + EPD_CHECK(epd_send_command(DEEP_SLEEP)); + EPD_CHECK(epd_send_data_byte(0xA5)); /* magic deep-sleep arg per every reference driver */ + epd_cs_both(1); + + epd_delay_ms(100); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0); + + return ESP_OK; } diff --git a/firmware/components/epd13in3e/include/epd13in3e.h b/firmware/components/epd13in3e/include/epd13in3e.h index 309a4f7..bc0f2bb 100644 --- a/firmware/components/epd13in3e/include/epd13in3e.h +++ b/firmware/components/epd13in3e/include/epd13in3e.h @@ -5,31 +5,42 @@ #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 + * board (XIAO ESP32-S3 Plus). The panel is marketed/mounted as a + * 1600x1200 landscape rectangle (270.40x202.80mm), but its SPI + * controller addresses a native raster of 1200 columns x 1600 rows -- + * i.e. the wire format is portrait, rotated 90 degrees from how the + * panel physically hangs. Confirmed identically across three independent + * vendor sources: Waveshare's RaspberryPi/c and ESP32 reference drivers + * for this exact panel (E-paper_Separate_Program/13.3inch_e-Paper_E in + * waveshare/e-Paper), and Waveshare's own ESP-IDF example for their + * ESP32-S3-ePaper-13.3E6 driver board (a *different* carrier board than + * Seeed's EE02, but the same panel+controller, hence the same command + * bytes/geometry -- only the GPIO numbers differ, and those come from + * EE02-specific sources, see this component's Kconfig). All three define + * EPD_WIDTH=1200/EPD_HEIGHT=1600 and split each row into two 600-byte + * (300px) halves sent to independent chip-selects: EPD_PIN_CS_MASTER + * gets the left half, EPD_PIN_CS_SLAVE the right -- see epd13in3e.c. + * + * Getting this backwards (assuming the wire raster matches the + * 1600x1200 mount/marketing size) doesn't just rotate the image -- 1600 + * and 1200 don't share a row stride with 1200 and 1600 the other way + * (800 bytes/row x 1200 rows vs 600 bytes/row x 1600 rows), so a mismatch + * here slices real image rows at the wrong byte offsets and shreds the + * picture into a repeating diagonal garble, not a clean rotation. + * server/app/image_pipeline.py's PANEL_WIRE_TRANSPOSE handles the + * corresponding rotation server-side before packing bytes for this + * panel_type -- this header and that dict must agree on which axis is + * native. */ +#define EPD_WIDTH 1200 +#define EPD_HEIGHT 1600 #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). */ +/* Same 6-ink Spectra family as the 7.3" panel, and (now confirmed by the + * same three vendor sources as the geometry above) the same 4-bit nibble + * codes as epd7in3e.h's epd_color_t -- matches + * server/app/image_pipeline.py's PANEL_CODES unconditionally, no + * panel-specific table needed there. */ typedef enum { EPD_COLOR_BLACK = 0x0, EPD_COLOR_WHITE = 0x1, diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index 72216bb..2fcf45b 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -19,15 +19,47 @@ DEFAULT_PANEL_TYPE = "epd7in3e" PANEL_SPECS: dict[str, tuple[int, int]] = { "epd7in3e": (EPD_WIDTH, EPD_HEIGHT), # Waveshare's 13.3" e-Paper (E) Spectra 6 panel (270.40x202.80mm, - # 1600x1200px, 4:3) driven by Seeed's EE02 board -- confirmed from - # Waveshare's/Seeed's public product pages, not yet from real - # hardware or vendor demo code (see firmware/components/epd13in3e's - # own docstring once it exists -- the init/LUT/refresh sequence and - # whether this panel's nibble color codes actually match PANEL_CODES - # below are still unconfirmed pending that). + # 1600x1200px, 4:3) driven by Seeed's EE02 board. This is the panel's + # MOUNT/marketing size, not its SPI wire raster -- the controller + # itself addresses a native 1200x1600 (portrait) raster, rotated 90 + # degrees from how the panel physically hangs. Both facts are now + # vendor-confirmed (see firmware/components/epd13in3e's own docstring) + # -- PANEL_SPECS stays in mount/logical terms like the 7.3" panel's + # entry (everything upstream of packing -- composition, the widget + # grid, face-label placement -- reasons in this space); the wire-raster + # rotation is applied only at pack time, see PANEL_WIRE_TRANSPOSE. "epd13in3e": (1600, 1200), } +# Panels whose SPI wire raster is rotated 90 degrees from PANEL_SPECS's +# mount/logical size (see that dict's own comment on epd13in3e). None = +# wire raster already matches the logical size, no extra rotation (true +# for the 7.3" panel). Applied in _transpose_and_pack AFTER the +# user-selected ORIENTATION_TRANSPOSE -- these are two independent +# rotations for two independent reasons (how the frame is hung vs. a fixed +# fact about this panel's controller wiring) and must not be conflated. +# +# Getting this wrong doesn't just rotate the output image: 1600x1200 and +# 1200x1600 don't share a row stride (800 bytes/row x 1200 rows vs 600 +# bytes/row x 1600 rows), so packing at the wrong one slices real image +# rows at the wrong byte offsets and shreds the picture into a repeating +# diagonal garble on the real panel, not a clean rotation -- see +# test_transpose_and_pack_epd13in3e_uses_true_wire_raster_stride in +# tests/test_render_size_invariants.py, which catches exactly that +# regression without needing real hardware. +# +# Direction (ROTATE_90 vs ROTATE_270) is a physical-assembly fact this +# code can't derive from vendor driver bytes -- it depends on which edge +# of the panel ends up "up" in this project's frame housing. Picked +# ROTATE_90 as a documented placeholder; confirm/flip against real +# hardware once the EE02 firmware target is actually flashed and +# displaying (a wrong direction shows a rotated/mirrored image, not +# corruption, so it's safe to ship pending that check). +PANEL_WIRE_TRANSPOSE: dict[str, "Image.Transpose | None"] = { + "epd7in3e": None, + "epd13in3e": Image.Transpose.ROTATE_90, +} + # Human-readable label per PANEL_SPECS key, for the frame settings page's # read-only "Panel" line (see routers/device.py's BOARD_PANEL_MAP for how # a frame's panel_type actually gets set -- this is display-only). @@ -249,10 +281,10 @@ CALIBRATED_SPECTRA6_RGB = [ # firmware/components/epd7in3e), in the same order as DEFAULT_PALETTE_RGB/ # PALETTE_LABELS -- fixed by the hardware protocol, never user- # configurable. 0x4 is intentionally unused upstream. Used unconditionally -# for every panel_type today -- unverified whether the 13.3" panel's -# driver (once it exists, see PANEL_SPECS["epd13in3e"]) uses the same -# codes; if not, this needs to become a PANEL_CODE_TABLES dict keyed like -# PANEL_SPECS, with _transpose_and_pack taking the right one as a param. +# for every panel_type today -- confirmed (not just assumed) that the +# 13.3" panel's controller uses the identical codes, from the same vendor +# driver sources as PANEL_SPECS["epd13in3e"]'s own comment, so no +# panel-specific table is needed here. PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6] # Per-widget optional border (models.Widget.border_style, see @@ -505,17 +537,27 @@ def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG) -def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes: +def _transpose_and_pack(quantized: Image.Image, orientation: str, + panel_type: str = DEFAULT_PANEL_TYPE) -> bytes: """Rotates a logical-space quantized image into native panel space and packs it 2 pixels/byte the way the panel's EPD driver expects (see firmware/components/epd7in3e). Returns exactly width*height/2 bytes for whatever native size `quantized` actually is post-rotation -- the canvas was already built at the calling frame's own panel size (see panel_size()), so this derives dimensions from the image itself - rather than a fixed global.""" + rather than a fixed global. + + Two independent rotations happen here, in order: ORIENTATION_TRANSPOSE + (how the frame is physically hung -- a per-frame user choice), then + PANEL_WIRE_TRANSPOSE (a fixed fact about this panel_type's SPI wire + raster vs. its mount size -- see that dict's own comment). Most panels + need only the first; epd13in3e needs both.""" transpose = ORIENTATION_TRANSPOSE.get(orientation) if transpose is not None: quantized = quantized.transpose(transpose) + wire_transpose = PANEL_WIRE_TRANSPOSE.get(panel_type) + if wire_transpose is not None: + quantized = quantized.transpose(wire_transpose) pixels = quantized.load() w, h = quantized.size @@ -589,7 +631,7 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None, color_boost, contrast_boost) fitted = _apply_manage_overlay(fitted, manage) quantized = _quantize(fitted, palette_rgb, dither_strength) - return _transpose_and_pack(quantized, orientation) + return _transpose_and_pack(quantized, orientation, panel_type) def _png_bytes(img: Image.Image) -> bytes: @@ -658,7 +700,7 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o quantized = _quantize(fitted, palette_rgb, dither_strength) if as_png: return _png_bytes(quantized) - packed = _transpose_and_pack(quantized, orientation) + packed = _transpose_and_pack(quantized, orientation, panel_type) if capture_snapshot: return packed, _png_bytes(quantized) return packed @@ -760,7 +802,7 @@ def render_placeholder(lines: list[str], qr_url: str | None = None, quantized = _quantize(img, palette_rgb, dither_strength=1.0) if as_png: return _png_bytes(quantized) - packed = _transpose_and_pack(quantized, orientation) + packed = _transpose_and_pack(quantized, orientation, panel_type) if capture_snapshot: return packed, _png_bytes(quantized) return packed diff --git a/server/tests/test_render_size_invariants.py b/server/tests/test_render_size_invariants.py index ea00a28..9d580cd 100644 --- a/server/tests/test_render_size_invariants.py +++ b/server/tests/test_render_size_invariants.py @@ -241,3 +241,57 @@ def test_render_placeholder_size_for_the_real_13in3_panel(orientation): panel_w, panel_h = PANEL_SPECS["epd13in3e"] data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type="epd13in3e") assert len(data) == panel_w * panel_h // 2 + + +def test_transpose_and_pack_epd13in3e_uses_true_wire_raster_stride(): + """Regression guard for a corruption bug, not just a rotation bug: the + 13.3" panel's SPI controller addresses a native 1200x1600 raster (600 + bytes/row x 1600 rows), rotated 90 degrees from PANEL_SPECS's + 1600x1200 mount/marketing size (800 bytes/row x 1200 rows) -- see + PANEL_WIRE_TRANSPOSE's own comment. Both shapes pack to the identical + 960000-byte total, so a regression here wouldn't fail a plain length + assertion -- it would ship a driver that slices real image rows at the + wrong byte offsets and shreds the picture on a real panel. + + This probes stride, not rotation direction: a vertical stripe (values + constant along the *mount* image's y-axis) stays constant along + whichever axis absorbs that constancy under ANY 90-degree-multiple + rotation, so this holds regardless of which direction + PANEL_WIRE_TRANSPOSE ends up using -- only the true 600-byte wire row + stride makes each decoded row uniform; decoding at the wrong (800-byte + mount) stride would slice across real row boundaries and mix both + colors into every "row".""" + from PIL import ImageDraw + + from app.image_pipeline import ( + DEFAULT_PALETTE_RGB, + PANEL_SPECS, + _build_palette_image, + _transpose_and_pack, + ) + + mount_w, mount_h = PANEL_SPECS["epd13in3e"] # (1600, 1200) + wire_w, wire_h = mount_h, mount_w # (1200, 1600) -- the true SPI wire raster + + img = Image.new("RGB", (mount_w, mount_h), (255, 255, 255)) + ImageDraw.Draw(img).rectangle([0, 0, mount_w // 2 - 1, mount_h - 1], fill=(0, 0, 0)) + quantized = img.quantize(palette=_build_palette_image(DEFAULT_PALETTE_RGB)) + + packed = _transpose_and_pack(quantized, "landscape", panel_type="epd13in3e") + assert len(packed) == wire_w * wire_h // 2 + + row_bytes = wire_w // 2 # 600 -- the true wire row stride + first_row = packed[0:row_bytes] + last_row = packed[(wire_h - 1) * row_bytes: wire_h * row_bytes] + + def nibbles(row_bytes_slice): + vals = set() + for b in row_bytes_slice: + vals.add(b >> 4) + vals.add(b & 0x0F) + return vals + + first_nibbles, last_nibbles = nibbles(first_row), nibbles(last_row) + assert len(first_nibbles) == 1, "first wire row should be a single color at the true 600-byte stride" + assert len(last_nibbles) == 1, "last wire row should be a single color at the true 600-byte stride" + assert first_nibbles != last_nibbles, "the black/white split should still show up across wire rows"