From 85a523872418d114a57d83b2e9a5370a8843ee70 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sat, 18 Jul 2026 11:05:06 -0400 Subject: [PATCH] Add epd7in3e display driver component Ports Waveshare's official EPD_7in3e.c register/refresh sequence (the panel has no public datasheet, so their reference driver is the source of truth) to an ESP-IDF component using spi_master + gpio instead of the bcm2835/RPi hardware abstraction the reference targets. Unlike the reference driver, which toggles CS around every single byte, this holds CS low for each logical command/data phase and DMAs pixel data in 4KB chunks -- sending ~192,000 individual one-byte SPI transactions would make a full refresh impractically slow. Exposes a streaming API (epd_display_stream, pulling chunks from a caller-supplied read_fn) so the eventual HTTP fetch path can feed the panel without holding a full ~192KB frame in RAM, plus an in-memory convenience wrapper (epd_display_buffer) for cases like the upcoming QR code screen where buffering the whole frame is fine. Wired into wifi_provisioning_start() as an init + white-clear for now, to prove the driver builds/links/runs at the right point in the boot sequence ahead of the actual QR code content. --- firmware/components/epd7in3e/CMakeLists.txt | 3 + firmware/components/epd7in3e/Kconfig | 39 +++ firmware/components/epd7in3e/epd7in3e.c | 293 ++++++++++++++++++ .../components/epd7in3e/include/epd7in3e.h | 47 +++ firmware/main/CMakeLists.txt | 2 +- firmware/main/wifi_provisioning.c | 15 +- 6 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 firmware/components/epd7in3e/CMakeLists.txt create mode 100644 firmware/components/epd7in3e/Kconfig create mode 100644 firmware/components/epd7in3e/epd7in3e.c create mode 100644 firmware/components/epd7in3e/include/epd7in3e.h diff --git a/firmware/components/epd7in3e/CMakeLists.txt b/firmware/components/epd7in3e/CMakeLists.txt new file mode 100644 index 0000000..009eeee --- /dev/null +++ b/firmware/components/epd7in3e/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "epd7in3e.c" + INCLUDE_DIRS "include" + PRIV_REQUIRES esp_driver_spi esp_driver_gpio) diff --git a/firmware/components/epd7in3e/Kconfig b/firmware/components/epd7in3e/Kconfig new file mode 100644 index 0000000..e5785c5 --- /dev/null +++ b/firmware/components/epd7in3e/Kconfig @@ -0,0 +1,39 @@ +menu "E-Paper Display (epd7in3e) Configuration" + + config EPD_PIN_CLK + int "SPI CLK (SCLK) GPIO" + default 18 + help + Not physically wired yet -- these are sensible ESP32-C6 devkit + defaults (avoiding strapping pins 4/5/8/9/15 and the USB-JTAG + pins 12/13). Override to match your actual wiring. + + config EPD_PIN_MOSI + int "SPI MOSI (DIN) GPIO" + default 19 + + config EPD_PIN_CS + int "SPI CS GPIO" + default 20 + + config EPD_PIN_DC + int "Data/Command GPIO" + default 21 + + config EPD_PIN_RST + int "Reset GPIO" + default 22 + + config EPD_PIN_BUSY + int "Busy GPIO" + default 23 + + config EPD_SPI_CLOCK_HZ + int "SPI clock speed (Hz)" + default 4000000 + help + The panel's SPI interface is rated well above this, but dupont + wires/breadboards are often unreliable much past a few MHz. + Raise this once the physical wiring is confirmed solid. + +endmenu diff --git a/firmware/components/epd7in3e/epd7in3e.c b/firmware/components/epd7in3e/epd7in3e.c new file mode 100644 index 0000000..b309c5b --- /dev/null +++ b/firmware/components/epd7in3e/epd7in3e.c @@ -0,0 +1,293 @@ +#include + +#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 "epd7in3e.h" + +/* Command bytes and register values below are a line-for-line transcription + * of Waveshare's official EPD_7in3e.c reference driver (RaspberryPi_JetsonNano/c + * variant) -- this panel's controller has no public datasheet, so the + * upstream driver is the source of truth. Unlike that driver (which toggles + * CS around every single byte), this port holds CS low for the duration of + * each logical command or data phase and DMAs data in chunks, since 192,000 + * one-byte SPI transactions would make a full refresh impractically slow. */ + +#define EPD_SPI_HOST SPI2_HOST +#define EPD_SPI_CHUNK_SIZE 4096 + +static const char *TAG = "epd7in3e"; + +#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. */ +static void epd_wait_busy(void) +{ + while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) { + epd_delay_ms(1); + } +} + +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, 0); + esp_err_t err = epd_spi_write(&cmd, 1); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS, 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, 0); + esp_err_t err = epd_spi_write(data, len); + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS, 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); +} + +/* Power on, "second setting" registers, refresh, power off -- mirrors + * EPD_7IN3E_TurnOnDisplay() in the reference driver. */ +static esp_err_t epd_turn_on_display(void) +{ + EPD_CHECK(epd_send_command(0x04)); // POWER_ON + epd_wait_busy(); + + EPD_CHECK(epd_send_command(0x06)); + EPD_CHECK(epd_send_data((uint8_t[]){ 0x6F, 0x1F, 0x17, 0x49 }, 4)); + + EPD_CHECK(epd_send_command(0x12)); // DISPLAY_REFRESH + EPD_CHECK(epd_send_data_byte(0x00)); + epd_wait_busy(); + + EPD_CHECK(epd_send_command(0x02)); // POWER_OFF + EPD_CHECK(epd_send_data_byte(0x00)); + epd_wait_busy(); + + return ESP_OK; +} + +esp_err_t epd_init(void) +{ + gpio_config_t out_cfg = { + .pin_bit_mask = (1ULL << CONFIG_EPD_PIN_DC) | (1ULL << CONFIG_EPD_PIN_RST) | (1ULL << CONFIG_EPD_PIN_CS), + .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, 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, + /* CS is bit-banged around each command/data phase above rather than + * hardware-driven, since a phase can span many chunked SPI + * transactions and must stay asserted across all of them. */ + .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); + + EPD_CHECK(epd_send_command(0xAA)); // CMDH + EPD_CHECK(epd_send_data((uint8_t[]){ 0x49, 0x55, 0x20, 0x08, 0x09, 0x18 }, 6)); + + EPD_CHECK(epd_send_command(0x01)); + EPD_CHECK(epd_send_data_byte(0x3F)); + + EPD_CHECK(epd_send_command(0x00)); + EPD_CHECK(epd_send_data((uint8_t[]){ 0x5F, 0x69 }, 2)); + + EPD_CHECK(epd_send_command(0x03)); + EPD_CHECK(epd_send_data((uint8_t[]){ 0x00, 0x54, 0x00, 0x44 }, 4)); + + EPD_CHECK(epd_send_command(0x05)); + EPD_CHECK(epd_send_data((uint8_t[]){ 0x40, 0x1F, 0x1F, 0x2C }, 4)); + + EPD_CHECK(epd_send_command(0x06)); + EPD_CHECK(epd_send_data((uint8_t[]){ 0x6F, 0x1F, 0x17, 0x49 }, 4)); + + EPD_CHECK(epd_send_command(0x08)); + EPD_CHECK(epd_send_data((uint8_t[]){ 0x6F, 0x1F, 0x1F, 0x22 }, 4)); + + EPD_CHECK(epd_send_command(0x30)); + EPD_CHECK(epd_send_data_byte(0x03)); + + EPD_CHECK(epd_send_command(0x50)); + EPD_CHECK(epd_send_data_byte(0x3F)); + + EPD_CHECK(epd_send_command(0x60)); + EPD_CHECK(epd_send_data((uint8_t[]){ 0x02, 0x00 }, 2)); + + EPD_CHECK(epd_send_command(0x61)); + EPD_CHECK(epd_send_data((uint8_t[]){ 0x03, 0x20, 0x01, 0xE0 }, 4)); + + EPD_CHECK(epd_send_command(0x84)); + EPD_CHECK(epd_send_data_byte(0x01)); + + EPD_CHECK(epd_send_command(0xE3)); + EPD_CHECK(epd_send_data_byte(0x2F)); + + EPD_CHECK(epd_send_command(0x04)); // POWER_ON; waits for the panel to release the idle signal + epd_wait_busy(); + + ESP_LOGI(TAG, "EPD initialized (CLK=%d MOSI=%d CS=%d DC=%d RST=%d BUSY=%d)", + CONFIG_EPD_PIN_CLK, CONFIG_EPD_PIN_MOSI, CONFIG_EPD_PIN_CS, + CONFIG_EPD_PIN_DC, CONFIG_EPD_PIN_RST, CONFIG_EPD_PIN_BUSY); + + return ESP_OK; +} + +esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx) +{ + 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, 0); + + uint8_t chunk[EPD_SPI_CHUNK_SIZE]; + size_t total = 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; + } + total += n; + } + + gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS, 1); + EPD_CHECK(err); + + if (total != EPD_FRAME_BYTES) { + ESP_LOGW(TAG, "Stream supplied %u bytes, expected %u", (unsigned)total, (unsigned)EPD_FRAME_BYTES); + } + + 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); +} + +esp_err_t epd_sleep(void) +{ + EPD_CHECK(epd_send_command(0x02)); // POWER_OFF + EPD_CHECK(epd_send_data_byte(0x00)); + epd_wait_busy(); + + EPD_CHECK(epd_send_command(0x07)); // DEEP_SLEEP + EPD_CHECK(epd_send_data_byte(0xA5)); + + return ESP_OK; +} diff --git a/firmware/components/epd7in3e/include/epd7in3e.h b/firmware/components/epd7in3e/include/epd7in3e.h new file mode 100644 index 0000000..5148c85 --- /dev/null +++ b/firmware/components/epd7in3e/include/epd7in3e.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include "esp_err.h" + +/* Waveshare 7.3" E Ink Spectra 6 (E6) panel: 800x480, 4 bits/pixel packed + * 2-pixels-per-byte, per the official EPD_7in3e.c reference driver. */ +#define EPD_WIDTH 800 +#define EPD_HEIGHT 480 +#define EPD_BYTES_PER_ROW ((EPD_WIDTH + 1) / 2) +#define EPD_FRAME_BYTES (EPD_BYTES_PER_ROW * EPD_HEIGHT) + +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 ~192KB frame in RAM. + */ +esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx); + +/** 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); diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 1d78629..272f829 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,3 +1,3 @@ idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c - PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server dns_server + PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server dns_server epd7in3e EMBED_FILES root.html) diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index ac3b3d6..f1cb9ef 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -19,6 +19,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "epd7in3e.h" #include "wifi_provisioning.h" #define NVS_NAMESPACE "frame_cfg" @@ -370,11 +371,19 @@ void wifi_provisioning_start(void) char ap_password[FRAME_AP_PASSWORD_LEN + 1]; ap_identity_get(ap_ssid, sizeof(ap_ssid), ap_password, sizeof(ap_password)); - /* TODO(step 5): draw the WiFi-join QR code + plaintext password on the - * e-ink panel here, before the AP goes up, so the join instructions are - * always visible by the time the network is joinable. */ ESP_LOGI(TAG, "Provisioning AP: SSID='%s' password='%s'", ap_ssid, ap_password); + /* Bring up the display before the AP so join instructions are on-screen + * by the time the network is joinable. + * TODO(step 5): replace this clear with the WiFi-join QR code + + * plaintext password. */ + esp_err_t epd_err = epd_init(); + if (epd_err == ESP_OK) { + epd_clear(EPD_COLOR_WHITE); + } else { + ESP_LOGW(TAG, "EPD init failed (%s), continuing without display", esp_err_to_name(epd_err)); + } + esp_netif_create_default_wifi_ap(); wifi_init_softap(ap_ssid, ap_password);