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
+8 -1
View File
@@ -20,10 +20,17 @@ class FrameConfig(BaseModel):
immich_api_key: str = ""
album_id: str = ""
order: str = "sequential" # or "shuffle"
cursor: int = 0
refresh_interval_s: int = 3600
smart_crop_faces: bool = True
# Current photo + upcoming queue (see app/photo_queue.py). current_asset_set_at
# is what lets the server decide "has it been long enough to advance" on its
# own clock, independent of how/why the device asked for a photo.
current_asset_id: str = ""
current_asset_set_at: float = 0.0
queue: list[str] = []
queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing
def load() -> FrameConfig:
with _lock:
+14
View File
@@ -55,3 +55,17 @@ class ImmichClient:
)
resp.raise_for_status()
return resp.content
def download_asset_thumbnail(self, asset_id: str) -> tuple[bytes, str]:
"""Smaller than download_asset_preview -- used for the web UI's
upcoming-photos list, not the actual rendered frame. Returns
(content, content_type) since this one gets proxied straight to a
browser <img> tag and needs a correct Content-Type header."""
resp = httpx.get(
f"{self.base_url}/api/assets/{asset_id}/thumbnail",
params={"size": "thumbnail"},
headers=self._headers,
timeout=30,
)
resp.raise_for_status()
return resp.content, resp.headers.get("content-type", "image/jpeg")
+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)
+100
View File
@@ -0,0 +1,100 @@
"""Tracks which photo is currently displayed and what's queued up next.
`current_asset_id` only ever changes two ways: the configured refresh
interval elapsing (`get_current`, called on every `GET /frame/image` --
a no-op otherwise, so an unplanned device reboot just redisplays the same
photo instead of silently skipping ahead) or an explicit forced advance
(`advance_forced`, called from `POST /frame/advance` -- the next-photo
button -- ignoring elapsed time).
`queue` is a small reorderable lookahead the web UI can preview and
rearrange, topped up automatically from the album as it's consumed.
`queue_cursor` is separate, internal-only bookkeeping for where sequential
top-up resumes in the album -- not shown or reordered in the UI.
"""
from __future__ import annotations
import random
import time
from .config import FrameConfig
QUEUE_TARGET_LEN = 10
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
valid_ids = {a["id"] for a in assets}
cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids]
needed = QUEUE_TARGET_LEN - len(cfg.queue)
if needed <= 0 or not assets:
return
excluded = set(cfg.queue)
if cfg.current_asset_id:
excluded.add(cfg.current_asset_id)
if cfg.order == "shuffle":
candidates = [a["id"] for a in assets if a["id"] not in excluded]
cfg.queue.extend(random.sample(candidates, min(needed, len(candidates))))
return
# Sequential: walk the album starting at queue_cursor, at most one full
# pass, wrapping around. queue_cursor resumes right after wherever this
# pass stopped, whether or not it filled the queue (e.g. a small album
# where everything's already queued/current -- next call is then a
# cheap no-op scan until something's consumed).
n = len(assets)
cfg.queue_cursor %= n
added = 0
i = 0
for i in range(n):
if added >= needed:
break
asset_id = assets[(cfg.queue_cursor + i) % n]["id"]
if asset_id not in excluded:
cfg.queue.append(asset_id)
excluded.add(asset_id)
added += 1
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
"""Unconditionally moves to the next photo, ignoring elapsed time, and
resets the interval clock from now. Used only by the explicit
next-photo action (POST /frame/advance) -- always mutates cfg."""
_top_up(cfg, assets)
if cfg.queue:
cfg.current_asset_id = cfg.queue.pop(0)
elif assets:
# Queue still empty after top-up (e.g. a single-photo album whose
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
# Refill back up to QUEUE_TARGET_LEN now that current_asset_id has
# changed -- otherwise the queue is left one short until the *next*
# advance, since the pop above consumes one of the items _top_up just
# added.
_top_up(cfg, assets)
def get_current(cfg: FrameConfig, assets: list[dict]) -> bool:
"""Time-based, idempotent path used by GET /frame/image. Advances only
if the current photo is unset/invalid or refresh_interval_s has
elapsed since it was set. Returns whether it changed anything, so the
caller knows whether to persist. Calling this repeatedly well within
the interval is a no-op both times -- what makes an unplanned device
reboot safe: it just re-reads the current photo instead of skipping
ahead, while a wake that lands after the interval has elapsed still
advances exactly once, even after a long time offline."""
valid_ids = {a["id"] for a in assets}
stale = (
not cfg.current_asset_id
or cfg.current_asset_id not in valid_ids
or (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s
)
if not stale:
return False
advance_forced(cfg, assets)
return True
+95
View File
@@ -22,6 +22,14 @@
.info-box { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 13px; background: #f3f4f6; color: #444; }
.info-box.warn { background: #fef9c3; color: #854d0e; }
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
h2.section { font-size: 16px; margin-top: 28px; margin-bottom: 8px; }
.thumb { width: 160px; max-width: 100%; border-radius: 4px; display: block; }
#upcoming-list { list-style: none; padding: 0; margin: 0; }
.queue-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid #eee; }
.queue-item img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; }
.queue-item .spacer { flex: 1; }
.queue-item button { margin: 0; padding: 4px 10px; font-size: 13px; background: #6b7280; }
.queue-item button:disabled { opacity: 0.35; cursor: default; }
</style>
</head>
<body>
@@ -63,6 +71,12 @@
</form>
<div id="result"></div>
<h2 class="section">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<h2 class="section">Upcoming</h2>
<ul id="upcoming-list"></ul>
<script>
const resultEl = document.getElementById('result');
@@ -117,10 +131,91 @@
try {
await saveConfig();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
let upcomingItems = [];
function renderUpcoming(items) {
upcomingItems = items;
const list = document.getElementById('upcoming-list');
list.innerHTML = '';
items.forEach((item, i) => {
const li = document.createElement('li');
li.className = 'queue-item';
const img = document.createElement('img');
img.src = item.thumbnail_url;
li.appendChild(img);
const spacer = document.createElement('span');
spacer.className = 'spacer';
li.appendChild(spacer);
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.textContent = '↑';
upBtn.disabled = i === 0;
upBtn.addEventListener('click', () => moveItem(i, -1));
li.appendChild(upBtn);
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.textContent = '↓';
downBtn.disabled = i === items.length - 1;
downBtn.addEventListener('click', () => moveItem(i, 1));
li.appendChild(downBtn);
list.appendChild(li);
});
}
async function moveItem(index, delta) {
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= upcomingItems.length) {
return;
}
const items = upcomingItems.slice();
[items[index], items[newIndex]] = [items[newIndex], items[index]];
renderUpcoming(items);
try {
const resp = await fetch('/api/queue/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue: items.map((item) => item.id) }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}
async function loadQueue() {
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch('/api/queue');
if (!resp.ok) {
currentEl.innerHTML = '<p class="sub">Not available yet -- configure Immich and an album first.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = data.current
? `<img class="thumb" src="${data.current.thumbnail_url}">`
: '<p class="sub">Nothing displayed yet.</p>';
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadQueue();
</script>
</body>
</html>