Files
eink-photo-frame/server/server.py
Thomas Hallock dd4f8e950d Add display simulator, orientation support, and per-image crop settings
- E-paper display simulator: Python port of the C++ Floyd-Steinberg
  dithering (same palette, same coefficients) with side-by-side preview
  in the web UI. Interactive pan/zoom controls with live re-rendering.

- Frame orientation: landscape / portrait_cw / portrait_ccw setting
  controls logical display dimensions (800x480 vs 480x800). Images are
  rotated to match the physical buffer after processing.

- Display modes: zoom (cover+crop) and letterbox (fit with padding),
  configurable globally and per-image. Zoom mode supports pan_x/pan_y
  (0.0-1.0) to control crop position.

- Settings persistence: frame settings, per-image settings, and frame
  state stored as JSON, surviving restarts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:32:38 -05:00

786 lines
28 KiB
Python
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.
"""
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, settings
GET /photo Random JPEG processed for the display (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 /api/settings Get frame settings
PUT /api/settings Update frame settings
GET /api/photos/<name>/settings Get per-image settings
PUT /api/photos/<name>/settings Update per-image settings
GET /preview/<name> Dithered e-paper preview image
GET /simulate/<name> Full simulator page with controls
GET /health Health check
"""
import argparse
import io
import json
import random
import sys
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
import numpy as np
from dither import dither_floyd_steinberg
app = Flask(__name__)
PHOTOS_DIR: Path = Path(".")
PHYSICAL_WIDTH = 800
PHYSICAL_HEIGHT = 480
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"}
DATA_DIR = Path("/data")
STATE_FILE = DATA_DIR / "frame_state.json"
SETTINGS_FILE = DATA_DIR / "settings.json"
IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json"
# --- Persistence helpers ---
def _load_json(path: Path, default):
try:
if path.exists():
return json.loads(path.read_text())
except Exception:
pass
return default() if callable(default) else default
def _save_json(path: Path, data):
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2))
except Exception as e:
print(f"Warning: could not save {path}: {e}", file=sys.stderr)
# Frame state (runtime, updated by heartbeats)
frame_state = _load_json(STATE_FILE, lambda: {
"last_update": None, "current_photo": None, "ip": None, "updates": 0
})
# Frame settings (user-configured)
DEFAULT_SETTINGS = {
"orientation": "landscape", # landscape | portrait_cw | portrait_ccw
"default_mode": "zoom", # zoom | letterbox
"letterbox_color": [255, 255, 255], # white — looks like e-paper background
}
frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS))
# Per-image settings: { "filename.jpg": { "mode": "zoom", "pan_x": 0.5, "pan_y": 0.3 }, ... }
image_settings: dict = _load_json(IMAGE_SETTINGS_FILE, dict)
def get_image_settings(name: str) -> dict:
"""Get effective settings for an image (per-image overrides merged with defaults)."""
defaults = {"mode": None, "pan_x": 0.5, "pan_y": 0.5}
per_image = image_settings.get(name, {})
merged = {**defaults, **per_image}
if merged["mode"] is None:
merged["mode"] = frame_settings.get("default_mode", "zoom")
return merged
# --- Image processing ---
def get_logical_dimensions() -> tuple[int, int]:
"""Get the logical display dimensions based on frame orientation."""
orientation = frame_settings.get("orientation", "landscape")
if orientation == "landscape":
return PHYSICAL_WIDTH, PHYSICAL_HEIGHT # 800x480
else:
return PHYSICAL_HEIGHT, PHYSICAL_WIDTH # 480x800
def process_image(img: Image.Image, settings: dict) -> Image.Image:
"""
Resize and transform an image for the display.
Takes frame orientation into account:
- Determines logical dimensions (800x480 or 480x800)
- Applies zoom+pan or letterbox based on settings
- Rotates to match physical 800x480 buffer if portrait
Returns an 800x480 image ready for dithering / JPEG encoding.
"""
logical_w, logical_h = get_logical_dimensions()
mode = settings.get("mode", "zoom")
pan_x = settings.get("pan_x", 0.5)
pan_y = settings.get("pan_y", 0.5)
if mode == "zoom":
img = _zoom_and_pan(img, logical_w, logical_h, pan_x, pan_y)
else:
img = _letterbox(img, logical_w, logical_h)
# Rotate to match physical buffer if portrait
orientation = frame_settings.get("orientation", "landscape")
if orientation == "portrait_cw":
img = img.rotate(-90, expand=True)
elif orientation == "portrait_ccw":
img = img.rotate(90, expand=True)
return img
def _zoom_and_pan(img: Image.Image, tw: int, th: int,
pan_x: float, pan_y: float) -> Image.Image:
"""Scale to cover target, then crop at the specified pan position."""
target_ratio = tw / th
img_ratio = img.width / img.height
if img_ratio > target_ratio:
# Image is wider — fit height, crop width
new_height = th
new_width = int(img.width * (th / img.height))
else:
# Image is taller — fit width, crop height
new_width = tw
new_height = int(img.height * (tw / img.width))
img = img.resize((new_width, new_height), Image.LANCZOS)
# Pan-aware crop: pan_x/pan_y are 0.01.0, controlling crop position
max_left = new_width - tw
max_top = new_height - th
left = int(max_left * pan_x)
top = int(max_top * pan_y)
return img.crop((left, top, left + tw, top + th))
def _letterbox(img: Image.Image, tw: int, th: int) -> Image.Image:
"""Scale to fit inside target, pad with letterbox color."""
lb_color = tuple(frame_settings.get("letterbox_color", [255, 255, 255]))
target_ratio = tw / th
img_ratio = img.width / img.height
if img_ratio > target_ratio:
# Image is wider — fit width
new_width = tw
new_height = int(img.height * (tw / img.width))
else:
# Image is taller — fit height
new_height = th
new_width = int(img.width * (th / img.height))
img = img.resize((new_width, new_height), Image.LANCZOS)
canvas = Image.new("RGB", (tw, th), lb_color)
paste_x = (tw - new_width) // 2
paste_y = (th - new_height) // 2
canvas.paste(img, (paste_x, paste_y))
return canvas
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 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).convert("RGB")
settings = get_image_settings(chosen.name)
img = process_image(img, settings)
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():
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_json(STATE_FILE, frame_state)
return jsonify({"ok": True})
# --- Settings API ---
@app.route("/api/settings", methods=["GET"])
def api_get_settings():
return jsonify(frame_settings)
@app.route("/api/settings", methods=["PUT"])
def api_put_settings():
global frame_settings
data = request.get_json()
allowed_keys = {"orientation", "default_mode", "letterbox_color"}
for k, v in data.items():
if k in allowed_keys:
frame_settings[k] = v
_save_json(SETTINGS_FILE, frame_settings)
return jsonify(frame_settings)
@app.route("/api/photos/<name>/settings", methods=["GET"])
def api_get_image_settings(name: str):
fname = secure_filename(name)
return jsonify(get_image_settings(fname))
@app.route("/api/photos/<name>/settings", methods=["PUT"])
def api_put_image_settings(name: str):
fname = secure_filename(name)
data = request.get_json()
allowed_keys = {"mode", "pan_x", "pan_y"}
current = image_settings.get(fname, {})
for k, v in data.items():
if k in allowed_keys:
current[k] = v
image_settings[fname] = current
_save_json(IMAGE_SETTINGS_FILE, image_settings)
return jsonify(get_image_settings(fname))
@app.route("/api/photos/<name>/settings", methods=["DELETE"])
def api_delete_image_settings(name: str):
fname = secure_filename(name)
image_settings.pop(fname, None)
_save_json(IMAGE_SETTINGS_FILE, image_settings)
return jsonify(get_image_settings(fname))
# --- Other API endpoints ---
@app.route("/api/status")
def api_status():
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),
"orientation": frame_settings.get("orientation", "landscape"),
})
@app.route("/api/photos")
def api_photos():
photos = get_photo_list()
result = []
for p in photos:
entry = {"name": p.name, "size": p.stat().st_size}
if p.name in image_settings:
entry["settings"] = image_settings[p.name]
result.append(entry)
return jsonify(result)
@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()
image_settings.pop(fname, None)
_save_json(IMAGE_SETTINGS_FILE, image_settings)
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:
size = request.args.get("size", 300, type=int)
size = min(size, 1600)
img = Image.open(path).convert("RGB")
data = make_thumbnail(img, size)
return Response(data, mimetype="image/jpeg")
except Exception:
return "", 500
# --- Simulator / Preview ---
@app.route("/preview/<name>")
def preview_photo(name: str):
fname = secure_filename(name)
path = PHOTOS_DIR / fname
if not path.exists():
return jsonify({"error": "not found"}), 404
# Allow query param overrides for interactive simulator
settings = get_image_settings(fname)
if "mode" in request.args:
settings["mode"] = request.args["mode"]
if "pan_x" in request.args:
settings["pan_x"] = float(request.args["pan_x"])
if "pan_y" in request.args:
settings["pan_y"] = float(request.args["pan_y"])
try:
img = Image.open(path).convert("RGB")
img = process_image(img, settings)
_, preview = dither_floyd_steinberg(img)
buf = io.BytesIO()
preview.save(buf, format="PNG")
buf.seek(0)
return Response(buf.getvalue(), mimetype="image/png")
except Exception as e:
print(f"Preview error: {e}", file=sys.stderr)
return jsonify({"error": str(e)}), 500
@app.route("/preview")
def preview_random():
photos = get_photo_list()
if not photos:
return jsonify({"error": "No photos found"}), 404
chosen = random.choice(photos)
return preview_photo(chosen.name)
@app.route("/simulate/<name>")
def simulate_page(name: str):
fname = secure_filename(name)
path = PHOTOS_DIR / fname
if not path.exists():
return "", 404
settings = get_image_settings(fname)
orientation = frame_settings.get("orientation", "landscape")
return render_template_string(SIMULATE_UI, photo_name=fname,
settings=settings, orientation=orientation)
SIMULATE_UI = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>E-Paper Simulator — {{ photo_name }}</title>
<style>
:root { --bg: #1a1a1a; --card: #222; --border: #444; --accent: #6c8; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: #eee; font-family: -apple-system, system-ui, sans-serif;
display: flex; flex-direction: column; align-items: center; min-height: 100vh; padding: 2rem; }
h2 { font-size: 1.1rem; margin-bottom: 1rem; font-weight: 400; color: #999; }
.frame {
background: #e8e4d9; border-radius: 4px; padding: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.1);
}
.frame img { display: block; image-rendering: auto; }
.frame.landscape img { width: 800px; max-width: 90vw; height: auto; }
.frame.portrait { }
.frame.portrait img { height: 800px; max-height: 70vh; width: auto; }
.controls {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 1.25rem; margin-top: 1.5rem; width: 100%; max-width: 700px;
}
.controls h3 { font-size: 0.85rem; color: #888; text-transform: uppercase;
letter-spacing: 0.05em; margin-bottom: 0.75rem; }
.control-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.6rem; flex-wrap: wrap; }
.control-row label { font-size: 0.85rem; min-width: 80px; color: #aaa; }
.control-row select, .control-row input[type=range] { flex: 1; max-width: 300px; }
select { background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px;
padding: 0.3rem 0.5rem; font-size: 0.85rem; }
input[type=range] { accent-color: var(--accent); }
.range-value { font-size: 0.8rem; color: #888; min-width: 2.5rem; }
.btn-row { display: flex; gap: 0.75rem; margin-top: 1rem; }
.btn { padding: 0.4rem 1rem; border-radius: 4px; border: 1px solid var(--border);
background: #333; color: #eee; cursor: pointer; font-size: 0.85rem; }
.btn:hover { background: #444; }
.btn.primary { background: var(--accent); color: #111; border-color: var(--accent); }
.btn.primary:hover { opacity: 0.9; }
.btn.reset { color: #f80; border-color: #f80; }
.pan-controls { display: {{ 'flex' if settings.mode == 'zoom' else 'none' }}; flex-direction: column; gap: 0.6rem; }
.back { margin-top: 1.5rem; color: var(--accent); text-decoration: none; font-size: 0.9rem; }
.back:hover { text-decoration: underline; }
.loading { color: #888; font-size: 0.8rem; margin-left: 0.5rem; display: none; }
</style>
</head>
<body>
<h2>E-Paper Display Simulator</h2>
<div class="frame {{ 'portrait' if orientation.startswith('portrait') else 'landscape' }}">
<img id="preview" src="/preview/{{ photo_name }}" alt="E-paper preview">
</div>
<div class="controls">
<h3>Display Settings</h3>
<div class="control-row">
<label>Mode</label>
<select id="mode" onchange="onModeChange()">
<option value="zoom" {{ 'selected' if settings.mode == 'zoom' else '' }}>Zoom (fill frame, crop overflow)</option>
<option value="letterbox" {{ 'selected' if settings.mode == 'letterbox' else '' }}>Letterbox (show entire image)</option>
</select>
</div>
<div class="pan-controls" id="pan-controls">
<div class="control-row">
<label>Pan X</label>
<input type="range" id="pan_x" min="0" max="1" step="0.01"
value="{{ settings.pan_x }}" oninput="onPanChange()">
<span class="range-value" id="pan_x_val">{{ '%.0f' % (settings.pan_x * 100) }}%</span>
</div>
<div class="control-row">
<label>Pan Y</label>
<input type="range" id="pan_y" min="0" max="1" step="0.01"
value="{{ settings.pan_y }}" oninput="onPanChange()">
<span class="range-value" id="pan_y_val">{{ '%.0f' % (settings.pan_y * 100) }}%</span>
</div>
</div>
<div class="btn-row">
<button class="btn primary" onclick="saveSettings()">Save</button>
<button class="btn reset" onclick="resetSettings()">Reset to default</button>
<span class="loading" id="loading">Rendering...</span>
</div>
</div>
<a class="back" href="/">&larr; Back to gallery</a>
<script>
const photoName = {{ photo_name | tojson }};
let refreshTimer = null;
function onModeChange() {
const mode = document.getElementById('mode').value;
document.getElementById('pan-controls').style.display = mode === 'zoom' ? 'flex' : 'none';
scheduleRefresh();
}
function onPanChange() {
document.getElementById('pan_x_val').textContent = Math.round(document.getElementById('pan_x').value * 100) + '%';
document.getElementById('pan_y_val').textContent = Math.round(document.getElementById('pan_y').value * 100) + '%';
scheduleRefresh();
}
function scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(refreshPreview, 300);
}
function refreshPreview() {
const mode = document.getElementById('mode').value;
const panX = document.getElementById('pan_x').value;
const panY = document.getElementById('pan_y').value;
const img = document.getElementById('preview');
const loading = document.getElementById('loading');
let url = `/preview/${encodeURIComponent(photoName)}?mode=${mode}`;
if (mode === 'zoom') url += `&pan_x=${panX}&pan_y=${panY}`;
loading.style.display = 'inline';
const newImg = new window.Image();
newImg.onload = () => { img.src = newImg.src; loading.style.display = 'none'; };
newImg.onerror = () => { loading.style.display = 'none'; };
newImg.src = url;
}
function saveSettings() {
const mode = document.getElementById('mode').value;
const body = { mode };
if (mode === 'zoom') {
body.pan_x = parseFloat(document.getElementById('pan_x').value);
body.pan_y = parseFloat(document.getElementById('pan_y').value);
}
fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, {
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
}).then(r => r.json()).then(() => { /* saved */ });
}
function resetSettings() {
fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, { method: 'DELETE' })
.then(r => r.json())
.then(s => {
document.getElementById('mode').value = s.mode;
document.getElementById('pan_x').value = s.pan_x;
document.getElementById('pan_y').value = s.pan_y;
onModeChange();
onPanChange();
refreshPreview();
});
}
</script>
</body>
</html>
"""
# --- Web UI ---
@app.route("/")
def index():
photos = get_photo_list()
orientation = frame_settings.get("orientation", "landscape")
default_mode = frame_settings.get("default_mode", "zoom")
return render_template_string(WEB_UI, photos=photos, state=frame_state,
photo_count=len(photos), orientation=orientation,
default_mode=default_mode)
@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;
align-items: flex-end;
}
.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; }
.status-card select {
background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px;
padding: 0.25rem 0.4rem; font-size: 0.85rem;
}
.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: 65%; }
.photo-card .actions { display: flex; gap: 0.3rem; }
.photo-card .btn {
background: none; border: none; cursor: pointer; font-size: 0.85rem;
padding: 0.2rem 0.4rem; border-radius: 4px;
}
.photo-card .simulate { color: #6c8; }
.photo-card .simulate:hover { background: rgba(102,204,136,0.15); }
.photo-card .delete { color: #f55; }
.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>&#9632;</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">Orientation</span>
<select onchange="updateSetting('orientation', this.value)">
<option value="landscape" {{ 'selected' if orientation == 'landscape' else '' }}>Landscape</option>
<option value="portrait_cw" {{ 'selected' if orientation == 'portrait_cw' else '' }}>Portrait (CW)</option>
<option value="portrait_ccw" {{ 'selected' if orientation == 'portrait_ccw' else '' }}>Portrait (CCW)</option>
</select>
</div>
<div class="item">
<span class="label">Default Mode</span>
<select onchange="updateSetting('default_mode', this.value)">
<option value="zoom" {{ 'selected' if default_mode == 'zoom' else '' }}>Zoom</option>
<option value="letterbox" {{ 'selected' if default_mode == 'letterbox' else '' }}>Letterbox</option>
</select>
</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>
<div class="actions">
<a class="btn simulate" href="/simulate/{{ p.name }}" title="Preview on e-paper">&#9632;</a>
<button class="btn delete" onclick="deletePhoto('{{ p.name }}')" title="Delete">&times;</button>
</div>
</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(); });
}
function updateSetting(key, value) {
fetch('/api/settings', {
method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({[key]: value})
});
}
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)
DATA_DIR = Path(args.photos_dir) / ".data"
STATE_FILE = DATA_DIR / "frame_state.json"
SETTINGS_FILE = DATA_DIR / "settings.json"
IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json"
if not PHOTOS_DIR.exists():
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
# Reload with correct paths
frame_state = _load_json(STATE_FILE, lambda: {
"last_update": None, "current_photo": None, "ip": None, "updates": 0
})
frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS))
image_settings = _load_json(IMAGE_SETTINGS_FILE, dict)
print(f"Serving photos from: {PHOTOS_DIR.resolve()}")
print(f"Found {len(get_photo_list())} photos")
print(f"Orientation: {frame_settings['orientation']}, Default mode: {frame_settings['default_mode']}")
print(f"Listening on port {args.port}")
app.run(host="0.0.0.0", port=args.port)