Files
pictureFrame-firmware/src/panels/waveshare13e6/v1/epd_driver.cpp
T
football2801 e2c9d8f1e4 feat(13e6): cut panel power rail in deep sleep via PIN_PWR + GPIO hold
The Waveshare board exposes PIN_PWR (GPIO 1) specifically so battery
designs can gate the panel rail between refreshes. Before this commit
PIN_PWR was driven HIGH at boot and never released, so the panel's
boost converter kept its quiescent draw (50–500 µA) through every
deep sleep. The e-ink particles are bistable so the displayed image
persists without VDD; dropping the rail is a free win.

Three pieces:
  • epd_sleep() drives PIN_PWR LOW after issuing the panel-internal
    DEEP_SLEEP command, then gpio_hold_en() latches the level so it
    survives the chip's RTC transition.
  • normal_operation_impl() calls gpio_deep_sleep_hold_en() just
    before esp_deep_sleep_start() so the per-pin hold extends through
    the deep sleep period itself (without this the holds release on
    the transition and the rail comes back up).
  • epd_setup_pins() calls gpio_hold_dis() at the very top to free
    PIN_PWR on wake before re-driving it HIGH; no-op on cold boot.

Tests: 47/47 pass. Added test/mocks/driver/gpio.h with no-op stubs so
the native test build links cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:05:24 -04:00

458 lines
18 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>
#include <driver/gpio.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() {
// Release the PIN_PWR hold latched from the previous deep sleep so we
// can drive it again. No-op on cold boot (nothing was held). Paired
// with gpio_hold_en() in epd_sleep() and gpio_deep_sleep_hold_en() in
// operation.h.
gpio_hold_dis((gpio_num_t)PIN_PWR);
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;
// Cut the panel power rail. The Waveshare board exposes PIN_PWR
// specifically for battery operation — the e-ink image persists
// without VDD (particles are bistable), and dropping the rail kills
// the boost converter's quiescent draw (~50500 µA depending on the
// load on the rail). Latch LOW so it survives deep sleep; paired with
// gpio_deep_sleep_hold_en() in operation.h just before the chip
// enters sleep.
digitalWrite(PIN_PWR, LOW);
gpio_hold_en((gpio_num_t)PIN_PWR);
}
// ── 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, 590, 14);
}
void epd_draw_ap_screen_retry(QRCode* qr) {
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 642, 590, 14);
}
void epd_draw_setup_screen(QRCode* qr) {
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 750, 14);
}