Files
espresso_frame/server/app/main.py
T
tfaour 5e7c47e2eb 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.
2026-07-18 14:35:14 -04:00

99 lines
3.1 KiB
Python

"""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")