Add FastAPI server: pulls from Immich, pre-processes for the panel

Implements the server side of the architecture decided on: the ESP32-C6
has no PSRAM and a tight RAM budget, so all the heavy lifting (JPEG
decode, resize, Floyd-Steinberg dithering, 6-color quantization, 4bpp
packing) happens here instead of on-device. The frame just does a single
GET and streams the response straight to SPI.

- GET /frame/image: looks up the current cursor's asset in the configured
  Immich album, downloads its preview thumbnail, and returns it packed
  into the panel's exact 800x480/4bpp/2px-per-byte format
  (application/octet-stream, always exactly 192,000 bytes).
- GET / + POST /api/config + GET /api/albums: a small web UI for entering
  the Immich URL/API key and picking an album, rather than cramming that
  into the ESP32's captive portal form.
- Config (Immich creds, selected album, cursor) persists to a JSON file
  via a docker-compose volume mount.

Verified locally with a venv (Docker isn't available in this environment):
unit-tested image_pipeline against a synthetic image (exact byte count,
valid panel color codes only), and ran a full end-to-end pass against a
mock Immich HTTP server exercising the real /frame/image path.

Pinned dependency versions in requirements.txt after hitting a real bug
with unpinned floors: the latest starlette (1.3.1) resolved by `pip
install fastapi` breaks Jinja2Templates outright.

Not yet wired to the ESP32 side (task 6) or authenticated -- /frame/image
is unauthenticated for now, fine on a trusted LAN but worth revisiting
once the firmware sends a shared device token.
This commit is contained in:
2026-07-18 14:35:14 -04:00
parent a518cbdbaf
commit 5e7c47e2eb
10 changed files with 410 additions and 0 deletions
+12
View 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 app ./app
EXPOSE 8420
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
+51
View File
@@ -0,0 +1,51 @@
# ESPresso Frame Server
Pulls photos from an [Immich](https://immich.app) album, resizes/dithers/quantizes
them to the E Ink Spectra 6 panel's exact 6-color format, and serves the
frame a ready-to-display image once an hour. All the image processing
happens here so the ESP32 never has to decode a JPEG or run a dithering
algorithm itself -- it just streams the response straight to the panel.
## Setup
1. **Get an Immich API key**: in Immich, go to Account Settings -> API Keys
-> New API Key. Read-only access to albums/assets is enough.
2. **Run the server**:
```
docker compose up -d
```
3. Open `http://<this-machine>:8420/` in a browser, enter your Immich URL
and API key, click **Load Albums**, pick one, and **Save**.
4. On the ESP32's captive portal setup form, set the **Tools Server** field
to `<this-machine>:8420`.
## Endpoints
- `GET /` -- config UI
- `GET /api/albums` -- lists Immich albums (used by the config UI)
- `POST /api/config` -- saves Immich URL/API key/album/order
- `GET /frame/image` -- returns the current photo pre-processed into the
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes)
- `GET /health` -- liveness check
## Notes
- Config (including the Immich API key) is stored in `./data/config.json`
on the host via the compose volume mount.
- `/frame/image` isn't authenticated yet. That's fine on a trusted home
LAN for now, but worth revisiting once the ESP32 side is wired up to
send a shared device token.
- The 6-color palette RGB values in `app/image_pipeline.py` are
approximations, not measured values (Waveshare doesn't publish exact
color primaries for this panel) -- tune them once you can compare a
rendered test image against the real panel.
## Local development (without Docker)
```
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
CONFIG_PATH=./data/config.json uvicorn app.main:app --reload --port 8420
```
View File
+36
View File
@@ -0,0 +1,36 @@
"""JSON-file-backed config: Immich connection, selected album, and cursor
state (which photo /frame/image serves next)."""
from __future__ import annotations
import json
import os
from pathlib import Path
from threading import Lock
from pydantic import BaseModel
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
_lock = Lock()
class FrameConfig(BaseModel):
immich_url: str = ""
immich_api_key: str = ""
album_id: str = ""
order: str = "sequential" # or "shuffle"
cursor: int = 0
def load() -> FrameConfig:
with _lock:
if not CONFIG_PATH.exists():
return FrameConfig()
return FrameConfig(**json.loads(CONFIG_PATH.read_text()))
def save(cfg: FrameConfig) -> None:
with _lock:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(cfg.model_dump_json(indent=2))
+57
View File
@@ -0,0 +1,57 @@
"""Resize, quantize, and pack a photo into the panel's raw 4bpp format."""
from __future__ import annotations
from PIL import Image, ImageOps
EPD_WIDTH = 800
EPD_HEIGHT = 480
# Approximate sRGB for each of the panel's 6 ink colors. These are
# reasonable placeholders, not measured values -- Waveshare doesn't publish
# exact color primaries for this panel. Tune them once you can compare a
# rendered test image against the real panel.
PALETTE_RGB = [
(0, 0, 0), # BLACK
(255, 255, 255), # WHITE
(255, 219, 0), # YELLOW
(207, 0, 15), # RED
(0, 39, 133), # BLUE
(0, 133, 55), # GREEN
]
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
def _build_palette_image() -> Image.Image:
pal_img = Image.new("P", (1, 1))
pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb])
return pal_img
_PALETTE_IMAGE = _build_palette_image()
def render_frame(source: Image.Image) -> bytes:
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
"""
fitted = ImageOps.exif_transpose(source.convert("RGB"))
fitted = ImageOps.fit(fitted, (EPD_WIDTH, EPD_HEIGHT), method=Image.LANCZOS)
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
pixels = quantized.load()
out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2)
i = 0
for y in range(EPD_HEIGHT):
for x in range(0, EPD_WIDTH, 2):
left = PANEL_CODES[pixels[x, y]]
right = PANEL_CODES[pixels[x + 1, y]]
out[i] = (left << 4) | right
i += 1
return bytes(out)
+31
View File
@@ -0,0 +1,31 @@
"""Thin wrapper around the bits of the Immich API this project needs."""
from __future__ import annotations
import httpx
class ImmichClient:
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip("/")
self._headers = {"x-api-key": api_key}
def list_albums(self) -> list[dict]:
resp = httpx.get(f"{self.base_url}/api/albums", headers=self._headers, timeout=10)
resp.raise_for_status()
return resp.json()
def get_album(self, album_id: str) -> dict:
resp = httpx.get(f"{self.base_url}/api/albums/{album_id}", headers=self._headers, timeout=10)
resp.raise_for_status()
return resp.json()
def download_asset_preview(self, asset_id: str) -> bytes:
resp = httpx.get(
f"{self.base_url}/api/assets/{asset_id}/thumbnail",
params={"size": "preview"},
headers=self._headers,
timeout=30,
)
resp.raise_for_status()
return resp.content
+98
View File
@@ -0,0 +1,98 @@
"""ESPresso Frame server: pulls photos from Immich, pre-processes them for
the panel, and serves the ESP32 a ready-to-display frame."""
from __future__ import annotations
import io
import random
import httpx
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from PIL import Image
from . import config
from .image_pipeline import render_frame
from .immich_client import ImmichClient
app = FastAPI(title="ESPresso Frame Server")
templates = Jinja2Templates(directory="app/templates")
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
cfg = config.load()
return templates.TemplateResponse("index.html", {"request": request, "cfg": cfg})
@app.get("/api/albums")
def api_albums():
cfg = config.load()
if not cfg.immich_url or not cfg.immich_api_key:
raise HTTPException(400, "Immich URL/API key not configured yet")
try:
albums = ImmichClient(cfg.immich_url, cfg.immich_api_key).list_albums()
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@app.post("/api/config")
def api_config_save(
immich_url: str = Form(""),
immich_api_key: str = Form(""),
album_id: str = Form(""),
order: str = Form("sequential"),
):
cfg = config.load()
cfg.immich_url = immich_url.strip()
cfg.immich_api_key = immich_api_key.strip()
if album_id != cfg.album_id:
cfg.cursor = 0 # restart from the top of a newly selected album
cfg.album_id = album_id
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
config.save(cfg)
return {"status": "saved"}
@app.get("/frame/image")
def frame_image():
cfg = config.load()
if not cfg.immich_url or not cfg.immich_api_key:
raise HTTPException(400, "Immich URL/API key not configured yet")
if not cfg.album_id:
raise HTTPException(400, "No album configured yet")
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
try:
album = client.get_album(cfg.album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
assets = album.get("assets", [])
if not assets:
raise HTTPException(404, "Album has no photos")
if cfg.order == "shuffle":
asset = random.choice(assets)
else:
index = cfg.cursor % len(assets)
asset = assets[index]
cfg.cursor = (index + 1) % len(assets)
config.save(cfg)
try:
jpeg_bytes = client.download_asset_preview(asset["id"])
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
source = Image.open(io.BytesIO(jpeg_bytes))
frame_bytes = render_frame(source)
return Response(content=frame_bytes, media_type="application/octet-stream")
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESPresso Frame</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; color: #222; }
h1 { font-size: 20px; }
p.sub { color: #666; font-size: 14px; margin-top: -8px; }
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; }
button:hover { background: #1d4ed8; }
button.secondary { background: #6b7280; margin-right: 8px; }
.status { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; }
.status.ok { background: #dcfce7; color: #166534; }
.status.err { background: #fee2e2; color: #991b1b; }
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
</style>
</head>
<body>
<h1>ESPresso Frame</h1>
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
<form id="config-form">
<label>Immich URL
<input type="text" id="immich_url" value="{{ cfg.immich_url }}" placeholder="http://192.168.1.10:2283" required>
</label>
<label>Immich API Key
<input type="password" id="immich_api_key" value="{{ cfg.immich_api_key }}" required>
</label>
<label>Album
<select id="album_id">
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
</select>
</label>
<label>Order
<select id="order">
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
<div id="result"></div>
<script>
const resultEl = document.getElementById('result');
function showStatus(ok, message) {
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
}
async function saveConfig() {
const body = new URLSearchParams({
immich_url: document.getElementById('immich_url').value,
immich_api_key: document.getElementById('immich_api_key').value,
album_id: document.getElementById('album_id').value || '',
order: document.getElementById('order').value,
});
const resp = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
document.getElementById('load-albums').addEventListener('click', async () => {
try {
await saveConfig();
const resp = await fetch('/api/albums');
if (!resp.ok) {
throw new Error(await resp.text());
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveConfig();
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
}
});
</script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
services:
espresso-frame-server:
build: .
ports:
- "8420:8420"
volumes:
- ./data:/data
environment:
- CONFIG_PATH=/data/config.json
restart: unless-stopped
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.115.6
starlette==0.41.3
uvicorn[standard]==0.34.0
httpx==0.28.1
pillow==11.1.0
python-multipart==0.0.20
jinja2==3.1.5