Initial project: ESP32-S3 e-ink photo frame with web UI

ESP32-S3 firmware (PlatformIO) that fetches JPEGs from a photo server,
decodes on-device with PSRAM, Floyd-Steinberg dithers to the Spectra 6
6-color palette, and displays on a 7.3" GDEP073E01 e-paper panel.
Deep sleeps 1 hour between updates.

Photo server (Python/Flask) with web UI for photo management, Traefik
routing at photos.haunt.house with Google OAuth, and Home Assistant
REST sensor integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:33:16 -05:00
commit 4ddda58b43
11 changed files with 859 additions and 0 deletions

17
firmware/platformio.ini Normal file
View File

@@ -0,0 +1,17 @@
[env:esp32s3]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
upload_speed = 921600
board_build.arduino.memory_type = qio_opi
board_build.psram = enabled
build_flags =
-DBOARD_HAS_PSRAM
-DARDUINO_USB_CDC_ON_BOOT=1
lib_deps =
zinggjm/GxEPD2@^1.6.0
bitbank2/JPEGDEC@^1.4.2

28
firmware/src/config.h Normal file
View File

@@ -0,0 +1,28 @@
#pragma once
// WiFi credentials
#define WIFI_SSID "your-wifi-ssid"
#define WIFI_PASSWORD "your-wifi-password"
// Photo server URL — should return a JPEG resized to 800x480
// Use the LAN address to avoid OAuth (ESP32 hits the /photo and /heartbeat
// endpoints directly). If using Traefik, those paths are auth-exempt.
#define PHOTO_SERVER_URL "http://nas.home.network:8473/photo"
// Or via Traefik (HTTPS, auth-exempt paths):
// #define PHOTO_SERVER_URL "https://photos.haunt.house/photo"
// How long to sleep between photo updates (in seconds)
#define SLEEP_DURATION_SEC (60 * 60) // 1 hour
// Display dimensions
#define DISPLAY_WIDTH 800
#define DISPLAY_HEIGHT 480
// SPI pin assignments for ESP32-S3-DevKitC-1
// Adjust these to match your wiring
#define PIN_SPI_MOSI 11
#define PIN_SPI_SCLK 12
#define PIN_EPD_CS 10
#define PIN_EPD_DC 8
#define PIN_EPD_RST 9
#define PIN_EPD_BUSY 7

96
firmware/src/dither.h Normal file
View File

@@ -0,0 +1,96 @@
#pragma once
#include <cstdint>
// Spectra 6 palette — 6 colors
// These RGB values approximate the actual E Ink pigment colors.
// Tune these if the output looks off on your specific panel.
struct PaletteColor {
int16_t r, g, b;
uint8_t index; // native panel color index
};
// Native panel color indices (what the controller expects)
static constexpr uint8_t COLOR_BLACK = 0;
static constexpr uint8_t COLOR_WHITE = 1;
static constexpr uint8_t COLOR_YELLOW = 2;
static constexpr uint8_t COLOR_RED = 3;
static constexpr uint8_t COLOR_BLUE = 5;
static constexpr uint8_t COLOR_GREEN = 6;
// Approximate RGB values of the actual E Ink pigments
static const PaletteColor PALETTE[] = {
{ 0, 0, 0, COLOR_BLACK },
{ 255, 255, 255, COLOR_WHITE },
{ 200, 30, 30, COLOR_RED },
{ 0, 145, 0, COLOR_GREEN },
{ 0, 0, 160, COLOR_BLUE },
{ 230, 210, 0, COLOR_YELLOW },
};
static constexpr int PALETTE_SIZE = sizeof(PALETTE) / sizeof(PALETTE[0]);
// Find the closest palette color using squared Euclidean distance
inline uint8_t findClosestColor(int16_t r, int16_t g, int16_t b) {
uint8_t bestIndex = 0;
int32_t bestDist = INT32_MAX;
for (int i = 0; i < PALETTE_SIZE; i++) {
int16_t dr = r - PALETTE[i].r;
int16_t dg = g - PALETTE[i].g;
int16_t db = b - PALETTE[i].b;
int32_t dist = (int32_t)dr * dr + (int32_t)dg * dg + (int32_t)db * db;
if (dist < bestDist) {
bestDist = dist;
bestIndex = i;
}
}
return bestIndex;
}
// Floyd-Steinberg dithering on an RGB888 buffer in-place.
// Writes the result into a 4bpp output buffer (2 pixels per byte, native panel indices).
// rgb must be width*height*3 bytes (allocated in PSRAM).
// output must be width*height/2 bytes.
void ditherFloydSteinberg(uint8_t* rgb, uint8_t* output, int width, int height) {
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int idx = (y * width + x) * 3;
// Clamp current pixel
int16_t r = constrain((int16_t)rgb[idx + 0], 0, 255);
int16_t g = constrain((int16_t)rgb[idx + 1], 0, 255);
int16_t b = constrain((int16_t)rgb[idx + 2], 0, 255);
// Find closest palette color
uint8_t ci = findClosestColor(r, g, b);
const PaletteColor& pc = PALETTE[ci];
// Write to output buffer (4bpp, 2 pixels per byte)
int pixelPos = y * width + x;
if (pixelPos % 2 == 0) {
output[pixelPos / 2] = (pc.index << 4);
} else {
output[pixelPos / 2] |= pc.index;
}
// Compute quantization error
int16_t er = r - pc.r;
int16_t eg = g - pc.g;
int16_t eb = b - pc.b;
// Distribute error to neighbors (Floyd-Steinberg coefficients)
// Right: 7/16, Bottom-left: 3/16, Bottom: 5/16, Bottom-right: 1/16
auto diffuse = [&](int nx, int ny, int16_t fraction) {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return;
int ni = (ny * width + nx) * 3;
rgb[ni + 0] = (uint8_t)constrain((int16_t)rgb[ni + 0] + (er * fraction / 16), 0, 255);
rgb[ni + 1] = (uint8_t)constrain((int16_t)rgb[ni + 1] + (eg * fraction / 16), 0, 255);
rgb[ni + 2] = (uint8_t)constrain((int16_t)rgb[ni + 2] + (eb * fraction / 16), 0, 255);
};
diffuse(x + 1, y, 7);
diffuse(x - 1, y + 1, 3);
diffuse(x, y + 1, 5);
diffuse(x + 1, y + 1, 1);
}
}
}

