Add two physical buttons: factory-reset and next-photo
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.
This commit is contained in:
2026-07-18 23:28:36 -04:00
parent 7013311249
commit d395cf3bb9
19 changed files with 668 additions and 47 deletions
+99 -21
View File
@@ -5,15 +5,15 @@ from __future__ import annotations
import io
import logging
import random
import httpx
from fastapi import FastAPI, Form, HTTPException, Request
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
from . import config, photo_queue
from .image_pipeline import render_frame
from .immich_client import ImmichClient
@@ -72,7 +72,12 @@ def api_config_save(
# so there's nothing here that could overwrite or clear them.
cfg = config.load()
if album_id != cfg.album_id:
cfg.cursor = 0 # restart from the top of a newly selected album
# 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))
@@ -81,46 +86,119 @@ def api_config_save(
return {"status": "saved"}
@app.get("/frame/image")
def frame_image():
cfg = config.load()
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")
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
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
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)
def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str) -> bytes:
try:
jpeg_bytes = client.download_asset_preview(asset["id"])
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"])
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)
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
source = Image.open(io.BytesIO(jpeg_bytes))
frame_bytes = render_frame(source, faces=faces)
return render_frame(source, faces=faces)
return Response(content=frame_bytes, media_type="application/octet-stream")
@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)