fix(13e6): bit-banged SPI to defeat hardware-SPI color mapping bug

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 15:04:10 -04:00
parent d23f33178e
commit 511ea9804c
2 changed files with 63 additions and 86 deletions
+62 -85
View File
@@ -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 ──────────────────────────────────────────────────── // ── CS / cmd / data helpers ────────────────────────────────────────────────────
// Pattern: assert one or both CS lines, send cmd byte with DC=LOW, then // 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. // 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) { static void begin_cmd_both(uint8_t c) {
cs_both(LOW); cs_both(LOW);
digitalWrite(PIN_DC, LOW); digitalWrite(PIN_DC, LOW);
SPI.transfer(c); spi_write_byte(c);
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_DC, HIGH);
} }
static void begin_cmd(int cs_pin, uint8_t c) { static void begin_cmd(int cs_pin, uint8_t c) {
cs(cs_pin, LOW); cs(cs_pin, LOW);
digitalWrite(PIN_DC, LOW); digitalWrite(PIN_DC, LOW);
SPI.transfer(c); spi_write_byte(c);
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_DC, HIGH);
} }
static void send_data_n(const uint8_t* buf, size_t n) { 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) { 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_BUSY, INPUT);
pinMode(PIN_CS_M, OUTPUT); pinMode(PIN_CS_M, OUTPUT);
pinMode(PIN_CS_S, OUTPUT); pinMode(PIN_CS_S, OUTPUT);
pinMode(PIN_SCK, OUTPUT);
pinMode(PIN_MOSI, OUTPUT);
digitalWrite(PIN_PWR, HIGH); digitalWrite(PIN_PWR, HIGH);
digitalWrite(PIN_CS_M, HIGH); digitalWrite(PIN_CS_M, HIGH);
digitalWrite(PIN_CS_S, HIGH); digitalWrite(PIN_CS_S, HIGH);
digitalWrite(PIN_RST, 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) — // No SPI.begin() — using bit-banged SPI via spi_write_byte() so we
// tried 10 MHz first and the panel showed a yellow cast across the // match the test-display-13e6 reference exactly. Hardware SPI on this
// body, suggesting bit corruption on long bursts (likely PSRAM-DMA // module mis-maps colors (RED→YELLOW etc.) regardless of clock speed.
// 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));
} }
void epd_init() { void epd_init() {
@@ -182,20 +198,20 @@ static void epd_refresh() {
wait_busy(); wait_busy();
delay(50); 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); cs_both(HIGH);
wait_busy(); wait_busy();
begin_cmd_both(0x02); SPI.transfer(0x00); // POWER_OFF begin_cmd_both(0x02); spi_write_byte(0x00); // POWER_OFF
cs_both(HIGH); cs_both(HIGH);
} }
void epd_sleep() { void epd_sleep() {
cs_both(LOW); cs_both(LOW);
digitalWrite(PIN_DC, LOW); digitalWrite(PIN_DC, LOW);
SPI.transfer(0x07); // DEEP_SLEEP spi_write_byte(0x07); // DEEP_SLEEP
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_DC, HIGH);
SPI.transfer(0xA5); // sentinel spi_write_byte(0xA5); // sentinel
cs_both(HIGH); cs_both(HIGH);
s_initialized = false; s_initialized = false;
@@ -212,71 +228,27 @@ void epd_sleep() {
// ── Draw helpers ─────────────────────────────────────────────────────────────── // ── 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 // Take a full-frame row-major framebuffer (BYTES_PER_ROW × H) and push
// left-then-right halves. Deinterleaves into a PSRAM scratch slice so // left-then-right halves via bit-banged SPI directly from the PSRAM fb.
// each half is row-contiguous, then push_half streams it through the // Per-byte CPU loop, ~400 kHz effective rate, ~5 s per full frame.
// DMA-safe internal-SRAM chunk buffer.
static void push_full_frame(const uint8_t* fb) { static void push_full_frame(const uint8_t* fb) {
constexpr size_t HALF_BYTES = (size_t)HALF_BYTES_ROW * H; Serial.println("[epd13e6] push_full_frame: pushing halves (bit-banged)");
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");
for (uint16_t y = 0; y < H; y++) { for (int half = 0; half < 2; half++) {
memcpy(slice + (size_t)y * HALF_BYTES_ROW, const int cs_pin = (half == 0) ? PIN_CS_M : PIN_CS_S;
fb + (size_t)y * BYTES_PER_ROW, const size_t col_off = (size_t)half * HALF_BYTES_ROW;
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"); 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; const int p = (half == 0) ? PIN_CS_M : PIN_CS_S;
begin_cmd(p, 0x10); begin_cmd(p, 0x10);
for (size_t i = 0; i < (size_t)HALF_BYTES_ROW * H; i++) { 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); 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). // 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 // 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 (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 // Setup screens transition the panel from a full-color photo to a
// against a small palette. Transitioning from a full-color photo to one of // mostly-yellow background. A single white pre-pass discharges the
// these in a single DRF cycle leaves visible ghost of the previous image — // previous-image particles enough that the subsequent setup-screen draw
// Spectra-6's color particles need more discharge time than one refresh // renders cleanly. (Earlier multi-stage cycles attempted to defeat a
// provides. Pre-clear to white first so the panel is fully reset, then draw // "ghost" that turned out to be push_full_frame data corruption — the
// the actual screen. Doubles the refresh time on setup events only. // real fix is in push_full_frame; one pre-pass is now sufficient.)
void epd_draw_ap_screen(QRCode* qr) { static void ghost_clear() {
epd_fill(COLOR_WHITE); 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); draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 40, 492, 14);
} }
void epd_draw_ap_screen_retry(QRCode* qr) { 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); draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 40, 492, 14);
} }
void epd_draw_setup_screen(QRCode* qr) { 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); draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 276, 14);
} }
+1 -1
View File
@@ -3,4 +3,4 @@
// Panel-specific firmware version for the Waveshare 13.3" Spectra-6 driver. // Panel-specific firmware version for the Waveshare 13.3" Spectra-6 driver.
// Bump on each driver change worth correlating with server-side reports. // Bump on each driver change worth correlating with server-side reports.
// Independent of the shared firmware version (HTTP / NVS / sleep / etc.). // Independent of the shared firmware version (HTTP / NVS / sleep / etc.).
#define PANEL_FW_VERSION "v1.0.3" #define PANEL_FW_VERSION "v1.0.7"