The native HTML5 Drag-and-Drop API (draggable/dragstart/dragover/drop)
is mouse-only by spec and never fires on phones/tablets, so reordering
was unusable on mobile. Replaced it with the Pointer Events API
(pointerdown/pointermove/pointerup), which unifies mouse, touch, and
pen into one code path, plus touch-action: none on the cards so
touching one to drag it doesn't get hijacked by the browser's default
scroll gesture. Same visual behavior as before (dim the dragged card,
outline the drop target).
Two changes, bundled since they landed in the same session and touch
overlapping files:
1. Fix: "Show next" sent the browser's full queue snapshot to
POST /api/queue/reorder, which hard-rejected if the server's queue
had shifted since the last fetch (e.g. right after a queue-length
trim). New POST /api/queue/promote moves one photo to the front
authoritatively, with no dependency on client staleness. /reorder
itself is now tolerant too -- unrecognized IDs are dropped and
missing ones appended, instead of rejecting the whole request.
2. Feature: the manage button's overlay now also shows the photo's
location (top-left, only if Immich reverse-geocoded it from GPS
EXIF), the date it was taken (bottom-right), and a QR code (bottom-
left) linking to a 30-minute public Immich share link -- created
lazily when someone actually scans it, not when the button's
pressed. New server endpoints GET /frame/photo-info and
GET /frame/share/{asset_id} (scoped to the frame's current/queued
photos, not any arbitrary Immich asset). Firmware-side, the overlay
mechanism generalizes from one spliced region to up to four
(manage_qr_overlay.c), each its own small buffer, still never
holding the full frame in RAM.
Adds "Upcoming photos to show" to the config UI (queue_target_len, 5-50,
default 20, replacing the hardcoded QUEUE_TARGET_LEN constant). Lowering
it trims the queue immediately on next page load rather than waiting for
enough advances to consume the excess naturally; raising it tops back up
the same way, via a new photo_queue.sync_queue_length() called from
GET /api/queue.
Replaces the up/down-button vertical list with a responsive photo grid
(native HTML5 drag-and-drop between cards, reusing the existing
POST /api/queue/reorder endpoint -- no new server route needed). Each
card also gets a "Show next" button that jumps it straight to the front
of the queue.
Also bumps the queue lookahead from 10 to 24 photos (QUEUE_TARGET_LEN in
photo_queue.py) now that the grid has room to show more at once.
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.
Now that they're set via docker-compose.yml's environment (previous
commit), leaving editable fields for them on the page was misleading --
anything typed there would be silently overwritten by the env vars on the
next load() anyway. Replaced with a read-only info banner showing the
configured Immich URL (never the API key value, even though it's already
env-sourced rather than user input) or a warning if IMMICH_URL/
IMMICH_API_KEY aren't set. POST /api/config no longer accepts or touches
those two fields at all.
Verified: page renders with no immich_url/immich_api_key input fields or
API key value in either case (env vars set or unset); config save/albums/
frame-image still work end-to-end via a mock Immich server.
Two features, both toggleable/settable from the web config UI:
Refresh interval: new GET /frame/config returns
{"refresh_interval_s": ...} as plain JSON. Reuses the endpoint the frame
already needs to hit for a reachability check each wake cycle (previously
/health) rather than adding a third round trip, and always returns 200
with current settings regardless of Immich-configured state so it stays
valid as a pure reachability signal. Clamped to [60, 86400] seconds in
POST /api/config.
Face-aware cropping: GET /api/faces?id={assetId} on Immich already
returns real per-photo face bounding boxes from its own People-feature
ML -- confirmed against a live instance, boxes scaled to the asset's
native resolution. No face detection built or bundled here at all, just
an API call plus rectangle math. image_pipeline.render_frame() gains an
optional `faces` param: when present, computes the largest crop window
matching the panel's aspect ratio that fits in the source image, centered
on the union of all face boxes' centroid (scaled into the downloaded
preview's actual resolution) instead of the image's geometric center,
clamped to stay within bounds. No faces (or the smart_crop_faces config
toggle off) falls straight back to the existing ImageOps.fit() center-crop
-- zero behavior change in that case. A faces-lookup failure logs and
degrades to center-crop rather than failing the whole request.
Verified: unit tests for the crop-box math (horizontal shift toward an
off-center face, edge clamping), a full mock-Immich end-to-end pass
(extended to serve /faces) confirming the toggle changes output and the
response is still exactly 192,000 bytes, and a live comparison against a
real 4-face photo on the user's Immich instance (crop top shifted from
528px to 246px toward the detected faces).
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.