From 511ea9804c009f04527abb36eeebb486acaf9673 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Sun, 17 May 2026 15:04:10 -0400 Subject: [PATCH] fix(13e6): bit-banged SPI to defeat hardware-SPI color mapping bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-in-one 13.3" module mis-maps colors when data is sent via hardware SPI: epd_fill(COLOR_RED) renders YELLOW; epd_fill(COLOR_WHITE) renders BLUE; image data collapses to a single hue. Tried 4 MHz, 1 MHz, and 100 kHz hardware SPI — all produced wrong colors. The test-display-13e6 smoke test uses bit-banged SPI via direct GPIO toggles and renders all 6 colors faithfully. Porting that exact approach into the production driver fixes setup-screen rendering: WeVisto banner, harbor backdrop, both QR codes, and the orientation tile graphics all appear correctly. Cost: ~5 s extra per full-frame push at ~400 kHz effective rate (down from <1 s on the broken hardware SPI). For setup screens that's acceptable; for photo cycles it adds ~10 s to a draw, well below the ~15 s panel refresh itself. Also simplified ghost_clear back to a single white pre-pass — the earlier multi-stage cycles (3-pass, 7-pass) were attempting to fight what we thought was particle ghosting but turned out to be data corruption. One white pre-pass is now sufficient given the data path is correct. Bumps PANEL_FW_VERSION to v1.0.7. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/panels/waveshare13e6/v1/epd_driver.cpp | 147 +++++++++------------ src/panels/waveshare13e6/v1/version.h | 2 +- 2 files changed, 63 insertions(+), 86 deletions(-) diff --git a/src/panels/waveshare13e6/v1/epd_driver.cpp b/src/panels/waveshare13e6/v1/epd_driver.cpp index 7493c00..5b8df9c 100644 --- a/src/panels/waveshare13e6/v1/epd_driver.cpp +++ b/src/panels/waveshare13e6/v1/epd_driver.cpp @@ -56,6 +56,22 @@ static void wait_busy() { } } +// ── Bit-banged SPI ───────────────────────────────────────────────────────────── +// On the 13e6 all-in-one module, hardware SPI (4 MHz, 1 MHz, even 100 kHz) +// mis-maps colors — epd_fill(RED) renders as YELLOW or BLUE, image data +// collapses to a single hue. The known-good test-display-13e6 smoke test +// uses bit-banged SPI and renders all 6 colors faithfully, so we use the +// same approach here. Effective rate ~400 kHz; for a full frame push (~960 +// KB) that's ~2.5 s per half — acceptable. +static inline void spi_write_byte(uint8_t b) { + for (int i = 0; i < 8; i++) { + digitalWrite(PIN_MOSI, (b & 0x80) ? HIGH : LOW); + b <<= 1; + digitalWrite(PIN_SCK, HIGH); + digitalWrite(PIN_SCK, LOW); + } +} + // ── CS / cmd / data helpers ──────────────────────────────────────────────────── // Pattern: assert one or both CS lines, send cmd byte with DC=LOW, then // stream data with DC=HIGH, then deassert. Matches Waveshare's reference. @@ -66,19 +82,19 @@ static inline void cs_both(int v) { cs(PIN_CS_M, v); cs(PIN_CS_S, v); static void begin_cmd_both(uint8_t c) { cs_both(LOW); digitalWrite(PIN_DC, LOW); - SPI.transfer(c); + spi_write_byte(c); digitalWrite(PIN_DC, HIGH); } static void begin_cmd(int cs_pin, uint8_t c) { cs(cs_pin, LOW); digitalWrite(PIN_DC, LOW); - SPI.transfer(c); + spi_write_byte(c); digitalWrite(PIN_DC, HIGH); } static void send_data_n(const uint8_t* buf, size_t n) { - SPI.writeBytes(buf, n); + for (size_t i = 0; i < n; i++) spi_write_byte(buf[i]); } static void send_cmd_with_data_both(uint8_t c, const uint8_t* buf, size_t n) { @@ -116,19 +132,19 @@ void epd_setup_pins() { pinMode(PIN_BUSY, INPUT); pinMode(PIN_CS_M, OUTPUT); pinMode(PIN_CS_S, OUTPUT); + pinMode(PIN_SCK, OUTPUT); + pinMode(PIN_MOSI, OUTPUT); digitalWrite(PIN_PWR, HIGH); digitalWrite(PIN_CS_M, HIGH); digitalWrite(PIN_CS_S, HIGH); digitalWrite(PIN_RST, HIGH); + digitalWrite(PIN_SCK, LOW); + digitalWrite(PIN_MOSI, LOW); - // FSPI on S3, manual CS. Matches the 7.3" production speed (4 MHz) — - // tried 10 MHz first and the panel showed a yellow cast across the - // body, suggesting bit corruption on long bursts (likely PSRAM-DMA - // cache coherency or SI margin issues on the long traces inside the - // all-in-one module). - SPI.begin(PIN_SCK, -1, PIN_MOSI, -1); - SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0)); + // No SPI.begin() — using bit-banged SPI via spi_write_byte() so we + // match the test-display-13e6 reference exactly. Hardware SPI on this + // module mis-maps colors (RED→YELLOW etc.) regardless of clock speed. } void epd_init() { @@ -182,20 +198,20 @@ static void epd_refresh() { wait_busy(); delay(50); - begin_cmd_both(0x12); SPI.transfer(0x00); // DRF (full refresh) + begin_cmd_both(0x12); spi_write_byte(0x00); // DRF (full refresh) cs_both(HIGH); wait_busy(); - begin_cmd_both(0x02); SPI.transfer(0x00); // POWER_OFF + begin_cmd_both(0x02); spi_write_byte(0x00); // POWER_OFF cs_both(HIGH); } void epd_sleep() { cs_both(LOW); digitalWrite(PIN_DC, LOW); - SPI.transfer(0x07); // DEEP_SLEEP + spi_write_byte(0x07); // DEEP_SLEEP digitalWrite(PIN_DC, HIGH); - SPI.transfer(0xA5); // sentinel + spi_write_byte(0xA5); // sentinel cs_both(HIGH); s_initialized = false; @@ -212,71 +228,27 @@ void epd_sleep() { // ── Draw helpers ─────────────────────────────────────────────────────────────── -// DMA-safe scratch buffer (internal SRAM). SPI.writeBytes on ESP32-S3 uses -// GDMA for bulk transfers; DMA can't reliably read from PSRAM-backed -// buffers because the CPU's cache may hold writes that haven't reached -// PSRAM yet — symptom is a yellow/random cast across the panel. Pushing -// from internal SRAM in chunks sidesteps that entirely. 8 KB is a -// per-half-row sweet spot (~27 rows of 300 bytes), enough that the -// per-transfer overhead is negligible. -static constexpr size_t DMA_CHUNK = 8192; -static uint8_t* g_dma_scratch = nullptr; - -static void ensure_dma_scratch() { - if (!g_dma_scratch) { - g_dma_scratch = (uint8_t*)heap_caps_malloc( - DMA_CHUNK, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA); - Serial.printf("[epd13e6] dma_scratch=%p (free internal=%u)\n", - g_dma_scratch, - (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); - } -} - -// Push one half's framebuffer slice to its CS line via DMA-safe chunks. -// half_fb points into PSRAM (the full framebuffer); we copy CHUNK bytes -// into internal SRAM, then SPI.writeBytes that. Repeat until done. -static void push_half(int cs_pin, const uint8_t* half_fb, size_t bytes) { - ensure_dma_scratch(); - if (!g_dma_scratch) return; - - begin_cmd(cs_pin, 0x10); - for (size_t off = 0; off < bytes; off += DMA_CHUNK) { - size_t n = (bytes - off < DMA_CHUNK) ? (bytes - off) : DMA_CHUNK; - memcpy(g_dma_scratch, half_fb + off, n); - SPI.writeBytes(g_dma_scratch, n); - } - cs(cs_pin, HIGH); -} - // Take a full-frame row-major framebuffer (BYTES_PER_ROW × H) and push -// left-then-right halves. Deinterleaves into a PSRAM scratch slice so -// each half is row-contiguous, then push_half streams it through the -// DMA-safe internal-SRAM chunk buffer. +// left-then-right halves via bit-banged SPI directly from the PSRAM fb. +// Per-byte CPU loop, ~400 kHz effective rate, ~5 s per full frame. static void push_full_frame(const uint8_t* fb) { - constexpr size_t HALF_BYTES = (size_t)HALF_BYTES_ROW * H; - uint8_t* slice = (uint8_t*)heap_caps_malloc(HALF_BYTES, MALLOC_CAP_SPIRAM); - if (!slice) { - Serial.printf("[epd13e6] push_full_frame: slice alloc FAILED (free PSRAM=%u)\n", - (unsigned)ESP.getFreePsram()); - return; - } - Serial.println("[epd13e6] push_full_frame: pushing halves"); + Serial.println("[epd13e6] push_full_frame: pushing halves (bit-banged)"); - for (uint16_t y = 0; y < H; y++) { - memcpy(slice + (size_t)y * HALF_BYTES_ROW, - fb + (size_t)y * BYTES_PER_ROW, - HALF_BYTES_ROW); + for (int half = 0; half < 2; half++) { + const int cs_pin = (half == 0) ? PIN_CS_M : PIN_CS_S; + const size_t col_off = (size_t)half * HALF_BYTES_ROW; + begin_cmd(cs_pin, 0x10); + for (uint16_t y = 0; y < H; y++) { + const uint8_t* row = fb + (size_t)y * BYTES_PER_ROW + col_off; + for (uint16_t x = 0; x < HALF_BYTES_ROW; x++) { + spi_write_byte(row[x]); + } + // Yield to the watchdog every ~16 rows so it doesn't reset us + // during the multi-second per-half push. + if ((y & 0x0F) == 0) esp_task_wdt_reset(); + } + cs(cs_pin, HIGH); } - push_half(PIN_CS_M, slice, HALF_BYTES); - - for (uint16_t y = 0; y < H; y++) { - memcpy(slice + (size_t)y * HALF_BYTES_ROW, - fb + (size_t)y * BYTES_PER_ROW + HALF_BYTES_ROW, - HALF_BYTES_ROW); - } - push_half(PIN_CS_S, slice, HALF_BYTES); - - heap_caps_free(slice); Serial.println("[epd13e6] push_full_frame: done"); } @@ -290,7 +262,8 @@ void epd_fill(uint8_t color) { const int p = (half == 0) ? PIN_CS_M : PIN_CS_S; begin_cmd(p, 0x10); for (size_t i = 0; i < (size_t)HALF_BYTES_ROW * H; i++) { - SPI.transfer(byte); + spi_write_byte(byte); + if ((i & 0x1FFF) == 0) esp_task_wdt_reset(); } cs(p, HIGH); } @@ -450,23 +423,27 @@ static void draw_from_lfs(const char* path, uint8_t fallback_color, // QR placeholders move to (W - old_x - QR_PX, H - old_y - QR_PX). // AP (518 px QR): pre-rot 642,590 → post-rot 40,492 // Setup (574 px QR): pre-rot 313,750 → post-rot 313,276 (X centred) -// Setup screens (yellow AP / red retry / green setup) are mostly two-tone -// against a small palette. Transitioning from a full-color photo to one of -// these in a single DRF cycle leaves visible ghost of the previous image — -// Spectra-6's color particles need more discharge time than one refresh -// provides. Pre-clear to white first so the panel is fully reset, then draw -// the actual screen. Doubles the refresh time on setup events only. -void epd_draw_ap_screen(QRCode* qr) { +// Setup screens transition the panel from a full-color photo to a +// mostly-yellow background. A single white pre-pass discharges the +// previous-image particles enough that the subsequent setup-screen draw +// renders cleanly. (Earlier multi-stage cycles attempted to defeat a +// "ghost" that turned out to be push_full_frame data corruption — the +// real fix is in push_full_frame; one pre-pass is now sufficient.) +static void ghost_clear() { epd_fill(COLOR_WHITE); +} + +void epd_draw_ap_screen(QRCode* qr) { + ghost_clear(); draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 40, 492, 14); } void epd_draw_ap_screen_retry(QRCode* qr) { - epd_fill(COLOR_WHITE); + ghost_clear(); draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 40, 492, 14); } void epd_draw_setup_screen(QRCode* qr) { - epd_fill(COLOR_WHITE); + ghost_clear(); draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 276, 14); } diff --git a/src/panels/waveshare13e6/v1/version.h b/src/panels/waveshare13e6/v1/version.h index e47e767..48ddde3 100644 --- a/src/panels/waveshare13e6/v1/version.h +++ b/src/panels/waveshare13e6/v1/version.h @@ -3,4 +3,4 @@ // Panel-specific firmware version for the Waveshare 13.3" Spectra-6 driver. // Bump on each driver change worth correlating with server-side reports. // Independent of the shared firmware version (HTTP / NVS / sleep / etc.). -#define PANEL_FW_VERSION "v1.0.3" +#define PANEL_FW_VERSION "v1.0.7"