View File

@@ -0,0 +1,2 @@
dependencies:
idf: '>=5.1'

240
firmware/src/main.cpp Normal file
View File

@@ -0,0 +1,240 @@
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <JPEGDEC.h>
#include <GxEPD2_EPD.h>
#include <epd7c/GxEPD2_730c_GDEP073E01.h>
#include <SPI.h>
#include "config.h"
#include "dither.h"
// Display instance
SPIClass displaySPI(SPI2_HOST);
GxEPD2_730c_GDEP073E01 epd(PIN_EPD_CS, PIN_EPD_DC, PIN_EPD_RST, PIN_EPD_BUSY);
// Buffers (allocated in PSRAM)
static uint8_t* jpegBuf = nullptr; // raw JPEG data from server
static uint8_t* rgbBuf = nullptr; // decoded RGB888 (800*480*3 = 1,152,000 bytes)
static uint8_t* displayBuf = nullptr; // dithered 4bpp output (800*480/2 = 192,000 bytes)
static size_t jpegLen = 0;
static String photoName = ""; // name of the photo currently being displayed
// JPEG decoder callback — writes decoded pixels into rgbBuf
static int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int y = 0; y < pDraw->iHeight; y++) {
int destY = pDraw->y + y;
if (destY >= DISPLAY_HEIGHT) continue;
for (int x = 0; x < pDraw->iWidth; x++) {
int destX = pDraw->x + x;
if (destX >= DISPLAY_WIDTH) continue;
// JPEGDEC outputs RGB565 by default
uint16_t pixel = pDraw->pPixels[y * pDraw->iWidth + x];
uint8_t r = ((pixel >> 11) & 0x1F) << 3;
uint8_t g = ((pixel >> 5) & 0x3F) << 2;
uint8_t b = ( pixel & 0x1F) << 3;
int idx = (destY * DISPLAY_WIDTH + destX) * 3;
rgbBuf[idx + 0] = r;
rgbBuf[idx + 1] = g;
rgbBuf[idx + 2] = b;
}
}
return 1; // continue decoding
}
bool connectWiFi() {
Serial.printf("Connecting to WiFi '%s'...\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 60) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nConnected! IP: %s\n", WiFi.localIP().toString().c_str());
return true;
}
Serial.println("\nWiFi connection failed.");
return false;
}
bool fetchPhoto() {
HTTPClient http;
http.begin(PHOTO_SERVER_URL);
http.setTimeout(30000);
const char* headerKeys[] = {"X-Photo-Name"};
http.collectHeaders(headerKeys, 1);
Serial.printf("Fetching photo from %s\n", PHOTO_SERVER_URL);
int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("HTTP error: %d\n", httpCode);
http.end();
return false;
}
// Capture photo name from response header
photoName = http.header("X-Photo-Name");
Serial.printf("Photo: %s\n", photoName.c_str());
jpegLen = http.getSize();
if (jpegLen <= 0 || jpegLen > 2 * 1024 * 1024) {
// Unknown size — read into buffer with a max cap
Serial.println("Reading response (unknown or oversized content-length)...");
WiFiClient* stream = http.getStreamPtr();
jpegLen = 0;
size_t maxSize = 2 * 1024 * 1024;
while (stream->connected() || stream->available()) {
size_t avail = stream->available();
if (avail == 0) { delay(10); continue; }
size_t toRead = min(avail, maxSize - jpegLen);
if (toRead == 0) break;
size_t read = stream->readBytes(jpegBuf + jpegLen, toRead);
jpegLen += read;
}
} else {
Serial.printf("Content-Length: %u bytes\n", jpegLen);
WiFiClient* stream = http.getStreamPtr();
size_t received = 0;
while (received < jpegLen) {
size_t avail = stream->available();
if (avail == 0) { delay(10); continue; }
size_t read = stream->readBytes(jpegBuf + received, min(avail, jpegLen - received));
received += read;
}
}
http.end();
Serial.printf("Received %u bytes of JPEG data\n", jpegLen);
return jpegLen > 0;
}
bool decodeJpeg() {
Serial.println("Decoding JPEG...");
JPEGDEC jpeg;
if (!jpeg.openRAM(jpegBuf, jpegLen, jpegDrawCallback)) {
Serial.println("Failed to open JPEG");
return false;
}
Serial.printf("JPEG: %dx%d\n", jpeg.getWidth(), jpeg.getHeight());
// Use 1/1 scale if the server already resized to 800x480.
// If the image is larger, we could use 1/2 or 1/4 scale, but
// the server should handle resizing.
jpeg.setPixelType(RGB565_BIG_ENDIAN);
if (!jpeg.decode(0, 0, 0)) {
Serial.println("JPEG decode failed");
return false;
}
Serial.println("JPEG decoded successfully");
return true;
}
void displayImage() {
Serial.println("Dithering to 6-color palette...");
memset(displayBuf, 0, DISPLAY_WIDTH * DISPLAY_HEIGHT / 2);
ditherFloydSteinberg(rgbBuf, displayBuf, DISPLAY_WIDTH, DISPLAY_HEIGHT);
Serial.println("Dithering complete");
Serial.println("Writing to e-paper display...");
epd.init(115200, true, 50, false);
// Write raw 4bpp native-encoded pixel data directly to the panel.
// invert=true tells GxEPD2 to skip its color remapping since our
// dither output already uses native panel color indices.
epd.writeNative(displayBuf, nullptr, 0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT,
true, false, false);
epd.refresh(false); // full refresh
Serial.println("Waiting for display refresh (~15s)...");
epd.hibernate();
Serial.println("Display updated!");
}
void sendHeartbeat() {
HTTPClient http;
String url = PHOTO_SERVER_URL;
// Replace /photo with /heartbeat
url = url.substring(0, url.lastIndexOf('/')) + "/heartbeat";
http.begin(url);
http.addHeader("Content-Type", "application/json");
String body = "{\"photo\":\"" + photoName + "\",\"free_heap\":" + String(ESP.getFreeHeap()) + "}";
int code = http.POST(body);
Serial.printf("Heartbeat sent (%d)\n", code);
http.end();
}
void enterDeepSleep() {
Serial.printf("Going to deep sleep for %d seconds...\n", SLEEP_DURATION_SEC);
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
esp_sleep_enable_timer_wakeup((uint64_t)SLEEP_DURATION_SEC * 1000000ULL);
esp_deep_sleep_start();
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=== E-Ink Photo Frame ===");
// Check PSRAM
if (!psramFound()) {
Serial.println("ERROR: PSRAM not found! Cannot continue.");
return;
}
Serial.printf("PSRAM: %u bytes free\n", ESP.getFreePsram());
// Allocate buffers in PSRAM
jpegBuf = (uint8_t*)ps_malloc(2 * 1024 * 1024); // 2MB for JPEG
rgbBuf = (uint8_t*)ps_malloc(DISPLAY_WIDTH * DISPLAY_HEIGHT * 3); // ~1.1MB
displayBuf = (uint8_t*)ps_malloc(DISPLAY_WIDTH * DISPLAY_HEIGHT / 2); // 192KB
if (!jpegBuf || !rgbBuf || !displayBuf) {
Serial.println("ERROR: Failed to allocate PSRAM buffers!");
return;
}
// Initialize SPI for the display
displaySPI.begin(PIN_SPI_SCLK, -1, PIN_SPI_MOSI, PIN_EPD_CS);
epd.selectSPI(displaySPI, SPISettings(20000000, MSBFIRST, SPI_MODE0));
if (!connectWiFi()) {
Serial.println("No WiFi — going back to sleep");
enterDeepSleep();
return;
}
if (!fetchPhoto()) {
Serial.println("Failed to fetch photo — going back to sleep");
enterDeepSleep();
return;
}
if (!decodeJpeg()) {
Serial.println("Failed to decode JPEG — going back to sleep");
enterDeepSleep();
return;
}
displayImage();
sendHeartbeat();
enterDeepSleep();
}
void loop() {
// Never reached — we deep sleep from setup()
}