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:
17
firmware/platformio.ini
Normal file
17
firmware/platformio.ini
Normal 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
28
firmware/src/config.h
Normal 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
96
firmware/src/dither.h
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
firmware/src/idf_component.yml
Normal file
2
firmware/src/idf_component.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
dependencies:
|
||||
idf: '>=5.1'
|
||||
240
firmware/src/main.cpp
Normal file
240
firmware/src/main.cpp
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user