Files
pictureFrame-firmware/src/panels/waveshare13e6/v1/epd_driver.cpp
T
football2801 569bec322f feat(13e6): bring up Waveshare 13.3" Spectra-6 end-to-end
Adds a second panel target alongside the 7.3":
- src/panels/waveshare13e6/v1/ — full epd.h impl with hardware SPI on
  FSPI, dual-CS dispatch (CS_M/CS_S split halves), PSRAM framebuffer
  for image/QR/setup-screen render paths
- src/test_display_13e6.cpp + [env:test-display-13e6] — self-contained
  first-pixels color-bar smoke test, kept as a hardware diagnostic
- [env:waveshare13e6-v1] — production env: ESP32-S3-WROOM-2 N32R16V
  with OPI flash + OPI PSRAM (the WROOM-2 is octal flash; QIO mode
  crashes at do_core_init startup.c:328)
- scripts/gen_screens_13e6.py + data/waveshare13e6-v1/ — 1200x1600
  portrait setup screens with QR overlay regions matching the driver
- scripts/data_dir.py — extra_scripts shim that routes uploadfs to the
  right data/ tree based on $PIOENV (PlatformIO ignores per-env data_dir)
- src/epd.h: epd_setup_pins() abstraction so each panel driver owns its
  own pinMode + SPI.begin; main/test_display/sim_border lose all
  panel-specific GPIO and call epd_setup_pins() once at boot
- src/operation.h: report PANEL_ID via X-Panel-Id header on every poll
  so the server can auto-correct Device.model

7.3" production env stays byte-identical, all 43 native tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:53:51 -04:00

383 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <LittleFS.h>
#include <qrcode.h>
#include <esp_task_wdt.h>
#include <esp_heap_caps.h>
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. 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.
SPI.begin(PIN_SCK, -1, PIN_MOSI, -1);
SPI.beginTransaction(SPISettings(10000000, 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 ───────────────────────────────────────────────────────────────
// 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) {
begin_cmd(cs_pin, 0x10);
send_data_n(half_fb, (size_t)HALF_BYTES_ROW * H);
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.
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;
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);
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);
heap_caps_free(slice);
}
// ── 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) {
if (f) f.close();
epd_fill(fallback_color);
return;
}
uint8_t* fb = fb_alloc();
if (!fb) { f.close(); epd_fill(fallback_color); return; }
if (f.read(fb, FB_BYTES) != FB_BYTES) {
fb_release(fb); f.close(); epd_fill(fallback_color); return;
}
f.close();
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, 304, 220, 16);
}
void epd_draw_ap_screen_retry(QRCode* qr) {
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 304, 220, 16);
}
void epd_draw_setup_screen(QRCode* qr) {
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 450, 14);
}