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:
12
server/Dockerfile
Normal file
12
server/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py .
|
||||
|
||||
EXPOSE 8473
|
||||
|
||||
CMD ["python", "server.py", "--port", "8473", "--photos-dir", "/photos"]
|
||||
40
server/docker-compose.yml
Normal file
40
server/docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
photo-server:
|
||||
build: .
|
||||
container_name: eink-photo-server
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- webgateway
|
||||
volumes:
|
||||
- photos:/photos
|
||||
- state:/data
|
||||
environment:
|
||||
- TZ=America/Chicago
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTPS
|
||||
- "traefik.http.routers.photos-websecure.rule=Host(`photos.haunt.house`)"
|
||||
- "traefik.http.routers.photos-websecure.entryPoints=websecure"
|
||||
- "traefik.http.routers.photos-websecure.tls.certresolver=myresolver"
|
||||
- "traefik.http.routers.photos-websecure.middlewares=hsts,google-auth@docker"
|
||||
# HTTP redirect
|
||||
- "traefik.http.routers.photos-http.rule=Host(`photos.haunt.house`)"
|
||||
- "traefik.http.routers.photos-http.entryPoints=web"
|
||||
- "traefik.http.routers.photos-http.middlewares=redirect-https"
|
||||
# Internal port
|
||||
- "traefik.http.services.photos.loadbalancer.server.port=8473"
|
||||
# ESP32 endpoint — no auth (frame can't do OAuth)
|
||||
- "traefik.http.routers.photos-frame.rule=Host(`photos.haunt.house`) && (Path(`/photo`) || Path(`/heartbeat`))"
|
||||
- "traefik.http.routers.photos-frame.entryPoints=websecure"
|
||||
- "traefik.http.routers.photos-frame.tls.certresolver=myresolver"
|
||||
- "traefik.http.routers.photos-frame.middlewares=hsts"
|
||||
- "traefik.http.routers.photos-frame.priority=100"
|
||||
- "traefik.http.routers.photos-frame.service=photos"
|
||||
|
||||
networks:
|
||||
webgateway:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
photos:
|
||||
state:
|
||||
2
server/requirements.txt
Normal file
2
server/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask==3.1.*
|
||||
pillow==11.*
|
||||
369
server/server.py
Normal file
369
server/server.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
E-Ink Photo Frame Server
|
||||
|
||||
Serves photos for the ESP32 photo frame and provides a web UI for management.
|
||||
Tracks frame status via heartbeat reports from the ESP32.
|
||||
|
||||
Endpoints:
|
||||
GET / Web UI — gallery, upload, frame status
|
||||
GET /photo Random JPEG resized to 800x480 (called by ESP32)
|
||||
POST /heartbeat ESP32 reports what it displayed
|
||||
GET /api/status Frame status (for Home Assistant REST sensor)
|
||||
GET /api/photos List all photos as JSON
|
||||
POST /api/upload Upload new photos
|
||||
DELETE /api/photos/<name> Delete a photo
|
||||
GET /health Health check
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Response, jsonify, request, render_template_string
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from PIL import Image
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
PHOTOS_DIR: Path = Path(".")
|
||||
TARGET_WIDTH = 800
|
||||
TARGET_HEIGHT = 480
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"}
|
||||
MAX_UPLOAD_SIZE = 20 * 1024 * 1024 # 20MB
|
||||
|
||||
# Frame state — persisted to disk so it survives restarts
|
||||
STATE_FILE = Path("/data/frame_state.json")
|
||||
|
||||
def load_state() -> dict:
|
||||
try:
|
||||
if STATE_FILE.exists():
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {"last_update": None, "current_photo": None, "ip": None, "updates": 0}
|
||||
|
||||
def save_state(state: dict):
|
||||
try:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state))
|
||||
except Exception as e:
|
||||
print(f"Warning: could not save state: {e}", file=sys.stderr)
|
||||
|
||||
frame_state = load_state()
|
||||
|
||||
|
||||
def get_photo_list() -> list[Path]:
|
||||
photos = []
|
||||
for f in PHOTOS_DIR.rglob("*"):
|
||||
if f.suffix.lower() in SUPPORTED_EXTENSIONS and f.is_file():
|
||||
photos.append(f)
|
||||
return sorted(photos, key=lambda p: p.name)
|
||||
|
||||
|
||||
def resize_and_crop(img: Image.Image) -> Image.Image:
|
||||
target_ratio = TARGET_WIDTH / TARGET_HEIGHT
|
||||
img_ratio = img.width / img.height
|
||||
|
||||
if img_ratio > target_ratio:
|
||||
new_height = TARGET_HEIGHT
|
||||
new_width = int(img.width * (TARGET_HEIGHT / img.height))
|
||||
else:
|
||||
new_width = TARGET_WIDTH
|
||||
new_height = int(img.height * (TARGET_WIDTH / img.width))
|
||||
|
||||
img = img.resize((new_width, new_height), Image.LANCZOS)
|
||||
|
||||
left = (new_width - TARGET_WIDTH) // 2
|
||||
top = (new_height - TARGET_HEIGHT) // 2
|
||||
img = img.crop((left, top, left + TARGET_WIDTH, top + TARGET_HEIGHT))
|
||||
return img
|
||||
|
||||
|
||||
def make_thumbnail(img: Image.Image, size: int = 300) -> bytes:
|
||||
img.thumbnail((size, size), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=75)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# --- ESP32 endpoints ---
|
||||
|
||||
@app.route("/photo")
|
||||
def random_photo():
|
||||
photos = get_photo_list()
|
||||
if not photos:
|
||||
return jsonify({"error": "No photos found"}), 404
|
||||
|
||||
chosen = random.choice(photos)
|
||||
try:
|
||||
img = Image.open(chosen)
|
||||
img = img.convert("RGB")
|
||||
img = resize_and_crop(img)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
buf.seek(0)
|
||||
|
||||
return Response(buf.getvalue(), mimetype="image/jpeg",
|
||||
headers={"X-Photo-Name": chosen.name})
|
||||
except Exception as e:
|
||||
print(f"Error processing {chosen}: {e}", file=sys.stderr)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/heartbeat", methods=["POST"])
|
||||
def heartbeat():
|
||||
"""ESP32 reports after displaying a photo."""
|
||||
global frame_state
|
||||
data = request.get_json(silent=True) or {}
|
||||
frame_state["last_update"] = datetime.now(timezone.utc).isoformat()
|
||||
frame_state["current_photo"] = data.get("photo", None)
|
||||
frame_state["ip"] = request.remote_addr
|
||||
frame_state["free_heap"] = data.get("free_heap", None)
|
||||
frame_state["updates"] = frame_state.get("updates", 0) + 1
|
||||
save_state(frame_state)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# --- API endpoints ---
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
"""Frame status for Home Assistant REST sensor."""
|
||||
photos = get_photo_list()
|
||||
return jsonify({
|
||||
"state": "online" if frame_state.get("last_update") else "waiting",
|
||||
"last_update": frame_state.get("last_update"),
|
||||
"current_photo": frame_state.get("current_photo"),
|
||||
"frame_ip": frame_state.get("ip"),
|
||||
"total_photos": len(photos),
|
||||
"total_updates": frame_state.get("updates", 0),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/photos")
|
||||
def api_photos():
|
||||
photos = get_photo_list()
|
||||
return jsonify([{"name": p.name, "size": p.stat().st_size} for p in photos])
|
||||
|
||||
|
||||
@app.route("/api/upload", methods=["POST"])
|
||||
def api_upload():
|
||||
uploaded = []
|
||||
for f in request.files.getlist("photos"):
|
||||
if not f.filename:
|
||||
continue
|
||||
fname = secure_filename(f.filename)
|
||||
ext = Path(fname).suffix.lower()
|
||||
if ext not in SUPPORTED_EXTENSIONS:
|
||||
continue
|
||||
dest = PHOTOS_DIR / fname
|
||||
f.save(str(dest))
|
||||
uploaded.append(fname)
|
||||
return jsonify({"uploaded": uploaded, "count": len(uploaded)})
|
||||
|
||||
|
||||
@app.route("/api/photos/<name>", methods=["DELETE"])
|
||||
def api_delete(name: str):
|
||||
fname = secure_filename(name)
|
||||
path = PHOTOS_DIR / fname
|
||||
if not path.exists():
|
||||
return jsonify({"error": "not found"}), 404
|
||||
path.unlink()
|
||||
return jsonify({"deleted": fname})
|
||||
|
||||
|
||||
@app.route("/thumb/<name>")
|
||||
def thumbnail(name: str):
|
||||
fname = secure_filename(name)
|
||||
path = PHOTOS_DIR / fname
|
||||
if not path.exists():
|
||||
return "", 404
|
||||
try:
|
||||
img = Image.open(path).convert("RGB")
|
||||
data = make_thumbnail(img)
|
||||
return Response(data, mimetype="image/jpeg")
|
||||
except Exception:
|
||||
return "", 500
|
||||
|
||||
|
||||
# --- Web UI ---
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
photos = get_photo_list()
|
||||
return render_template_string(WEB_UI, photos=photos, state=frame_state,
|
||||
photo_count=len(photos))
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
photos = get_photo_list()
|
||||
return jsonify({"status": "ok", "photo_count": len(photos)})
|
||||
|
||||
|
||||
WEB_UI = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Photo Frame</title>
|
||||
<style>
|
||||
:root { --bg: #111; --card: #1a1a1a; --text: #eee; --accent: #6c8; --border: #333; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; }
|
||||
h1 span { color: var(--accent); }
|
||||
|
||||
.status-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 1rem 1.25rem; margin-bottom: 1.5rem; display: flex; gap: 2rem; flex-wrap: wrap;
|
||||
}
|
||||
.status-card .item { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.status-card .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
|
||||
.status-card .value { font-size: 0.95rem; }
|
||||
.status-card .online { color: var(--accent); }
|
||||
.status-card .waiting { color: #f80; }
|
||||
|
||||
.upload-area {
|
||||
background: var(--card); border: 2px dashed var(--border); border-radius: 8px;
|
||||
padding: 2rem; text-align: center; margin-bottom: 1.5rem; cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.upload-area:hover, .upload-area.dragover { border-color: var(--accent); }
|
||||
.upload-area input { display: none; }
|
||||
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; }
|
||||
.photo-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
.photo-card img { width: 100%; aspect-ratio: 5/3; object-fit: cover; display: block; }
|
||||
.photo-card .info {
|
||||
padding: 0.5rem 0.6rem; display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.photo-card .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; }
|
||||
.photo-card .delete {
|
||||
background: none; border: none; color: #f55; cursor: pointer; font-size: 0.85rem;
|
||||
padding: 0.2rem 0.4rem; border-radius: 4px;
|
||||
}
|
||||
.photo-card .delete:hover { background: rgba(255,85,85,0.15); }
|
||||
.photo-card.current { border-color: var(--accent); }
|
||||
|
||||
.empty { text-align: center; padding: 3rem; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><span>■</span> Photo Frame</h1>
|
||||
|
||||
<div class="status-card">
|
||||
<div class="item">
|
||||
<span class="label">Frame Status</span>
|
||||
<span class="value {{ 'online' if state.get('last_update') else 'waiting' }}">
|
||||
{{ 'Online' if state.get('last_update') else 'Waiting for first update' }}
|
||||
</span>
|
||||
</div>
|
||||
{% if state.get('last_update') %}
|
||||
<div class="item">
|
||||
<span class="label">Last Update</span>
|
||||
<span class="value" data-utc="{{ state.last_update }}">{{ state.last_update[:19] }}</span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="label">Showing</span>
|
||||
<span class="value">{{ state.get('current_photo', 'Unknown') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="item">
|
||||
<span class="label">Photos</span>
|
||||
<span class="value">{{ photo_count }}</span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="label">Total Refreshes</span>
|
||||
<span class="value">{{ state.get('updates', 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-area" id="upload-area" onclick="document.getElementById('file-input').click()">
|
||||
<input type="file" id="file-input" multiple accept="image/*">
|
||||
<p>Drop photos here or click to upload</p>
|
||||
</div>
|
||||
|
||||
{% if photos %}
|
||||
<div class="gallery">
|
||||
{% for p in photos %}
|
||||
<div class="photo-card {{ 'current' if state.get('current_photo') == p.name else '' }}" data-name="{{ p.name }}">
|
||||
<img src="/thumb/{{ p.name }}" loading="lazy" alt="{{ p.name }}">
|
||||
<div class="info">
|
||||
<span class="name" title="{{ p.name }}">{{ p.name }}</span>
|
||||
<button class="delete" onclick="deletePhoto('{{ p.name }}')" title="Delete">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">No photos yet. Upload some!</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const area = document.getElementById('upload-area');
|
||||
const input = document.getElementById('file-input');
|
||||
|
||||
['dragenter','dragover'].forEach(e => area.addEventListener(e, ev => { ev.preventDefault(); area.classList.add('dragover'); }));
|
||||
['dragleave','drop'].forEach(e => area.addEventListener(e, ev => { ev.preventDefault(); area.classList.remove('dragover'); }));
|
||||
|
||||
area.addEventListener('drop', ev => { uploadFiles(ev.dataTransfer.files); });
|
||||
input.addEventListener('change', () => { uploadFiles(input.files); });
|
||||
|
||||
function uploadFiles(files) {
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append('photos', f);
|
||||
fetch('/api/upload', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.count > 0) location.reload(); });
|
||||
}
|
||||
|
||||
function deletePhoto(name) {
|
||||
if (!confirm('Delete ' + name + '?')) return;
|
||||
fetch('/api/photos/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
document.querySelector('[data-name="' + name + '"]')?.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Convert UTC timestamps to local time
|
||||
document.querySelectorAll('[data-utc]').forEach(el => {
|
||||
const d = new Date(el.dataset.utc);
|
||||
el.textContent = d.toLocaleString();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="E-Ink Photo Frame Server")
|
||||
parser.add_argument("--port", type=int, default=8473)
|
||||
parser.add_argument("--photos-dir", type=str, default="./photos")
|
||||
args = parser.parse_args()
|
||||
|
||||
PHOTOS_DIR = Path(args.photos_dir)
|
||||
if not PHOTOS_DIR.exists():
|
||||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Serving photos from: {PHOTOS_DIR.resolve()}")
|
||||
print(f"Found {len(get_photo_list())} photos")
|
||||
print(f"Listening on port {args.port}")
|
||||
|
||||
app.run(host="0.0.0.0", port=args.port)
|
||||
Reference in New Issue
Block a user