569bec322f
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>
383 lines
14 KiB
C++
383 lines
14 KiB
C++
// 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);
|
||
}
|