Build and push server image / build-and-push (push) Successful in 35s
Factory-reset (GPIO3, hold 10s): clears stored WiFi/server config and restarts into provisioning -- the deliberate, USB-free replacement for the earlier reverted RST-based auto-reprovisioning idea. Next-photo (GPIO2, tap): wakes the device and forces the server to advance immediately via a new POST /frame/advance, instead of waiting for the refresh interval. Both buttons arm themselves as deep-sleep GPIO wakeup sources so a press is noticed promptly even while asleep. Also makes GET /frame/image side-effect-free: it now only advances once refresh_interval_s has elapsed since the current photo was set (tracked server-side), so a device reboot for any reason just redisplays the current photo instead of silently skipping ahead. The server maintains a small reorderable upcoming-photos queue, viewable and rearrangeable from the web UI.
205 lines
7.0 KiB
Python
205 lines
7.0 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 logging
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException, Form, Request
|
|
from fastapi.responses import HTMLResponse, Response
|
|
from fastapi.templating import Jinja2Templates
|
|
from PIL import Image
|
|
from pydantic import BaseModel
|
|
|
|
from . import config, photo_queue
|
|
from .image_pipeline import render_frame
|
|
from .immich_client import ImmichClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI(title="ESPresso Frame Server")
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
MIN_REFRESH_INTERVAL_S = 60
|
|
MAX_REFRESH_INTERVAL_S = 86400
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/frame/config")
|
|
def frame_config():
|
|
"""Device-facing settings, polled by the frame alongside its
|
|
reachability check. Always returns 200 with current settings
|
|
(defaults if nothing's been saved yet) -- no Immich-configured gate,
|
|
since this doubles as the "is the server up" signal."""
|
|
cfg = config.load()
|
|
return {"refresh_interval_s": cfg.refresh_interval_s}
|
|
|
|
|
|
@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(
|
|
album_id: str = Form(""),
|
|
order: str = Form("sequential"),
|
|
refresh_interval_s: int = Form(3600),
|
|
smart_crop_faces: bool = Form(True),
|
|
):
|
|
# Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see
|
|
# docker-compose.yml.example) -- config.load() already applies them,
|
|
# and this handler doesn't touch cfg.immich_url/immich_api_key at all,
|
|
# so there's nothing here that could overwrite or clear them.
|
|
cfg = config.load()
|
|
if album_id != cfg.album_id:
|
|
# A newly selected album starts clean -- the old current photo and
|
|
# queue don't mean anything in the new album's context.
|
|
cfg.current_asset_id = ""
|
|
cfg.current_asset_set_at = 0.0
|
|
cfg.queue = []
|
|
cfg.queue_cursor = 0
|
|
cfg.album_id = album_id
|
|
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
|
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
|
|
cfg.smart_crop_faces = smart_crop_faces
|
|
config.save(cfg)
|
|
return {"status": "saved"}
|
|
|
|
|
|
def _require_configured(cfg: config.FrameConfig) -> None:
|
|
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")
|
|
|
|
|
|
def _list_assets(client: ImmichClient, cfg: config.FrameConfig) -> list[dict]:
|
|
try:
|
|
assets = client.list_album_assets(cfg.album_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
|
|
if not assets:
|
|
raise HTTPException(404, "Album has no photos")
|
|
return assets
|
|
|
|
|
|
def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str) -> bytes:
|
|
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
|
|
|
|
faces = None
|
|
if cfg.smart_crop_faces:
|
|
try:
|
|
faces = client.get_asset_faces(asset_id)
|
|
except httpx.HTTPError as e:
|
|
# A faces lookup hiccup shouldn't block showing a photo at
|
|
# all -- just fall back to a plain center-crop this cycle.
|
|
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
|
|
|
source = Image.open(io.BytesIO(jpeg_bytes))
|
|
return render_frame(source, faces=faces)
|
|
|
|
|
|
@app.get("/frame/image")
|
|
def frame_image():
|
|
"""Returns the current photo. Idempotent: only actually advances to
|
|
the next photo once refresh_interval_s has elapsed since the current
|
|
one was set (see app/photo_queue.py) -- safe to call as often as the
|
|
device wants, including after an unplanned reboot, without skipping
|
|
ahead in the album."""
|
|
cfg = config.load()
|
|
_require_configured(cfg)
|
|
|
|
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
|
assets = _list_assets(client, cfg)
|
|
|
|
if photo_queue.get_current(cfg, assets):
|
|
config.save(cfg)
|
|
|
|
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
|
|
|
|
|
|
@app.post("/frame/advance")
|
|
def frame_advance():
|
|
"""Forces an immediate advance to the next photo, ignoring
|
|
refresh_interval_s, and resets the interval clock from now. Used by
|
|
the device's next-photo button."""
|
|
cfg = config.load()
|
|
_require_configured(cfg)
|
|
|
|
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
|
assets = _list_assets(client, cfg)
|
|
|
|
photo_queue.advance_forced(cfg, assets)
|
|
config.save(cfg)
|
|
|
|
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
|
|
|
|
|
|
@app.get("/api/queue")
|
|
def api_queue():
|
|
cfg = config.load()
|
|
_require_configured(cfg)
|
|
|
|
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
|
assets = _list_assets(client, cfg)
|
|
|
|
if photo_queue.get_current(cfg, assets):
|
|
config.save(cfg)
|
|
|
|
def entry(asset_id: str) -> dict:
|
|
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
|
|
|
|
return {
|
|
"current": entry(cfg.current_asset_id) if cfg.current_asset_id else None,
|
|
"upcoming": [entry(asset_id) for asset_id in cfg.queue],
|
|
}
|
|
|
|
|
|
class QueueReorderRequest(BaseModel):
|
|
queue: list[str]
|
|
|
|
|
|
@app.post("/api/queue/reorder")
|
|
def api_queue_reorder(body: QueueReorderRequest):
|
|
cfg = config.load()
|
|
if set(body.queue) != set(cfg.queue) or len(body.queue) != len(cfg.queue):
|
|
raise HTTPException(400, "Reordered queue must contain exactly the current queue's photos")
|
|
cfg.queue = body.queue
|
|
config.save(cfg)
|
|
return {"status": "saved"}
|
|
|
|
|
|
@app.get("/api/photo-thumbnail/{asset_id}")
|
|
def api_photo_thumbnail(asset_id: str):
|
|
cfg = config.load()
|
|
_require_configured(cfg)
|
|
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
|
try:
|
|
content, content_type = client.download_asset_thumbnail(asset_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
|
return Response(content=content, media_type=content_type)
|