Commit Graph
25 Commits
Author SHA1 Message Date
tfaour f7eaadcbed Server: device status panel (last seen, battery history, runtime estimate) + firmware hosting for OTA
/api/queue now returns a "device" object: last_seen/overdue, running and
available firmware versions, battery percent + on-battery duration +
linear-fit remaining-time estimate (recharge cycles reset the history so
estimates never span a charge). New POST /api/firmware (token-gated
upload, validates the embedded esp_app_desc_t) and GET /frame/firmware
(token-gated download) let a build be pushed to the device without
touching it physically. GET /frame/config now accepts an X-Frame-Version
header and returns the available firmware version, piggybacking the
device's update check on a request it already makes every wake.
2026-07-20 22:23:59 -04:00
tfaour 015993af00 Add battery level reporting (Kconfig-gated) and display orientation
Build and push server image / build-and-push (push) Successful in 32s
Battery (firmware + server, disabled by default): new battery.c reads
a 2x200k voltage divider via ADC oneshot with curve-fitting calibration
(the ESP32-C6's scheme), maps through a piecewise LiPo discharge curve,
and restores the pin to button duty after each read -- the settled
XIAO ESP32-C6 design shares the back button's GPIO0/A0, time-shared per
wake. Skipped entirely when on mains (a 2x100k VBUS divider into a
spare digital pin -- the 5V pin is dead on battery power, so presence =
mains, where the charging voltage would read misleadingly full) or when
the reading is implausible. The manage overlay gains a battery region
(static outline glyph + "NN%", below the manage QR, all menu levels),
and the device POSTs to the new /frame/battery endpoint after a
successful fetch; the server stores percent + as-of timestamp, exposed
via /api/queue and shown in the web UI. FRAME_BATTERY_ADC_GPIO /
FRAME_VBUS_SENSE_GPIO default to -1 (fully inert on the dev board);
compile-verified both disabled and enabled, hardware bring-up deferred
until the ordered XIAO + batteries arrive.

Orientation (server-side only): new config setting + web UI dropdown
(landscape / portrait / landscape_flipped / portrait_flipped). Photos
are composed/cropped at the logical hanging shape (portrait crops at
480x800, so face-aware crops match how the frame actually hangs), then
rotated losslessly into the panel's native 800x480 byte layout after
dithering -- the device never knows. Face-label anchors are transformed
through the same rotation (logical_to_native()) so they stay attached
to faces on rotated frames. Known documented limitation: the on-device
manage overlay still renders in native orientation, so it appears
sideways on a portrait-hung frame (QRs scan at any rotation; text reads
sideways).
2026-07-19 22:11:14 -04:00
tfaour 86d5852f8e Add remove-from-rotation, fix mobile scroll-vs-drag conflict
Build and push server image / build-and-push (push) Successful in 32s
Remove from rotation: a new bounded exclude list
(FrameConfig.excluded_asset_ids) that photo_queue._top_up() never
selects from. POST /api/queue/remove scrubs an asset out of
queue/history too so it can't resurface via "Show next" or the back
button, and if it was the current photo, advances away from it
immediately -- without recording it in history, since going back to a
photo you just explicitly removed doesn't make sense. Doesn't touch
Immich or the album itself, just this frame's own selection. Wired into
the web UI as a small "x" button on both the current-photo thumbnail
and every upcoming card.

Mobile scroll fix: touching a card to scroll the page was being
captured as a drag attempt every time (touch-action: none on every
.photo-card, needed for the existing drag-reorder gesture to work at
all), making it too easy to accidentally reorder instead of scroll.
Reworked touch dragging to require a brief hold (350ms, roughly
stationary) before it arms -- touch-action stays "pan-y" (native
scroll allowed) the whole time up to that point, so a normal
touch-and-swipe scrolls the page like anywhere else, and only switches
to "none" once a hold is confirmed as deliberate. Mouse dragging is
unchanged (no hold delay -- no scroll-vs-drag ambiguity with a mouse).
Also made the "Show next" and new remove buttons always visible instead
of hover/focus-revealed, since that was invisible-but-still-tappable on
touch (no hover state) -- a real hazard for a destructive action.
2026-07-19 18:09:30 -04:00
tfaour 4ffd9e852c Raise MAX_QUEUE_TARGET_LEN from 50 to 5000 2026-07-19 17:38:21 -04:00
tfaour 5e86a20e8b Fix config read-modify-write race and two firmware buffer edge cases
Build and push server image / build-and-push (push) Successful in 35s
Found by a thorough code review:

- server/app/config.py's load()/save() each locked only their own file
  I/O, not the full read-modify-write cycle each route does around
  them. Since uvicorn dispatches sync routes to a thread pool, two
  concurrent requests (e.g. the device's own poll landing alongside a
  web UI edit) could each load() the same on-disk state and the
  second's save() silently clobber the first's changes. Added
  config.locked() (backed by an RLock, since load()/save() also take
  the lock internally) and wrapped every mutating route's
  load/mutate/save span in it -- kept outside the lock wherever a route
  also does slow Immich network I/O, re-loading fresh state right
  before the actual mutation instead. Verified with a new concurrency
  stress test (many concurrent /api/queue/promote and /api/config
  calls) alongside the existing scratch suite.
- firmware/main/root.html's SSID/password/toolsserver/access-token
  inputs had no maxlength, so pasting something longer than the
  matching NVS buffer (wifi_provisioning.h's FRAME_CFG_*_MAX_LEN) was
  silently truncated with no indication why the device later can't
  connect or gets 401s.
- frame_client.c's share_url buffer (256 bytes) could be too small in
  the worst case -- toolsserver (128) + "/frame/share/" + asset_id (47)
  + "?token=" + access_token (64) can reach ~266 bytes, silently
  dropping the token off a request that would then just 401 with no
  obvious cause. Widened to 320.
2026-07-19 15:20:26 -04:00
tfaour d5de882b1e Fix stale documentation found by a doc-accuracy audit
firmware/README.md's HTTP vs HTTPS section still described the
public-CA-bundle trust approach that was tried and abandoned in favor
of pinning one specific certificate -- rewritten to match what's
actually there. docs/architecture.md was missing the back-photo button
entirely (sequence diagram and boot-flow bullets only covered next) and
still said the system talks "over plain HTTP" despite HTTPS support.
docker-compose.yml.example's MANAGEMENT_TOKEN comment understated its
scope (said "the web UI", omitting that every /frame/* endpoint is
gated too).
2026-07-19 15:20:12 -04:00
tfaour aef8a10029 Make the upcoming-queue drag reorder work on touch devices
Build and push server image / build-and-push (push) Successful in 34s
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).
2026-07-19 12:59:53 -04:00
tfaour 3868d357ff Add back-photo button; consolidate reset/manage onto one hold-duration button
Build and push server image / build-and-push (push) Successful in 32s
Back button (new GPIO0, POST /frame/back): the server now tracks a
bounded history of previously-current photos (photo_queue.py), pushed
to on every advance (auto or forced) and popped by back_forced() --
symmetric with advance, so pressing next afterwards returns to right
where you were. frame_client.c's force_advance bool becomes a 3-way
fetch_action_t (NORMAL/ADVANCE/BACK) threaded through the whole fetch
path.

Also folds the separate reset and manage buttons onto one pin
(combo_button.c, replacing reset_button.c/manage_button.c entirely),
disambiguated by hold duration: quick press shows the management menu
(unchanged), ~3s hold-then-release soft-resets (esp_restart(), config
kept -- new), ~15s hold factory-resets (today's old reset behavior,
extended from 10s for clearer tier separation). Driven by a production
board (Seeed XIAO ESP32-C6) exposing only 3 of the ESP32-C6's 8
deep-sleep-wakeup-capable GPIOs -- next/back keep their own dedicated
pins where instant response matters most, everything else shares the
third pin via timing instead of needing its own. Same three-pin layout
now works on both the dev board and the production board.

Fixed a fast-tap bug in combo_button_check() before shipping: it only
did a live gpio_get_level() read to decide whether the button was
pressed at all, so a press fast enough to already be released by the
time boot reached that check was missed entirely (treated as "never
pressed" rather than "quick press"). Added the same latched
esp_sleep_get_gpio_wakeup_status() check the other buttons already use
for exactly this reason.
2026-07-19 12:53:26 -04:00
tfaour 62cf907d88 Extend the access token to every endpoint, not just the web UI
Build and push server image / build-and-push (push) Successful in 33s
The management token only gated / and /api/* -- every device-facing
/frame/* endpoint (including /frame/image, which serves the actual
photo bytes) stayed open regardless. That was fine while the server
was assumed LAN-only, but defeats the point now that HTTPS exists
specifically to let this sit behind a public hostname.

build_url() (frame_client.c) is the one chokepoint all firmware-side
URL construction already went through, so it now appends ?token= to
every request it builds -- device fetches and QR-embedded links alike
-- instead of that being bolted on per-callsite. Server-side, the
former require_management_token dependency (renamed require_access_token)
is applied to /frame/config, /frame/image, /frame/advance,
/frame/photo-info, /frame/face-labels, and /frame/share/{asset_id} too.
/health stays open -- pure liveness, nothing sensitive to protect.
2026-07-19 09:52:01 -04:00
tfaour 6c7468a36e Add HTTPS support and a management-token gate for the web UI
Build and push server image / build-and-push (push) Successful in 31s
ESP32 side can now reach the tools server over HTTPS: the Tools Server
field accepts an https:// address for a TLS-terminating reverse proxy
in front of the server (which still only ever speaks plain HTTP
itself), trusting Cloudflare's Origin CA root (embedded at build time)
since that's the common way to get a real cert on a private origin.
Every URL the device builds -- image fetch, config check, manage-menu
data, the QR codes' own links -- goes through one build_url() helper
that picks the scheme from what's configured.

Also adds an optional MANAGEMENT_TOKEN (docker-compose.yml) that gates
the web UI (/, /api/*) behind a shared secret -- unset by default, so
existing trusted-LAN deployments are unaffected. The same token is
entered once during the ESP32's captive-portal setup and gets baked
into the manage-menu's QR code (?token=...), so scanning it just works;
visiting the page without a valid token shows a plain entry prompt
instead of the config UI, and a valid query-param hit sets a cookie so
the page's own fetch()/<img> calls stay authorized for the rest of the
visit. Device-facing /frame/* endpoints are unaffected -- a separate,
already-documented trust boundary.
2026-07-19 09:42:38 -04:00
tfaour 8daafb0f81 Document that the Immich API key needs sharedLink.create
Build and push server image / build-and-push (push) Successful in 30s
A plain read-only key (per the original setup instructions) 403s on
POST /api/shared-links -- confirmed against the live instance's
permission enum in /api/spec.json.
2026-07-19 09:13:56 -04:00
tfaour e870898490 Refine manage overlay: US/CAN state abbreviations, share-QR caption, and an escalating second menu with named-face labels
Build and push server image / build-and-push (push) Successful in 32s
Two rounds of follow-up work on the manage-button overlay:

1. Location formatting: US/Canada now show abbreviated state/province
   ("CA", "ON") instead of the full name, other countries show the full
   country name, and each is its own line (was one line, now wraps to
   two) so longer international place names have more room without
   threatening to overlap the top-right QR box. The bottom-left share QR
   also gets a "SCAN TO DOWNLOAD" caption.

2. Escalating menu: pressing the manage button again while its overlay
   is already up adds a second level -- each Immich-identified person's
   name labeled next to their face in the photo (using Immich's own
   face recognition/People data, no detection/recognition added to this
   project). A third press exits immediately instead of waiting out the
   30s auto-revert timer. No new Immich API needed -- GET /api/faces
   already embeds a nullable person.name per face; new
   server/app/face_labels.py maps a named face's box into the final
   800x480 frame's pixel space (reusing crop-box math extracted from
   image_pipeline.py's face-aware cropping). Capped at 4 named faces,
   sized to a real firmware RAM budget: each label is its own malloc'd
   overlay region on the device, alongside the 4 fixed corner regions
   already in use. New GET /frame/face-labels returns a flattened
   fixed-slot JSON shape (not a real array) so firmware's existing
   flat-scalar parser can read it without needing an actual array
   parser. No persistent state needed for the escalation itself -- it's
   all local control flow within one continuous awake session
   (frame_client.c's run_management_menu()).
2026-07-19 09:09:06 -04:00
tfaour a358045cea Fix "Show next" staleness bug; add location/date/share-QR to manage overlay
Build and push server image / build-and-push (push) Successful in 32s
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.
2026-07-19 01:28:25 -04:00
tfaour 42d7c09f97 Make the upcoming-photos queue length user-configurable
Build and push server image / build-and-push (push) Successful in 42s
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.
2026-07-19 01:03:05 -04:00
tfaour de41b00210 Web UI: grid layout, drag-and-drop reorder, and "show next" for upcoming photos
Build and push server image / build-and-push (push) Successful in 32s
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.
2026-07-19 00:57:13 -04:00
tfaour d395cf3bb9 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.
2026-07-18 23:28:36 -04:00
tfaour 7013311249 Add READMEs, docs, and LICENSE for publishing
Build and push server image / build-and-push (push) Successful in 32s
- LICENSE: MIT, with attribution notes for the vendored qrcode/epaper_fonts/
  dns_server code and the epd7in3e driver's transcription of Waveshare's
  register sequence.
- Top-level README.md: project overview, hardware list, quick-start
  pointing at firmware/ and server/, repo layout, license, Claude Code
  attribution.
- firmware/README.md: full rewrite (was still the stock ESP-IDF captive
  portal example's README) -- build/flash instructions, Kconfig reference
  table, first-boot walkthrough, and how to reset to provisioning mode via
  NVS erase (the only way in right now; a proper reconfigure trigger is a
  future addition).
- docs/hardware.md: wiring table + parts list + strapping-pin/SPI-speed notes.
- docs/architecture.md: sequence diagram and walkthrough of the full
  provision -> connect -> fetch -> display -> sleep cycle, plus the
  reasoning behind doing image processing server-side and reusing Immich's
  face detection instead of bundling a detector.
- server/README.md: fixed stale endpoint docs (missing GET /frame/config,
  POST /api/config still describing removed immich_url/api_key fields).
2026-07-18 16:47:30 -04:00
tfaour bd72fdad71 Remove Immich URL/API key from the config page
Build and push server image / build-and-push (push) Successful in 34s
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.
2026-07-18 16:14:38 -04:00
tfaour e7f096ac23 Support Immich URL/API key via docker-compose env vars
Build and push server image / build-and-push (push) Successful in 33s
docker-compose.yml is tracked in a repo meant for publishing, so it can't
hold a real API key. Renamed it to docker-compose.yml.example (placeholder
values, safe to commit) and gitignored the real docker-compose.yml --
deploying is now "cp the example, fill in real values, docker compose up",
no .env file needed.

config.load() now reads IMMICH_URL/IMMICH_API_KEY from the environment
and applies them on top of whatever's in config.json, so setting them in
the compose file's environment: block takes effect without ever touching
the web UI. Env vars always win over the UI-saved values when both are
present -- verified they survive a save() with different UI-entered
values still in place.
2026-07-18 16:05:06 -04:00
tfaour f2a374b363 Make face-aware crop minimal-shift instead of full re-centering
Build and push server image / build-and-push (push) Failing after 10s
_face_aware_crop_box() previously always centered the crop on the union
of all detected faces' centroid, even when the plain center-crop already
kept every face fully on screen -- unnecessarily moving a composition
that didn't need fixing. Now starts from the plain center-crop and only
shifts it the minimum amount needed to bring an otherwise-cropped-out
face back into frame; already-fine framing is left untouched (falls back
to centering on the faces' midpoint only if they're spread too wide for
any single shift to contain them all, which is unchanged from before).

Verified: a face safely inside the plain center-crop now produces byte-
identical output to the no-shift case (previously it still would have
been re-centered); an edge face gets a 100px shift instead of the 1050px
a full re-center would have applied. Re-ran against the real 4-face test
photo from earlier -- all four were already fully visible, so the refined
box now exactly matches the plain center-crop instead of shifting
unnecessarily.
2026-07-18 15:46:01 -04:00
tfaour 6e353c2271 Add server-configurable refresh interval + face-aware cropping
Build and push server image / build-and-push (push) Failing after 10s
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).
2026-07-18 15:32:29 -04:00
tfaour 34a3293a07 Fix /frame/image 404: Immich v3 doesn't embed assets in album responses
Build and push server image / build-and-push (push) Failing after 2m33s
GET /api/albums/{id} was assumed to return an "assets" array alongside
the album metadata (that's what the original plan/prior art expected),
but on Immich 3.0.3 AlbumResponseDto only has assetCount -- no assets
field at all. Confirmed against the OpenAPI spec served at
/api/spec.json and by testing directly against a real instance: the
assumption was simply wrong for this API version, not a permissions
issue (the album metadata call succeeds fine with a valid 200).

Assets for an album now come from POST /api/search/metadata with an
albumIds filter, which returns them under assets.items. Verified
end-to-end against the real Immich instance and album -- /frame/image
now returns a proper 200 with exactly 192,000 bytes, spread across all
six panel colors (not a degenerate all-white/black response).

Only fetches the first page of search results; fine for a photo frame
cycling through an album, but would need nextPage handling for anyone
pointing this at a very large album.
2026-07-18 15:19:05 -04:00
tfaour 7008233320 Fix local dev setup: pillow wheel + unreachable localhost bind
Build and push server image / build-and-push (push) Failing after 11s
- pillow==11.1.0 has no prebuilt wheel for Python 3.14, so pip fell back
  to building from source and failed without libjpeg dev headers
  installed. Bumped to 12.3.0 (has wheels); re-ran the local test suite
  against it with no other changes needed.
- The local-dev uvicorn command in the README was missing --host 0.0.0.0,
  so it defaulted to 127.0.0.1 -- unreachable from the ESP32 on the LAN.
  The Docker image already binds 0.0.0.0 correctly; only the doc'd local
  command was wrong.
2026-07-18 14:56:47 -04:00
tfaour 1c1d9c6767 Add Gitea Actions workflow to build/push the server image
Build and push server image / build-and-push (push) Failing after 2m46s
Builds server/Dockerfile and pushes to this repo's Gitea Container
Registry (git.thumeit.com/tfaour/espresso-frame-server) on every push to
main that touches server/, tagged both latest and the commit SHA.

docker-compose.yml now sets both image: and build: -- deploy hosts can
docker compose pull to grab the CI-built image without needing this
repo's build context, while local dev can still docker compose build
against Dockerfile changes directly.
2026-07-18 14:38:55 -04:00
tfaour 5e7c47e2eb Add FastAPI server: pulls from Immich, pre-processes for the panel
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.
2026-07-18 14:35:14 -04:00