fix(13e6): SPI corruption fix + setup-screen polish

- SPI corruption: lower clock to 4 MHz (matches 7.3" prod) and push from
  internal SRAM in 8 KB chunks instead of streaming directly from a PSRAM
  scratch buffer. On the S3, Arduino's SPI DMA reads RAM directly — the
  CPU's cache can hold writes to PSRAM that the DMA never sees, painting
  the panel yellow/garbage. Internal-SRAM chunks are DMA-coherent.
- LittleFS partition: switch the env to default_16MB.csv. The stock
  partition table for esp32-s3-devkitc-1 reserves ~1.5 MB for SPIFFS;
  three 960 KB setup-screen .bin files need ~2.9 MB + LittleFS metadata.
- Setup screens: redesigned to match the 7.3" information density —
  yellow header band, two-column body with vertical divider, "Connect to
  WiFi" heading + 5 numbered steps + manual QR + side label on the left,
  Step 1 / Step 2 QRs on the right.
- Orientation diagrams: PORTRAIT drawn upright (ribbon-bottom, up-arrow);
  LANDSCAPE drawn pre-rotated 90° CCW so it snaps to upright landscape
  when the user rotates the frame 90° CW (ribbon-bottom + left-arrow in
  portrait view → ribbon-left + up-arrow after rotation). "LANDSCAPE"
  label runs vertically up the long edge so it reads horizontally once
  the frame is mounted landscape.
- New helper paste_rotated_text() — PIL's text() can't rotate, so render
  → rotate → paste-with-alpha. Used for the vertical LANDSCAPE label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 19:45:58 -04:00
parent 569bec322f
commit 8eec4bd5fa
9 changed files with 374 additions and 85 deletions
+66 -17
View File
@@ -115,11 +115,13 @@ void epd_setup_pins() {
digitalWrite(PIN_CS_S, HIGH);
digitalWrite(PIN_RST, HIGH);
// FSPI on S3, manual CS. 10 MHz is well under the panel's documented
// 20 MHz ceiling and gives plenty of margin on the long traces between
// the module and the connector.
// 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(10000000, MSBFIRST, SPI_MODE0));
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
}
void epd_init() {
@@ -193,19 +195,44 @@ void epd_sleep() {
// ── Draw helpers ───────────────────────────────────────────────────────────────
// Push one half's framebuffer slice to its CS line. The slice is the
// HALF_BYTES_ROW × H bytes for that half, laid out row-major-contiguous.
static void push_half(int cs_pin, const uint8_t* half_fb) {
// 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);
}
}
// 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);
send_data_n(half_fb, (size_t)HALF_BYTES_ROW * H);
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. Needs a scratch buffer to deinterleave halves
// from the row-major layout — the SPI bus needs contiguous bytes per CS.
// 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) {
// Allocate a half-slice scratch buffer in PSRAM. 300 × 1600 = 480 KB.
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) return;
@@ -215,14 +242,14 @@ static void push_full_frame(const uint8_t* fb) {
fb + (size_t)y * BYTES_PER_ROW,
HALF_BYTES_ROW);
}
push_half(PIN_CS_M, slice);
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);
push_half(PIN_CS_S, slice, HALF_BYTES);
heap_caps_free(slice);
}
@@ -330,19 +357,41 @@ static void draw_from_lfs(const char* path, uint8_t fallback_color,
QRCode* qr, int qr_x, int qr_y, int qr_cell) {
File f = LittleFS.open(path, "r");
if (!f || f.size() != FB_BYTES) {
Serial.printf("[epd13e6] %s: open=%d size=%u -> fill 0x%X\n",
path, (int)(bool)f, f ? (unsigned)f.size() : 0u,
fallback_color);
if (f) f.close();
epd_fill(fallback_color);
return;
}
uint8_t* fb = fb_alloc();
if (!fb) { f.close(); epd_fill(fallback_color); return; }
if (!fb) {
Serial.printf("[epd13e6] fb_alloc FAILED (PSRAM free=%u)\n",
(unsigned)ESP.getFreePsram());
f.close();
epd_fill(fallback_color);
return;
}
if (f.read(fb, FB_BYTES) != FB_BYTES) {
Serial.println("[epd13e6] read short");
fb_release(fb); f.close(); epd_fill(fallback_color); return;
}
f.close();
// Sample first 8 bytes of each quarter of the framebuffer so we can
// verify what we're about to push matches the on-disk .bin. If the
// panel shows wrong colors with these sample bytes looking right,
// corruption is downstream of the CPU.
Serial.printf("[epd13e6] fb sample: head=%02x%02x%02x%02x%02x%02x%02x%02x ",
fb[0], fb[1], fb[2], fb[3], fb[4], fb[5], fb[6], fb[7]);
size_t q = FB_BYTES / 4;
Serial.printf("q1=%02x%02x%02x%02x q2=%02x%02x%02x%02x q3=%02x%02x%02x%02x\n",
fb[q+0], fb[q+1], fb[q+2], fb[q+3],
fb[2*q+0], fb[2*q+1], fb[2*q+2], fb[2*q+3],
fb[3*q+0], fb[3*q+1], fb[3*q+2], fb[3*q+3]);
const int qr_px = qr->size * qr_cell;
const int y0 = max(qr_y, 0);
const int y1 = min(qr_y + qr_px, (int)H);
@@ -370,13 +419,13 @@ static void draw_from_lfs(const char* path, uint8_t fallback_color,
// firmware paints the live QR into it. Mismatch = the QR draws over
// decorative borders or the QR placeholder shows through.
void epd_draw_ap_screen(QRCode* qr) {
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 304, 220, 16);
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 642, 230, 14);
}
void epd_draw_ap_screen_retry(QRCode* qr) {
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 304, 220, 16);
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 642, 230, 14);
}
void epd_draw_setup_screen(QRCode* qr) {
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 450, 14);
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 272, 490, 16);
}