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:
@@ -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;
|
||||
// Take a full-frame row-major framebuffer (BYTES_PER_ROW × H) and push
|
||||
// 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) {
|
||||
Serial.println("[epd13e6] push_full_frame: pushing halves (bit-banged)");
|
||||
|
||||
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 (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);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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");
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user