// Waveshare 13.3" Spectra-6 (E6) panel driver — implements epd.h for the // ESP32-S3-ePaper-13.3E6 board. // // Hardware: 1200 × 1600 pixels, 6 colors, 4 bits-per-pixel packed two // pixels per byte (full framebuffer = 960 KB). Panel is internally split: // the left 600 columns are driven via CS_M (master), the right 600 via // CS_S (slave), both sharing SCK / MOSI / DC / BUSY. Init commands go to // both halves with both CS lines asserted; framebuffer pushes go to one // half at a time. // // .bin layout (server-rendered, panel-native): row-major, 600 bytes/row // × 1600 rows. Within each row, bytes [0..300) are the left half and // [300..600) are the right half. Server writes in this exact order; the // driver streams the file into a PSRAM buffer then pushes each half's // columns to its respective CS line. // // Init sequence + command set verified against: // github.com/teatall/13.3inch_e-Paper_E-Frame (community photo-frame // project on the same board) and Waveshare's stock 13in3e demo. #include "epd.h" #include "config.h" #include #include #include #include static constexpr uint16_t W = 1200; static constexpr uint16_t H = 1600; static constexpr uint16_t BYTES_PER_ROW = W / 2; // 600 static constexpr uint16_t HALF_BYTES_ROW = W / 4; // 300 per half static constexpr size_t FB_BYTES = (size_t)BYTES_PER_ROW * H; // 960000 // Driver-level "is the panel awake?" flag. Useful for asserts; not load-bearing. static bool s_initialized = false; // PSRAM framebuffer for image render paths. Allocated lazily on first use // so a fill-only or QR-only path doesn't pay for it. Caller frees via // release_fb() once the half-pushes are done. static uint8_t* fb_alloc() { return (uint8_t*)heap_caps_malloc(FB_BYTES, MALLOC_CAP_SPIRAM); } static void fb_release(uint8_t* fb) { if (fb) heap_caps_free(fb); } // ── BUSY ─────────────────────────────────────────────────────────────────────── // Active-LOW: panel pulls BUSY low while working, releases high when idle. // Full refresh on Spectra-6 takes ~25 s; bound the wait at 60 s so a // hung panel doesn't strand the boot cycle. static void wait_busy() { uint32_t start = millis(); while (digitalRead(PIN_BUSY) == LOW) { if (millis() - start > 60000) return; delay(5); esp_task_wdt_reset(); } } // ── 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. static inline void cs(int pin, int v) { digitalWrite(pin, v); } 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); 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); digitalWrite(PIN_DC, HIGH); } static void send_data_n(const uint8_t* buf, size_t n) { SPI.writeBytes(buf, n); } static void send_cmd_with_data_both(uint8_t c, const uint8_t* buf, size_t n) { begin_cmd_both(c); if (buf && n) send_data_n(buf, n); cs_both(HIGH); } static void send_cmd_with_data_master(uint8_t c, const uint8_t* buf, size_t n) { begin_cmd(PIN_CS_M, c); if (buf && n) send_data_n(buf, n); cs_both(HIGH); } // ── Panel reset + init sequence ──────────────────────────────────────────────── static void panel_reset() { digitalWrite(PIN_RST, HIGH); delay(30); digitalWrite(PIN_RST, LOW); delay(30); digitalWrite(PIN_RST, HIGH); delay(30); digitalWrite(PIN_RST, LOW); delay(30); digitalWrite(PIN_RST, HIGH); delay(30); } void epd_setup_pins() { pinMode(PIN_PWR, OUTPUT); pinMode(PIN_RST, OUTPUT); pinMode(PIN_DC, OUTPUT); pinMode(PIN_BUSY, INPUT); pinMode(PIN_CS_M, OUTPUT); pinMode(PIN_CS_S, OUTPUT); digitalWrite(PIN_PWR, HIGH); digitalWrite(PIN_CS_M, HIGH); digitalWrite(PIN_CS_S, HIGH); digitalWrite(PIN_RST, HIGH); // 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)); } void epd_init() { panel_reset(); static const uint8_t AN_TM_V[] = {0xC0,0x1C,0x1C,0xCC,0xCC,0xCC,0x15,0x15,0x55}; static const uint8_t CMD66_V[] = {0x49,0x55,0x13,0x5D,0x05,0x10}; static const uint8_t PSR_V[] = {0xDF,0x69}; static const uint8_t CDI_V[] = {0xF7}; static const uint8_t TCON_V[] = {0x03,0x03}; static const uint8_t AGID_V[] = {0x10}; static const uint8_t PWS_V[] = {0x22}; static const uint8_t CCSET_V[] = {0x01}; static const uint8_t TRES_V[] = {0x04,0xB0,0x03,0x20}; static const uint8_t PWR_V[] = {0x0F,0x00,0x28,0x2C,0x28,0x38}; static const uint8_t EN_BUF_V[] = {0x07}; static const uint8_t BTST_P_V[] = {0xE8,0x28}; static const uint8_t BOOST_VDDP_EN_V[] = {0x01}; static const uint8_t BTST_N_V[] = {0xE8,0x28}; static const uint8_t BUCK_VDDN_V[] = {0x01}; static const uint8_t TFT_VCOM_V[] = {0x02}; // AN_TM goes to master only first (per reference; not obvious why but // matching exactly de-risks bringup), then a batch of commands to // both halves, then a tail of master-only tuning commands. send_cmd_with_data_master(0x74, AN_TM_V, sizeof(AN_TM_V)); send_cmd_with_data_both (0xF0, CMD66_V, sizeof(CMD66_V)); send_cmd_with_data_both (0x00, PSR_V, sizeof(PSR_V)); send_cmd_with_data_both (0x50, CDI_V, sizeof(CDI_V)); send_cmd_with_data_both (0x60, TCON_V, sizeof(TCON_V)); send_cmd_with_data_both (0x86, AGID_V, sizeof(AGID_V)); send_cmd_with_data_both (0xE3, PWS_V, sizeof(PWS_V)); send_cmd_with_data_both (0xE0, CCSET_V, sizeof(CCSET_V)); send_cmd_with_data_both (0x61, TRES_V, sizeof(TRES_V)); send_cmd_with_data_master(0x01, PWR_V, sizeof(PWR_V)); send_cmd_with_data_master(0xB6, EN_BUF_V, sizeof(EN_BUF_V)); send_cmd_with_data_master(0x06, BTST_P_V, sizeof(BTST_P_V)); send_cmd_with_data_master(0xB7, BOOST_VDDP_EN_V, sizeof(BOOST_VDDP_EN_V)); send_cmd_with_data_master(0x05, BTST_N_V, sizeof(BTST_N_V)); send_cmd_with_data_master(0xB0, BUCK_VDDN_V, sizeof(BUCK_VDDN_V)); send_cmd_with_data_master(0xB1, TFT_VCOM_V, sizeof(TFT_VCOM_V)); s_initialized = true; } // ── Refresh / sleep ──────────────────────────────────────────────────────────── static void epd_refresh() { begin_cmd_both(0x04); // POWER_ON cs_both(HIGH); wait_busy(); delay(50); begin_cmd_both(0x12); SPI.transfer(0x00); // DRF (full refresh) cs_both(HIGH); wait_busy(); begin_cmd_both(0x02); SPI.transfer(0x00); // POWER_OFF cs_both(HIGH); } void epd_sleep() { cs_both(LOW); digitalWrite(PIN_DC, LOW); SPI.transfer(0x07); // DEEP_SLEEP digitalWrite(PIN_DC, HIGH); SPI.transfer(0xA5); // sentinel cs_both(HIGH); s_initialized = false; } // ── 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. 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"); } // ── epd.h surface ────────────────────────────────────────────────────────────── void epd_fill(uint8_t color) { const uint8_t byte = (color << 4) | color; // Solid fill needs no framebuffer — stream the byte directly per half. for (int half = 0; half < 2; half++) { 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); } cs(p, HIGH); } epd_refresh(); } void epd_draw_image_from_file(fs::File& f) { uint8_t* fb = fb_alloc(); if (!fb) { epd_fill(COLOR_WHITE); return; } size_t n = f.read(fb, FB_BYTES); if (n != FB_BYTES) { fb_release(fb); epd_fill(COLOR_WHITE); return; } push_full_frame(fb); fb_release(fb); epd_refresh(); } void epd_draw_image_with_border(fs::File& f, uint8_t color, int thickness) { if (f.size() != FB_BYTES) { epd_fill(color); return; } uint8_t* fb = fb_alloc(); if (!fb) { epd_fill(color); return; } if (f.read(fb, FB_BYTES) != FB_BYTES) { fb_release(fb); epd_fill(color); return; } const uint8_t pair = (color << 4) | color; // Overlay border in-place. Same x/y orientation as the 7.3" driver: // top/bottom solid stripes, plus left/right edges on the middle band. for (int y = 0; y < H; y++) { uint8_t* row = fb + (size_t)y * BYTES_PER_ROW; if (y < thickness || y >= H - thickness) { memset(row, pair, BYTES_PER_ROW); } else { for (int x = 0; x < thickness; x++) { if (x & 1) row[x/2] = (row[x/2] & 0xF0) | color; else row[x/2] = (row[x/2] & 0x0F) | (color << 4); } for (int x = W - thickness; x < W; x++) { if (x & 1) row[x/2] = (row[x/2] & 0xF0) | color; else row[x/2] = (row[x/2] & 0x0F) | (color << 4); } } } push_full_frame(fb); fb_release(fb); epd_refresh(); } void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg) { uint8_t* fb = fb_alloc(); if (!fb) { epd_fill(bg); return; } const uint8_t bg_pair = (bg << 4) | bg; memset(fb, bg_pair, FB_BYTES); const int qrPx = qr->size * cellPx; const int offX = (W - qrPx) / 2; const int offY = (H - qrPx) / 2; for (int y = offY; y < offY + qrPx; y++) { if (y < 0 || y >= H) continue; uint8_t* row = fb + (size_t)y * BYTES_PER_ROW; const int qy = (y - offY) / cellPx; for (int x = offX; x < offX + qrPx; x++) { if (x < 0 || x >= W) continue; const int qx = (x - offX) / cellPx; const uint8_t c = qrcode_getModule(qr, qx, qy) ? fg : bg; if (x & 1) row[x/2] = (row[x/2] & 0xF0) | c; else row[x/2] = (row[x/2] & 0x0F) | (c << 4); } } push_full_frame(fb); fb_release(fb); epd_refresh(); } // Stream background from LittleFS into the PSRAM framebuffer, overlay the QR // at (qr_x, qr_y) with the given cell size, then push the full frame in two // halves. Falls back to a solid fill if the file is missing or wrong size. 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) { 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); const int x0 = max(qr_x, 0); const int x1 = min(qr_x + qr_px, (int)W); for (int y = y0; y < y1; y++) { uint8_t* row = fb + (size_t)y * BYTES_PER_ROW; const int qy = (y - qr_y) / qr_cell; for (int x = x0; x < x1; x++) { const int qx = (x - qr_x) / qr_cell; const uint8_t c = qrcode_getModule(qr, qx, qy) ? COLOR_BLACK : COLOR_WHITE; if (x & 1) row[x/2] = (row[x/2] & 0xF0) | c; else row[x/2] = (row[x/2] & 0x0F) | (c << 4); } } push_full_frame(fb); fb_release(fb); epd_refresh(); } // QR overlay coordinates for the 13.3" portrait setup screens. Must stay // in sync with scripts/gen_screens_13e6.py — the bg .bin leaves a white // rectangle exactly the size of QR_MODS × QR_CELL at (X, Y), and the // 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, 642, 230, 14); } void epd_draw_ap_screen_retry(QRCode* qr) { 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, 272, 490, 16); }