Add two physical buttons: factory-reset and next-photo
Build and push server image / build-and-push (push) Successful in 35s
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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user