35 Commits
Author SHA1 Message Date
tfaour 716776c3f2 Bump firmware to 1.3.0
Build and release firmware / build-and-release (push) Successful in 1m48s
Manage overlay now composited server-side.
2026-07-22 19:07:29 -04:00
tfaour 0f98d96d25 Untrack .claude/ (editor tooling, not project content) 2026-07-22 19:07:08 -04:00
tfaour c007acde75 Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds,
render agenda/week/month views. manage_overlay.py: composites the
manage-button overlay server-side (QR, battery, location/date,
share-QR, face labels), reused by every render mode. device.py/common.py
wire both together: mode dispatch for /frame/image+advance+back, and
the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings
calendar URL field) and the icalendar/recurring-ical-events deps.
2026-07-22 19:06:49 -04:00
tfaour 15e37c77cd Simplify firmware: manage overlay now composited server-side
Deletes manage_qr_overlay.c/.h; frame_client.c's manage-button flow is
now one fetch with &manage=1 instead of on-device QR/text generation
plus separate photo-info/face-labels requests.
2026-07-22 19:06:49 -04:00
tfaour 1fa1c68478 Add calendar frame mode data model (migration 7)
New columns, all ADD COLUMN with inert defaults -- no existing frame's
behavior changes until mode is explicitly switched to "calendar":

- users.calendar_ics_url: one personal iCal/CalDAV subscription per
  user, same shape as the existing per-user immich_url/immich_api_key.
- user_frames.calendar_included: explicit per-(user,frame) opt-in,
  default off. Being linked to a frame does not by itself contribute
  your calendar to it -- each person's calendar is their own data to
  share, not something a frame's controller decides on their behalf.
- frames.calendar_view/calendar_photo_inlay/calendar_browse_offset:
  per-frame display settings and NEXT/BACK navigation state.
- frames.calendar_checked_at/calendar_cached_events/calendar_fetch_summary:
  the throttled merge-fetch cache, same shape as the existing
  firmware_update_checked_at/firmware_gitea_latest_version pattern.
2026-07-22 19:05:02 -04:00
tfaour fc65b19cf2 Bump firmware to 1.2.4
Build and release firmware / build-and-release (push) Successful in 1m47s
Trimmed-mean battery ADC sampling to reduce noisy readings.
2026-07-22 16:57:34 -04:00
tfaour e1bca5a81a Fix false recharge-cycle detection from a single noisy battery reading
Build and push server image / build-and-push (push) Successful in 38s
A report was flagged as "the battery got recharged" (resetting
battery_history and stats_recharge_cycles, and re-arming the low-battery
alert) whenever it came in >= RECHARGE_JUMP_PCT above the single
immediately-previous report. That's exactly what a real recharge looks
like, but it's also exactly what a normal reading looks like right
after one noisy low report: e.g. 60, 59, 58, then a stray 53, then back
to a perfectly normal 58 -- 58 >= 53+5 falsely read as a recharge.

Now compared against the max of the last RECHARGE_LOOKBACK (3) reports
instead of just the one before it, so a lone stray reading doesn't get
to set the bar a normal reading then trips. A real recharge still needs
to clear all of them, so genuine recharges are still caught immediately
(verified: 18% -> 90% still triggers, history still resets).

Paired with the firmware-side battery.c change (trimmed-mean ADC
sampling) that reduces how often a stray reading like the 53 above
happens in the first place.
2026-07-22 16:51:37 -04:00
tfaour 845e4f9509 Reduce battery-reading noise with a trimmed-mean ADC sample
Sometimes a single reading came in noticeably off from the real trend
(a regulator/RF transient during sampling), and the next normal reading
would then look like a big jump relative to that bad one -- server-side,
enough to misfire the recharge-cycle heuristic (see the paired server
commit). Went from 8 raw-averaged samples to 16, sorted, with the 3
extreme samples on each end dropped before averaging the remaining 10 --
a handful of outliers can no longer skew the result the way a plain
average let them.
2026-07-22 16:51:28 -04:00
tfaour 02934b1d10 Bump firmware to 1.2.3
Build and release firmware / build-and-release (push) Successful in 1m48s
Fix stack buffer overflow in face-labels parsing.
2026-07-22 16:35:34 -04:00
tfaour f24c3b9c8e Gate firmware auto-check behind control+CSRF; skip faces with a null bounding box
Build and push server image / build-and-push (push) Successful in 39s
/api/frames/{id}/firmware/check could silently stage new firmware as a
side effect (the auto-apply path, when firmware_auto_update is on and
a newer release exists) but was gated by require_frame_view instead of
require_frame_control like its sibling firmware routes, and being a
GET, was exempt from the app's CSRF check (which only applies to
non-GET/HEAD/OPTIONS). A linked viewer without control -- or a
cross-site page riding a control-holding victim's session via a plain
GET -- could trigger an unreviewed firmware install. Now POST +
require_frame_control, matching /firmware/apply-latest; the frontend's
two callers (passive poll on page load, "Check now" button) both
already handle a 409 from a non-controller gracefully via the existing
apiError()/control-banner pattern, so this doesn't change UX for a
frame's actual controller.

Separately: Immich has been observed to return a face detection entry
with a null bounding-box field (a still-pending or otherwise
incomplete detection). Both places that do arithmetic on those fields
-- image_pipeline._face_aware_crop_box (crop_faces display mode) and
face_labels.compute_face_labels (manage-menu name labels) -- crashed
with an unhandled TypeError on such an entry, taking down that frame's
whole photo instead of the intended graceful fallback. Both now skip
any face missing a bounding-box field via a shared _has_bounding_box()
check; a face list with zero valid entries already degrades cleanly to
the plain center crop (the existing inf/-inf sentinel math already
handled "no faces" correctly, it just couldn't tell "none passed
Immich" apart from "one broken entry" before).
2026-07-22 16:21:37 -04:00
tfaour 38944a1287 Fix stack buffer overflow in face-labels parsing
fetch_face_labels() clamped the server-reported label count against
max_labels by casting the count to int first -- a value >= 2^31 (a
perfectly ordinary decimal in JSON) went negative under that cast, so
the comparison was always false and the clamp never fired. The loop
then ran with the full, unclamped count, writing past the caller's
fixed MANAGE_FACE_LABELS_MAX-element stack array on a crafted
/frame/face-labels response. Reachable by a compromised/malicious
tools server, or a MITM on the default plain-HTTP connection.

Fixed by comparing unsigned instead of casting to int.
2026-07-22 16:21:23 -04:00
tfaour 5b4fdbe330 Scope thumbnail access to the frame's own photos; validate Gitea repo URL
Build and push server image / build-and-push (push) Successful in 38s
Two fixes from a security pass over the server:

- /api/frames/{id}/thumbnail/{asset_id} accepted any asset id and
  fetched it via the frame owner's Immich credentials, unscoped to what
  that frame actually shows -- a user merely linked to view a frame
  could pull thumbnails for any asset in the owner's whole library, not
  just the frame's own album. Now scoped to current_asset_id/queue,
  matching the check device.frame_share and manage.manage_thumbnail
  already both apply.

- firmware_update_repo_url now has to be a plain http(s) URL. Unlike a
  one-off manual firmware upload (a deliberate, explicit act -- left
  alone), auto-update from a repo is a standing trust relationship: the
  frame keeps fetching from it and, with auto-update on, installs
  whatever it finds with nobody reviewing it first. Added a plain-
  language note next to the checkbox saying exactly that.
2026-07-22 12:57:11 -04:00
tfaour dcbc71e683 Show "Not enough data yet" instead of hiding the battery-estimate row
Build and push server image / build-and-push (push) Successful in 40s
Previously the row just disappeared whenever battery_estimate_s
couldn't be computed yet, which looked like the feature was gone.
Now it always shows once there's any battery reading at all, with a
placeholder until enough discharge history accumulates (matches the
"Not enough data yet." wording battery_chart.js already uses for the
same situation on the chart).
2026-07-22 11:35:38 -04:00
tfaour f4d2a23e8a Drop "On battery for", clarify the remaining-estimate label
Build and push server image / build-and-push (push) Successful in 38s
"On battery for" was clutter next to the actual number people care
about. Relabeled "Est. remaining" to "Est. battery life left" and
dropped the now-unused on_battery_since field from the /queue response.

battery_estimate_s itself is unchanged -- it still needs 2h of span and
a 2% drop within the current discharge cycle (reset on any 5%+ jump,
i.e. a recharge or reflash) before it'll show anything. A frame that's
been power-cycled/reflashed recently won't have an estimate yet; that's
expected, not a regression.
2026-07-22 11:29:17 -04:00
tfaour 55b53d5bb2 Fix migration runner crashing on a genuinely fresh database
Build and push server image / build-and-push (push) Successful in 39s
_migration_1() is Base.metadata.create_all() -- it already builds
today's full schema straight from models.py. Every migration after it
is an incremental ALTER/UPDATE meant to bring an *existing* install
forward from an older version; replaying them against a brand-new
database collided with columns create_all had already added ("duplicate
column name"), crashing on first boot.

Found while testing the device-status-bar change against a scratch DB.
Every real deployment has been migrating forward incrementally since
before this bug existed, so it never showed up in practice -- but any
brand-new install would have hit it. Fresh databases now jump straight
to the latest schema_version after create_all; existing databases keep
applying whichever migrations are still pending, same as before.
2026-07-22 10:48:36 -04:00
tfaour 60fcfca4a0 Make the device status card always visible, not just on Stats
Build and push server image / build-and-push (push) Successful in 39s
Moved out of the Stats tab's side column into a new horizontal bar
shared by every frame page (Photos/Configuration/Stats), sitting
between the page title and the tabs so it's on screen regardless of
which tab is active.

_device_status_bar.html is a new partial included via a device_status
block in app_base.html; device_status_bar.js is the fetch/render/poll
logic extracted from frame_stats.js and adapted to a wrapping row of
label/value pairs instead of a stacked list. frame_stats.html's Device
card and now-single-card .side-col are gone -- Battery history and
Lifetime stats just stack directly.
2026-07-22 10:46:05 -04:00
tfaour 996e06e2bc Bump firmware to 1.2.2
Build and release firmware / build-and-release (push) Successful in 1m51s
Read battery once, after the picture is pushed, instead of at boot.
2026-07-22 10:32:46 -04:00
tfaour d324bc4a57 Read battery once, after the picture is pushed, not at boot
Build and push server image / build-and-push (push) Successful in 40s
battery_read_percent() was called once at the very start of boot, before
WiFi even connects, and that value was reused both for the manage-menu
overlay and the server report. Taken right after a reset (e.g. the OTA
reboot that immediately precedes it), the rail may still be settling --
plausible source of noisy jumps in reported battery level.

Now there's a single read, in frame_client_run() right before
report_battery(), after the photo (and manage overlay, if shown) is
already on the panel -- the fetch/display work already done this cycle
is the settle time, no delay to guess. The manage overlay no longer
needs an early local reading at all: it shows the server's last-known
value instead, added to the /frame/photo-info response it already
fetches.
2026-07-22 10:32:20 -04:00
tfaour ac4b57e611 Bump firmware to 1.2.1
Build and release firmware / build-and-release (push) Successful in 1m48s
Captive portal redirect fix.
2026-07-22 09:48:34 -04:00
tfaour 462b558bef Fix captive portal redirect: visible countdown, keep AP up until it finishes
Previously the softAP was torn down (esp_restart) only 1s after sending
the success page, while the page's own redirect timer waited 7s -- so
the AP (and the phone's captive-portal session with it) was gone long
before the redirect could fire. Now the page shows a live 10s countdown
before redirecting, and the device holds the AP up for 11s so the
countdown always completes. Also added a "Redirect now" button for a
phone that's already reconnected to normal WiFi.
2026-07-22 09:45:59 -04:00
tfaour 9f9ad34a40 Fix stray tab whitespace in DEFAULT_PALETTE_RGB
Build and push server image / build-and-push (push) Successful in 41s
2026-07-22 08:46:46 -04:00
tfaour e48ac50ea1 Color/contrast/dithering sliders + before/after render preview
Advanced configuration gains three sliders (PIL ImageEnhance factors
for color/contrast, 0-2, 1=unchanged; a 0-1 dithering strength) applied
to every photo this frame renders. Confirmed the parameter conventions
against a similar project (jwchen119/EPF: ImageEnhance.Color/Contrast,
1.0 baseline) before implementing; dithering strength isn't natively
exposed by PIL's quantize(), so it's implemented by blending the source
toward its own flat/undithered quantization before running Floyd-
Steinberg on the blend -- at 0 there's no quantization error left to
diffuse (exactly the flat result), at 1 it's the original unmodified
behavior, with a smooth continuum between rather than dithering being
an on/off toggle.

image_pipeline.py split into composition (_compose), enhancement
(_enhance), quantization (_quantize), and transpose+pack stages so
render_frame (device bytes) and the new render_preview_png (a normal
viewable PNG, upright logical orientation) share the same pipeline
instead of duplicating it. Named-face overlay label math (face_labels.py)
was already routed through the shared _placement_transform, so it
needed no changes for the new params.

Also added the requested before/after comparison: the Configuration
tab's new Preview card shows the current photo's untouched Immich
preview next to that same photo run through the frame's actual saved
rendering pipeline (two new GET endpoints, /preview/original and
/preview/rendered) -- immediate visual feedback for tuning the palette
and these new sliders. "Refresh preview" re-fetches after saving.

Schema migration v6 adds color_boost/contrast_boost/dither_strength,
defaulting to 1.0/1.0/1.0 -- reproduces the exact previous rendering
until a frame's Configuration tab changes one.

Verified against the live-shaped test database: the migration, sliders
persisting and clamping out-of-range input, both preview endpoints
(real JPEG passthrough / real PNG at correct logical size+orientation),
confirmed dither_strength=0 actually changes the rendered bytes vs.
default, and the standing legacy-device curl suite.
2026-07-22 08:46:22 -04:00
tfaour 83c59af1dd Update server/app/image_pipeline.py
Build and push server image / build-and-push (push) Successful in 46s
Fix pallette defaults
2026-07-22 01:33:56 -04:00
tfaour 49bc9f9ec9 Display mode: crop to fill, crop to faces, stretch to fill, shrink to fit
Build and push server image / build-and-push (push) Successful in 40s
Replaces the smart_crop_faces boolean with a 4-way display_mode select
on each frame's Configuration tab (image_pipeline.DISPLAY_MODES):

- Crop to fill / Crop to faces: the previous False/True behavior,
  unchanged (center-crop trimming excess, optionally shifted to keep
  faces on screen).
- Stretch to fill (new): fills the panel exactly, aspect ratio not
  preserved -- a plain resize, no crop.
- Shrink to fit (new): the whole photo visible, letterboxed with white
  where it doesn't fill the panel.

Named-face overlay label positioning (face_labels.py, the manage menu's
"who's in this photo") now goes through a shared _placement_transform()
in image_pipeline.py instead of duplicating crop-box math, so label
placement stays correct (and in-bounds) under all four modes, not just
the two crop ones -- letterbox/stretch never crop a face out, so labels
just use straight scale+offset math there.

Schema migration v5 adds display_mode, backfills it from the old
boolean (True/False -> crop_faces/crop_fill), and drops the boolean.

Verified against the live-shaped test database: the migration
(existing frames correctly preserved as crop_faces), the config page's
new 4-option select, actual renders under letterbox (confirmed real
white letterbox padding in the packed panel-code bytes) and
stretch_fill, invalid-input fallback, and the standing legacy-device
curl suite.
2026-07-22 01:32:52 -04:00
tfaour e802882fc1 Palette calibration: precise hex/RGB inputs instead of a color picker
Build and push server image / build-and-push (push) Successful in 41s
A native <input type="color"> swatch can't be typed into precisely --
no way to enter an exact measured value. Replaced with a small table:
a read-only preview swatch, a hex text field, and three 0-255 number
fields (R/G/B) per ink color, kept in sync live in both directions
(editing hex updates R/G/B and the swatch; editing any of R/G/B updates
hex and the swatch). Hex stays the field actually read at save time --
the server-side validation (#rrggbb via hex_to_rgb) is unchanged, this
is a client-side-only swap of the input widget.
2026-07-22 01:26:03 -04:00
tfaour 5b11f2accb Per-frame palette calibration + sidebar battery indicator
Build and push server image / build-and-push (push) Successful in 40s
Advanced configuration (Configuration tab, collapsed <details> section):
a color picker per ink color (black/white/yellow/red/blue/green),
overriding image_pipeline.DEFAULT_PALETTE_RGB for that frame's actual
panel -- different units can vary enough from the documented
approximations to be worth calibrating once you can compare a rendered
photo against the real hardware. Stored as Frame.palette_rgb (NULL =
default, schema migration v4), threaded through render_frame/
render_placeholder/_quantize_and_pack (which now builds the PIL palette
image per call instead of once at import) so both photos and the
unclaimed/unconfigured placeholder screen respect it. "Reset to
defaults" clears back to NULL. Config-save validates exactly 6 #rrggbb
values, rejecting anything else with a 400.

Also: each frame's sidebar entry now shows its last-reported battery
percent (🔋NN%) next to the name, using the frame_dot's existing
recently-seen indicator conventions -- silent when never reported
(mains-only frames, or before the first report), matching how battery
is hidden everywhere else it's not applicable.

Verified against the same live-shaped database as the SMTP work: the
v3->v4 migration, save/reload/reset round trip through the real HTTP
route, an actual rendered image using a custom palette (confirmed via
its packed panel-code bytes), input validation, and the sidebar badge
against real battery data -- plus the standing legacy-device curl suite.
2026-07-22 01:19:06 -04:00
tfaour c1c803b497 SMTP: implicit TLS (port 465) support + fix quarantined mail
Build and push server image / build-and-push (push) Successful in 38s
Two real fixes to app/mail.py, both found by testing against an actual
mail server rather than just a fake stub:

- Replaces the STARTTLS-only smtp_use_tls boolean with a three-way
  smtp_encryption ("none"/"starttls"/"ssl"). Implicit TLS (port 465,
  what Purelymail and most providers offer alongside 587/STARTTLS) is a
  different handshake entirely -- TLS from the first byte, not a
  plaintext connection that gets upgraded -- so it needs its own
  smtplib.SMTP_SSL code path, not just a skipped starttls() call.
  Schema migration v3 adds the column, backfills it from the old
  boolean, and drops the boolean (safe on a live, populated DB).

- Outgoing mail was missing Date and Message-ID headers -- email.mime
  doesn't set either automatically, and a missing Message-ID in
  particular is enough for a strict content filter (confirmed via a
  real Postfix+Amavis mail server's logs: SPF/DKIM/DMARC all passed
  cleanly, but Amavis quarantined the message as "BAD-HEADER-0" purely
  for the missing id) to silently swallow an otherwise-legitimate
  email, even though smtplib reports success -- the send genuinely
  succeeds to the relay, it just never survives the recipient's own
  filtering. Both headers are now set, with the Message-ID's domain
  matching the From address.

Verified: SMTP_SSL path against a hand-rolled implicit-TLS fake server
(self-signed cert, client-side verification relaxed only in the test
harness -- production code keeps ssl.create_default_context()'s real
verification), the v2->v3 migration against live data, the full admin
SMTP-save + test-email round trip over HTTP, and the standing legacy-
device curl suite.
2026-07-22 01:07:50 -04:00
tfaour 8e10ca540e Add SMTP email: password reset + per-frame battery-threshold alerts
Build and push server image / build-and-push (push) Successful in 40s
Admin-configured SMTP (server/port/username/password/from address/
STARTTLS, a singleton server_settings row set from /admin -- not env
vars, since it's operator infrastructure a household admin sets up
once through the UI) powers two features, both requiring the relevant
user to have an email set in their own Settings:

- "Forgot password?" on /login emails a one-hour single-use reset link
  (password_reset_tokens table). The endpoint always returns the same
  generic "check your email" response regardless of whether the address
  matched an account, so it can't be used to enumerate registered users.
- A frame's Configuration tab can set a battery-alert threshold
  (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery
  emails the owner the first time a report drops to or below it, then
  stays quiet for the rest of that discharge cycle (battery_alert_sent,
  reset alongside battery_history whenever the existing recharge-jump
  detection fires) -- not once per wake.

New app/mail.py wraps stdlib smtplib (no new dependency); send_email()
never raises, so a broken mail server can't 500 a battery report or a
password-reset request. Schema migration v2 adds users.email and the
two frame columns via ALTER TABLE (safe against the live, already-
populated database) plus the two new tables via the existing
create_all-based migration runner.

Verified against a real (already-migrated, real user/frame data)
database: the v1->v2 migration, admin SMTP config + test-email button,
full forgot/reset-password roundtrip (including single-use token
invalidation and the no-enumeration response), and the battery alert
firing exactly once per crossing against a hand-rolled fake SMTP
server -- all via curl end-to-end, plus the standing legacy-device
curl suite to confirm the device protocol is untouched.
2026-07-22 00:51:54 -04:00
tfaour a45444ab4b Bump firmware to 1.2.0
Build and release firmware / build-and-release (push) Successful in 1m54s
2026-07-22 00:34:07 -04:00
tfaour 8ac3fc0de3 Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.

The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.

All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.

Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
2026-07-21 23:56:18 -04:00
tfaour 683e3881b1 Redesign phase C: claim flow, limited manage page, device protocol
The frame-claiming pipeline, end to end. Firmware: every request now
carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url),
and the captive portal's success page became a redirect that hands the
user's browser to <server>/claim?device_id=... after ~7s -- enough time
for the phone to drop the provisioning AP while the device reboots.
The server pushes a per-frame device token through /frame/config during
a one-time handshake; the firmware persists it to NVS (a dedicated
single-key write that deliberately doesn't reset the connected-once
flag or WiFi cache) and prefers it over the provisioned shared token
from the next request on. Config response buffer grows 256->512. Both
board variants compile clean; new firmware also works against an old
server (which ignores ?id=) and old firmware against this server (the
phase A legacy mapping), so either deploy order survives.

Server: /claim lands the captive-portal redirect -- claim-gated signup
(a valid unclaimed/unregistered device id IS the enrollment invitation),
pending claims for the user-beats-the-frame race (auto-attached at
self-registration, 24h expiry), and a waiting page that refreshes until
the frame checks in. Unclaimed/unconfigured frames get a rendered
instruction placeholder with a QR from /frame/image (200, never an
error loop) -- new qrcode dep, placeholder shares the exact
quantize/pack path photos use.

The on-frame manage QR now resolves to a limited no-login page: scans
of / carrying device credentials (new ?id&token or the legacy shared
token) 303 to /m/<manage_token>, which allows exactly view queue,
show-next, advance, back, and scoped thumbnails -- no settings, no
removal, no other frames. Full control means logging in.

One real protocol hole found by simulating full wake cycles: after
self-registration the device could never authenticate again (the wake
cycle fetches the image BEFORE /frame/config delivers its token).
require_device now treats the id itself as the credential until the
first authenticated request flips device_token_ack -- the same trust
level as open registration, closing permanently once the handshake
completes.
2026-07-21 23:44:22 -04:00
tfaour 1e8d6803ac Redesign phase B: users, sessions, first-run setup, admin panel
Real identity on top of phase A's schema: scrypt-hashed passwords
(stdlib, no new deps -- parameters baked into each stored hash),
server-side sessions (sha256 of the cookie value stored, 30-day rolling
expiry), and per-session CSRF tokens enforced on every mutating
session-authed request -- via X-CSRF-Token for the JSON API (a fetch()
wrapper in base.html injects it, so the existing page scripts didn't
need touching) and a hidden form field for the HTML forms.

/setup runs once while no users exist: creates admin #1, links every
existing frame to them (owner + controller), and inherits the migrated
Immich creds onto their account -- per-user creds are now the primary
source, with env vars still winning as the operator fallback. /login,
/logout, /settings (display name, Immich creds, password change), and
/admin (enroll users, reset passwords, link users to frames, close a
frame's legacy-token window, delete) round out the pages, all in the
existing template/card style.

The legacy shared token stays accepted on browser routes so the
deployed frame's on-panel manage QR keeps working until phase C swaps
it for the limited manage page; token access renders without nav or
CSRF shim and is exempt from CSRF (explicit credential, not an ambient
cookie). Device routes untouched -- the legacy curl suite passes
verbatim.

Identity is provider-pluggable (identity_provider/provider_subject
already modeled) so OIDC can land later without schema surgery.
2026-07-21 23:28:14 -04:00
tfaour 9fbbb8ed2b Redesign phase A: SQLite storage, per-frame data model, device identity
Replaces the single global config.json (whole-file pydantic model under
one RLock) with SQLite via SQLAlchemy 2.0: users/sessions/frames/links/
pending-claims/battery_log tables (models.py), a per-frame lock registry
(db.frame_locked) succeeding config.locked(), and hand-rolled schema
versioning (migration.py). A pre-database deployment's config.json is
imported verbatim as frame #1 on first boot and left untouched as the
rollback path; the old single firmware.bin slot becomes per-frame
firmware/<id>.bin.

Routes split out of the 900-line main.py into routers/device.py (the
frozen /frame/* protocol) and routers/api.py (web UI, still on the old
single-frame paths for now). Device auth moves to require_device, which
already speaks the full multi-frame protocol: per-frame device tokens
pushed via /frame/config and acknowledged on first use, self-
registration of unknown device ids as unclaimed frames, pending-claim
attachment, and the legacy-token migration window that keeps the
currently-deployed firmware (no id, shared MANAGEMENT_TOKEN) resolving
to frame #1 -- including the one-time binding of its device id when it
first reports one after a future OTA.

Externally identical for existing deployments: same paths, same token
semantics, same response shapes -- verified with a migration fixture,
the legacy-device curl suite, a 20-way concurrent-advance smoke test,
and a mutate-restart-assert persistence check against a fake Immich.

photo_queue.py ports nearly verbatim onto the Frame ORM row (MutableList
JSON columns make its in-place list mutations dirty-track); quiet-hours
math extracted unchanged into quiet_hours.py.
2026-07-21 23:21:38 -04:00
tfaour 6a0072e383 Add a "Check now" button to the Firmware update card
Build and push server image / build-and-push (push) Successful in 36s
GET /api/firmware/check's 15-minute throttle meant a genuinely new
Gitea release could sit invisible in the UI for up to that long even
though POST /api/firmware/apply-latest (unthrottled) would've picked
it up immediately. New ?force=true bypasses the throttle for an
explicit check; the button wires it up and surfaces errors instead of
failing silently like the passive poll.
2026-07-21 22:29:05 -04:00
tfaour fdb5dc7ab3 Bump firmware to 1.1.2a (test build)
Build and release firmware / build-and-release (push) Successful in 1m49s
2026-07-21 22:22:04 -04:00
63 changed files with 7598 additions and 3226 deletions
+1
View File
@@ -27,3 +27,4 @@ server/docker-compose.yml
.idea/
*.swp
.DS_Store
.claude/
+18 -11
View File
@@ -121,8 +121,14 @@ two-step setup screen:
The config page asks for your home WiFi SSID/password, the "Tools
Server" address (`host:port` of the [server](../server/) -- **not** your
Immich server; see below for the `https://` form), and an optional
"Access Token" (see below). Saving reboots the device, which then
connects to your home network and starts its normal fetch/sleep cycle.
"Access Token" (see below -- usually blank). Saving hands your browser
off to the server's claim page (after ~7 seconds, giving your phone
time to rejoin its normal WiFi while the device reboots) so the frame
gets linked to your account; the device meanwhile connects to your home
network and starts its normal fetch/sleep cycle. The frame identifies
itself to the server by `?id=` (derived from its WiFi MAC) on every
request, and the server issues it a private per-frame token on first
contact -- no manual token handling involved.
## HTTP vs HTTPS
@@ -179,15 +185,16 @@ perfectly valid cert for a different name.
## Access token
If the server has `MANAGEMENT_TOKEN` set (see
[`server/README.md`](../server/README.md)), it requires that same value
on every request -- the web UI *and* every device-facing request the
frame itself makes. Paste it into the captive portal's "Access Token"
field and the device sends it (`?token=...`) on every request
automatically, and bakes it into the manage-menu/share QR codes so
scanning them just works too. Leave it blank if the server has no
`MANAGEMENT_TOKEN` configured -- the default, unauthenticated-on-a-
trusted-LAN behavior from before.
Usually blank. Current servers issue each frame its own private token
automatically on first contact (delivered via `GET /frame/config`,
persisted in NVS, preferred by `build_url()` from then on -- and baked
into the manage-menu/share QR codes so scanning them just works). The
captive portal's "Access Token" field only matters when pointing this
firmware at an *older* (pre-multi-frame) server whose `MANAGEMENT_TOKEN`
is set: paste that shared value and the device sends it (`?token=...`)
until a newer server replaces it with a per-frame one. Re-provisioning
clears any stored per-frame token -- a fresh identity handshake with
whatever server you point it at next.
## Skipping to the next photo
+1 -1
View File
@@ -1,3 +1,3 @@
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c battery.c ota_update.c board_antenna.c
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
EMBED_FILES root.html)
+30 -7
View File
@@ -1,3 +1,5 @@
#include <stdlib.h>
#include "driver/gpio.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_adc/adc_oneshot.h"
@@ -11,7 +13,13 @@ static const char *TAG = "battery";
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
#define BATTERY_SAMPLES 8
#define BATTERY_SAMPLES 16
/* Trimmed mean: the extreme BATTERY_TRIM samples on each end (regulator/
* RF transients, not the true resting voltage) are dropped before
* averaging the rest -- a plain average lets even one or two of those
* skew the result enough to read as a real percent change downstream
* (see the recharge-jump handling in routers/device.py). */
#define BATTERY_TRIM 3
/* The external divider halves the battery voltage (2x200k, per the
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
* pin, inside the 12dB-attenuation ADC range. */
@@ -34,6 +42,11 @@ static const struct {
{ 3300, 5 }, { 3000, 0 },
};
static int int_cmp(const void *a, const void *b)
{
return *(const int *)a - *(const int *)b;
}
static int mv_to_percent(int mv)
{
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
@@ -139,19 +152,17 @@ int battery_read_percent(void)
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
}
int mv_sum = 0;
int mv_samples[BATTERY_SAMPLES];
int samples = 0;
for (int i = 0; i < BATTERY_SAMPLES; i++) {
int value;
if (calibrated) {
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
mv_sum += value;
samples++;
mv_samples[samples++] = value;
}
} else {
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
samples++;
mv_samples[samples++] = value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
}
}
}
@@ -167,7 +178,19 @@ int battery_read_percent(void)
return -1;
}
int battery_mv = (mv_sum / samples) * BATTERY_DIVIDER_RATIO;
/* Only trim if there's enough left afterward to still be a
* meaningful average -- falls back to a plain average of whatever
* came in on a wake where most reads failed. */
qsort(mv_samples, samples, sizeof(int), int_cmp);
int trim = (samples > 2 * BATTERY_TRIM) ? BATTERY_TRIM : 0;
int mv_sum = 0;
int kept = 0;
for (int i = trim; i < samples - trim; i++) {
mv_sum += mv_samples[i];
kept++;
}
int battery_mv = (mv_sum / kept) * BATTERY_DIVIDER_RATIO;
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
return -1;
+104 -321
View File
@@ -16,10 +16,10 @@
#include "epd7in3e.h"
#include "status_screen.h"
#include "manage_qr_overlay.h"
#include "combo_button.h"
#include "ota_update.h"
#include "board_antenna.h"
#include "battery.h"
#include "frame_client.h"
@@ -99,11 +99,14 @@ static void save_wifi_cache(esp_netif_t *netif)
* normally a bare "host:port", defaulting to plain http; it may instead
* carry an explicit "http://" or "https://" prefix to pick the scheme,
* e.g. "https://frame.example.com" if a reverse proxy is terminating
* TLS in front of the tools server. The token, once the server has
* MANAGEMENT_TOKEN set, is required on every request the server
* receives (device-facing endpoints included, not just the web UI) --
* this is the one chokepoint all of them go through, so every caller
* gets it for free instead of needing to remember to add it. */
* TLS in front of the tools server. Every URL carries ?id= (the device's
* MAC-derived identity -- how a multi-frame server tells frames apart
* and how an unknown frame self-registers) plus &token=: the server-
* issued per-frame device token once one has been delivered via
* /frame/config, else the provisioned access token (the legacy shared
* secret, also what a pre-multi-frame server still expects). This is
* the one chokepoint all requests go through, so every caller gets both
* for free instead of needing to remember to add them. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{
const char *toolsserver = cfg->toolsserver;
@@ -113,8 +116,16 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
} else {
len = (size_t)snprintf(out, out_size, "http://%s/%s", toolsserver, path);
}
if (cfg->access_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
if (len < out_size) {
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
}
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
if (token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", token);
}
}
@@ -266,6 +277,11 @@ typedef struct {
bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
/* Per-frame token the server pushes until this device has
* authenticated with it once; empty when absent. Persisted via
* frame_config_set_device_token() and used by build_url() from the
* next request on. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_server_config_t;
/* Finds the first integer value associated with "key" in a small JSON
@@ -356,6 +372,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
};
result.firmware_version[0] = '\0';
result.device_token[0] = '\0';
char url[256];
build_url(url, sizeof(url), cfg, "frame/config");
@@ -380,7 +397,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
esp_http_client_fetch_headers(client);
result.reachable = true;
char body[256];
/* 512 (was 256): the response also carries "device_token" during the
* one-time identity handshake -- worst case is still well under half
* of this, the rest is headroom for future fields. */
char body[512];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
@@ -400,240 +420,40 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
(int)result.refresh_interval_s);
}
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
return result;
}
/* GETs the server's /frame/photo-info for the manage-button overlay:
* location/taken_at text (left empty if the server didn't have them --
* e.g. no GPS EXIF to geocode, or no capture date) and a share_url built
* from the returned asset_id, same construction pattern as
* run_fetch_cycle()'s management_url. Any failure (unreachable, no
* current photo, etc.) just leaves all outputs empty -- the caller
* treats that as "skip these optional overlay regions", not a hard
* error, since the base "scan to manage" QR should still show. */
static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, size_t location_line1_size,
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
char *share_url, size_t share_url_size)
{
location_line1[0] = '\0';
location_line2[0] = '\0';
taken_at[0] = '\0';
share_url[0] = '\0';
char url[256];
build_url(url, sizeof(url), cfg, "frame/photo-info");
/* CONFIG_FRAME_FETCH_TIMEOUT_MS, not the shorter SERVER_CHECK one:
* unlike fetch_frame_config() (always called after the image fetch
* has already warmed the connection, see frame_client_run()), this
* is the *first* network call of the wake cycle whenever the manage
* menu is opened -- same cold-connection latency spike that made
* the short timeout unreliable for /frame/config before, now worse
* with a real TLS handshake on top. Confirmed on hardware: this
* timed out under CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS while the
* rest of the cycle (a fresh connection, but not the *first* one)
* succeeded fine. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return;
}
char body[384];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
json_extract_string(body, "location_line1", location_line1, location_line1_size);
json_extract_string(body, "location_line2", location_line2, location_line2_size);
json_extract_string(body, "taken_at", taken_at, taken_at_size);
char asset_id[48];
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
char path[80];
snprintf(path, sizeof(path), "frame/share/%s", asset_id);
build_url(share_url, share_url_size, cfg, path);
}
}
/* GETs the server's /frame/face-labels for the manage-button's escalated
* "level 2" menu -- named-face positions, if Immich has any for the
* current photo. Response is a flattened, fixed-slot shape ("count",
* then name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a real JSON
* array, read with the same flat-scalar helpers as everywhere else in
* this file instead of needing an actual array parser. Any failure
* (unreachable, malformed response, etc.) just returns 0 -- named faces
* are a "nice to have" addition to the menu, not worth failing it over. */
static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out, int max_labels)
{
char url[256];
build_url(url, sizeof(url), cfg, "frame/face-labels");
/* Same reasoning as fetch_photo_info() -- this is a manage-menu
* request too, not a warmed-connection reachability check. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return 0;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return 0;
}
char body[768];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
uint32_t count = 0;
json_extract_uint(body, "count", &count);
if ((int)count > max_labels) {
count = (uint32_t)max_labels;
}
int found = 0;
for (uint32_t i = 0; i < count; i++) {
char key[16];
snprintf(key, sizeof(key), "name_%u", (unsigned)i);
if (!json_extract_string(body, key, out[found].name, sizeof(out[found].name))) {
continue;
}
snprintf(key, sizeof(key), "x_%u", (unsigned)i);
uint32_t x;
if (!json_extract_uint(body, key, &x)) {
continue;
}
snprintf(key, sizeof(key), "y_%u", (unsigned)i);
uint32_t y;
if (!json_extract_uint(body, key, &y)) {
continue;
}
out[found].x = (int)x;
out[found].y = (int)y;
found++;
}
return found;
}
typedef struct {
esp_http_client_handle_t client;
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
const manage_overlay_set_t *overlay; /* NULL = no overlay this fetch */
} http_read_ctx_t;
/* Splices one overlay region's pixels over the real photo bytes in chunk
* wherever chunk's absolute byte range [chunk_start, chunk_start+chunk_len)
* within the full frame intersects that region's rectangle. Rows/chunks
* outside the region's footprint are left completely untouched.
* region->x0 is always even (see manage_qr_overlay.h), so byte_x0 below
* is exact. */
static void splice_overlay_region(uint8_t *chunk, size_t chunk_len, size_t chunk_start,
const manage_overlay_region_t *region)
{
int byte_x0 = region->x0 / 2;
int byte_w = region->w / 2;
size_t chunk_end = chunk_start + chunk_len;
for (int row = region->y0; row < region->y0 + region->h; row++) {
size_t row_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0;
size_t row_end = row_start + (size_t)byte_w;
size_t lo = row_start > chunk_start ? row_start : chunk_start;
size_t hi = row_end < chunk_end ? row_end : chunk_end;
if (lo >= hi) {
continue;
}
size_t region_row_offset = (size_t)(row - region->y0) * (size_t)byte_w + (lo - row_start);
memcpy(chunk + (lo - chunk_start), region->buf + region_row_offset, hi - lo);
}
}
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
splice_overlay_region(chunk, chunk_len, chunk_start, &overlay->regions[i]);
}
}
/* Pulls the next chunk straight out of the in-progress HTTP response --
* epd_write_frame() calls this to feed the panel without ever holding
* the full ~192KB frame in RAM. Splices in ctx->overlay's regions (if
* set) as chunks pass through, so the panel driver never needs to know
* an overlay exists at all. */
* the full ~192KB frame in RAM. Just a plain relay: the manage overlay
* (scan-to-manage QR, battery, location/date, share-QR, named face
* labels) is composited server-side now (see server/app/manage_overlay.py),
* baked into the same image bytes as any other render -- this function,
* like the rest of this file, has no idea an overlay exists. */
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_;
int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size);
if (n <= 0) {
return 0;
}
if (ctx->overlay != NULL) {
splice_overlay(chunk, (size_t)n, ctx->stream_pos, ctx->overlay);
}
ctx->stream_pos += (size_t)n;
return (size_t)n;
return n > 0 ? (size_t)n : 0;
}
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
* /frame/back to force a move in either direction (FETCH_ADVANCE /
* FETCH_BACK -- the next-photo / back-photo buttons), and streams the
* response directly into the panel, splicing in overlay's pixels (if
* non-NULL) as it streams. Returning non-ESP_OK means the panel was
* never actually refreshed -- epd_display_stream() (see epd7in3e.c)
* refuses to trigger a physical refresh on a short/wrong-size stream,
* so a failure here always leaves the visible screen exactly as it
* was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action,
const manage_overlay_set_t *overlay)
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the
* manage button) appends &manage=1, telling the server to bake its
* overlay into this same response instead of returning the bare
* content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream()
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
* wrong-size stream, so a failure here always leaves the visible screen
* exactly as it was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action, bool manage)
{
const char *path = "frame/image";
if (action == FETCH_ADVANCE) {
@@ -644,6 +464,12 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
char url[256];
build_url(url, sizeof(url), cfg, path);
if (manage) {
size_t len = strlen(url);
if (len + strlen("&manage=1") < sizeof(url)) {
strcpy(url + len, "&manage=1");
}
}
esp_http_client_config_t config = {
.url = url,
@@ -670,7 +496,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
}
ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);
http_read_ctx_t ctx = { .client = client, .overlay = overlay };
http_read_ctx_t ctx = { .client = client };
uint32_t crc = 0;
err = epd_write_frame(http_read_fn, &ctx, &crc);
@@ -698,8 +524,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
return err;
}
#define MANAGE_MENU_MAX_LEVEL 2
#define MANAGE_MENU_LEVEL_TIMEOUT_MS 30000
#define MANAGE_MENU_TIMEOUT_MS 30000
#define MANAGE_MENU_POLL_MS 150
#define MANAGE_MENU_DEBOUNCE_MS 30
@@ -731,110 +556,42 @@ static bool wait_for_button_press(uint32_t timeout_ms)
return false;
}
/* Builds and shows one level of the manage menu: level 1 is the base
* overlay (management QR + location/date/share-QR wherever the server
* had that data); level 2 adds named-face labels on top. action only
* applies at level 1 -- escalating to level 2 redisplays the same
* photo, so it never re-advances/-backs. */
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level,
int battery_percent)
/* Runs the manage-button view: fetches once with manage=1 (the server
* bakes its whole overlay -- scan-to-manage QR, battery, location/date/
* share-QR, every named face label, no more RAM-driven cap on how many --
* into the response), shows it, then waits up to 30s for either another
* press or the timeout before reverting to a plain fetch. Device stays
* awake throughout (doesn't sleep the panel or the chip). Returns
* non-ESP_OK only if the manage fetch itself failed; a revert failure
* after that is logged but doesn't count as an overall failure --
* something was already shown successfully, which was the point of the
* button. */
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
{
char management_url[256];
build_url(management_url, sizeof(management_url), cfg, "");
char location_line1[32];
char location_line2[32];
char taken_at[32];
/* Wider than the other URL buffers in this file: unlike a fixed path,
* this one stacks toolsserver (up to 128) + "/frame/share/" + an
* asset_id (up to 47) + "?token=" + an access_token (up to 64) --
* worst case ~266 bytes, which a 256-byte buffer could silently
* truncate the token off of (build_url()'s bounds check avoids an
* overflow, but a truncated/dropped token still means the resulting
* request just 401s with no obvious cause). */
char share_url[320];
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
int face_label_count = 0;
if (level >= 2) {
face_label_count = fetch_face_labels(cfg, face_labels, MANAGE_FACE_LABELS_MAX);
}
manage_overlay_content_t content = {
.management_url = management_url,
.location_line1 = location_line1[0] != '\0' ? location_line1 : NULL,
.location_line2 = location_line2[0] != '\0' ? location_line2 : NULL,
.taken_at = taken_at[0] != '\0' ? taken_at : NULL,
.share_url = share_url[0] != '\0' ? share_url : NULL,
.face_labels = face_labels,
.face_label_count = face_label_count,
.battery_percent = battery_percent,
};
manage_overlay_set_t overlay;
esp_err_t err = manage_overlay_render(&content, &overlay);
esp_err_t err = fetch_and_display(cfg, action, true);
if (err != ESP_OK) {
manage_overlay_free(&overlay);
return err;
ESP_LOGW(TAG, "Could not fetch manage view (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, false);
}
err = fetch_and_display(cfg, action, &overlay);
manage_overlay_free(&overlay);
return err;
}
ESP_LOGI(TAG, "Showing manage view, waiting up to 30s");
wait_for_button_press(MANAGE_MENU_TIMEOUT_MS);
/* Runs the manage-button menu: level 1 (the base overlay) shows first;
* from there, each further press within 30s escalates one level (up to
* MANAGE_MENU_MAX_LEVEL, which adds named-face labels), and a press once
* already at the max level exits immediately instead of escalating
* further. A 30s timeout at any level also exits. Device stays awake
* throughout (doesn't sleep the panel or the chip). Returns non-ESP_OK
* only if the very first (level 1) render/fetch failed; failures after
* that (escalating, or the final revert) are logged but don't count as
* an overall failure -- something was already shown successfully, which
* was the point of the button. */
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action, int battery_percent)
{
int level = 1;
esp_err_t err = show_menu_level(cfg, action, level, battery_percent);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, NULL);
}
for (;;) {
ESP_LOGI(TAG, "Showing management menu level %d, waiting up to 30s", level);
bool pressed = wait_for_button_press(MANAGE_MENU_LEVEL_TIMEOUT_MS);
if (!pressed || level >= MANAGE_MENU_MAX_LEVEL) {
break; /* timeout at any level, or a press while already maxed out -- exit */
}
level++;
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level, battery_percent);
if (level_err != ESP_OK) {
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
break;
}
}
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, NULL);
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, false);
if (revert_err != ESP_OK) {
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
ESP_LOGW(TAG, "Failed to revert manage view (%s)", esp_err_to_name(revert_err));
}
return ESP_OK;
}
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- the escalating manage menu (see
* run_management_menu()). */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
int battery_percent)
* show_management_qr -- the manage view (see run_management_menu()). */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
if (!show_management_qr) {
return fetch_and_display(cfg, action, NULL);
return fetch_and_display(cfg, action, false);
}
return run_management_menu(cfg, action, battery_percent);
return run_management_menu(cfg, action);
}
/* Reports the battery percent to the server (POST /frame/battery).
@@ -878,8 +635,7 @@ static void report_battery(const frame_config_t *cfg, int percent)
esp_http_client_cleanup(client);
}
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
int battery_percent)
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
esp_err_t epd_err = epd_init();
bool have_display = (epd_err == ESP_OK);
@@ -913,7 +669,7 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
* worth it to stop false-failing on the common case. */
bool image_ok = true;
if (have_display) {
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr, battery_percent);
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
image_ok = (fetch_err == ESP_OK);
if (!image_ok) {
/* epd_display_stream() never triggers a physical refresh on a
@@ -948,10 +704,37 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
* normal boot, not just the one right after an update). */
esp_ota_mark_app_valid_cancel_rollback();
/* Read now, not at boot: the photo (and, if shown, the manage
* overlay -- entirely server-composited now, using the server's
* own last-known battery value, not a local reading, see
* server/app/manage_overlay.py) is already on the panel, so
* there's no display deadline to beat.
* Reading here instead of right after waking sidesteps taking the
* ADC sample while the rail's still settling from whatever the
* boot/reset just did, with no need to guess a settle delay --
* the fetch/display work already done this cycle is the delay.
* Still safe re: the battery/button pin sharing (battery.h) --
* every button check main.c does happens well before this, at
* the very start of boot. */
int battery_percent = battery_read_percent();
report_battery(cfg, battery_percent);
frame_server_config_t server_cfg = fetch_frame_config(cfg);
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
/* One-time identity handshake: the server pushes this frame's
* own token until we've authenticated with it once. Persist it
* and use it immediately (the OTA below is part of this same
* cycle) via a local working copy -- cfg itself is const. */
frame_config_t updated_cfg;
if (server_cfg.device_token[0] != '\0' &&
strcmp(server_cfg.device_token, cfg->device_token) != 0) {
frame_config_set_device_token(server_cfg.device_token);
updated_cfg = *cfg;
snprintf(updated_cfg.device_token, sizeof(updated_cfg.device_token), "%s",
server_cfg.device_token);
cfg = &updated_cfg;
}
/* Last, deliberately -- the photo's already on screen and the
* battery report already sent, so a reboot here (whether OTA
* succeeds or the device is mid-update) never loses either. */
+12 -9
View File
@@ -33,14 +33,17 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
* deep-sleep until the next refresh.
*
* If show_management_qr is true (the manage button was held), the
* displayed photo gets a small "scan to manage" QR overlay in the
* top-right corner linking to the server's config page, held for 30
* seconds (the device stays awake), then reverted back to the plain
* photo before proceeding to the normal sleep-interval logic.
* request for that cycle carries &manage=1, and the server bakes its
* whole manage overlay (scan-to-manage QR, battery, location/date,
* share-QR, named face labels) directly into the image it returns --
* see server/app/manage_overlay.py; this device is otherwise unaware
* any of that exists, it just displays whatever comes back. Held for 30
* seconds (the device stays awake), then reverted back to a plain fetch
* before proceeding to the normal sleep-interval logic.
*
* battery_percent (0-100, or -1 for "no reading" -- see
* battery_read_percent()) is shown on the management menu overlay and
* reported to the server after a successful fetch; -1 skips both.
* Reads the battery (see battery_read_percent()) itself, once, after the
* photo is already on the panel, and reports it to the server on a
* successful fetch; a -1 reading ("no reading" -- on mains, disabled, or
* implausible) skips the report.
*/
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
int battery_percent);
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
+1 -8
View File
@@ -10,7 +10,6 @@
#include "next_button.h"
#include "back_button.h"
#include "combo_button.h"
#include "battery.h"
static const char *TAG = "main";
@@ -52,18 +51,12 @@ void app_main(void)
* for "not pressed" (false) or "quick press" (true, show the menu). */
bool show_management_qr = combo_button_check();
/* Must come after the button checks: the battery pin is (by design,
* on the XIAO board) shared with a button, and the ADC read briefly
* takes the pin over -- see battery.h. -1 = no reading (disabled,
* on mains, or implausible). */
int battery_percent = battery_read_percent();
frame_config_t cfg;
esp_err_t cfg_err = frame_config_load(&cfg);
if (cfg_err == ESP_OK) {
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
frame_client_run(&cfg, action, show_management_qr, battery_percent);
frame_client_run(&cfg, action, show_management_qr);
return; /* frame_client_run currently never returns */
}
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
-356
View File
@@ -1,356 +0,0 @@
#include <stdlib.h>
#include <string.h>
#include "esp_check.h"
#include "epd7in3e.h"
#include "epd_draw.h"
#include "fonts.h"
#include "qrcodegen.h"
#include "manage_qr_overlay.h"
static const char *TAG = "manage_qr_overlay";
#define QR_MAX_VERSION 10
#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION)
/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- these are compact
* corner popups, not a full-screen setup step. */
#define QR_MODULE_PX 4
#define PADDING 16
#define QR_TEXT_GAP 8
#define LINE_GAP 4
/* Distance from the panel's edges to each overlay box. Combined with
* EPD_WIDTH/EPD_HEIGHT and each region's forced-even width below, this
* guarantees x0 is always even -- required so a region's columns land on
* frame byte boundaries (2px/byte) when spliced into the fetch stream. */
#define PANEL_MARGIN 20
typedef enum {
CORNER_TOP_LEFT,
CORNER_TOP_RIGHT,
CORNER_BOTTOM_LEFT,
CORNER_BOTTOM_RIGHT,
} overlay_corner_t;
static void position_region(manage_overlay_region_t *region, overlay_corner_t corner)
{
switch (corner) {
case CORNER_TOP_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = PANEL_MARGIN;
break;
case CORNER_TOP_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = PANEL_MARGIN;
break;
case CORNER_BOTTOM_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
case CORNER_BOTTOM_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
}
}
static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x,
int origin_y)
{
int size = qrcodegen_getSize(qrcode);
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
epd_color_t color = qrcodegen_getModule(qrcode, x, y) ? EPD_COLOR_BLACK : EPD_COLOR_WHITE;
for (int dy = 0; dy < QR_MODULE_PX; dy++) {
for (int dx = 0; dx < QR_MODULE_PX; dx++) {
epd_draw_pixel_ex(buf, stride, width, height, origin_x + x * QR_MODULE_PX + dx,
origin_y + y * QR_MODULE_PX + dy, color);
}
}
}
}
}
/* White-padded box with a QR code encoding payload, plus zero, one, or
* two centered caption lines beneath it (either may be NULL). Used for
* both the top-right "scan to manage" box (two lines) and the
* bottom-left share-link box (no lines). */
static esp_err_t render_qr_region(const char *payload, const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
uint8_t temp_buffer[QR_BUFFER_LEN];
uint8_t qrcode[QR_BUFFER_LEN];
bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
QR_MAX_VERSION, qrcodegen_Mask_AUTO, true);
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", payload);
int qr_size = qrcodegen_getSize(qrcode);
int qr_px = qr_size * QR_MODULE_PX;
/* Font24 (32x41px uppercase glyphs) is the only font vendored into
* this project -- see components/epaper_fonts. "SCAN TO MANAGE" on
* one line would be 448px wide, too wide for a compact corner box,
* so it's passed in pre-wrapped across two lines instead. */
int text_w = 0;
int text_h = 0;
if (line1 != NULL) {
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
text_w = w1 > w2 ? w1 : w2;
text_h = QR_TEXT_GAP + Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
}
int content_w = qr_px > text_w ? qr_px : text_w;
int content_h = qr_px + text_h;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
w += w % 2; /* keep byte-aligned (2px/byte) */
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y);
y += qr_px;
if (line1 != NULL) {
y += QR_TEXT_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
y += Font24.Height;
}
if (line2 != NULL) {
y += LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
/* White-padded box with one or two centered lines of text (line2 may be
* NULL) -- used for the top-left location (city + state/country, two
* lines rather than cramming both onto one to keep the box from
* threatening to overlap the top-right QR box) and the bottom-right
* date-taken label (one line). */
static esp_err_t render_text_region(const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
int text_w = w1 > w2 ? w1 : w2;
int text_h = Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
int w = text_w + PADDING * 2;
int h = text_h + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
if (line2 != NULL) {
y += Font24.Height + LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
/* Deliberately tighter than PADDING (used for the fixed QR/text corner
* boxes) -- these labels sit right next to a face rather than needing
* generous QR-scanning margin, and there can be several of them
* simultaneously (see MANAGE_FACE_LABELS_MAX's memory-budget note in
* the header). */
#define FACE_LABEL_PADDING 8
#define FACE_LABEL_GAP 4 /* distance from the face's anchor point to the label box */
/* White-padded single-line name label positioned near an arbitrary
* (anchor_x, anchor_y) face position, rather than a fixed corner --
* unlike the four corner regions (always in-bounds by construction),
* this needs real clamping since a face can be anywhere, including near
* an edge. Centered horizontally on the face, placed just below it by
* default, flipped above if there's no room below. */
static esp_err_t render_face_label_region(const char *name, int anchor_x, int anchor_y, manage_overlay_region_t *out)
{
int text_w = (int)strlen(name) * Font24.Width;
int w = text_w + FACE_LABEL_PADDING * 2;
int h = Font24.Height + FACE_LABEL_PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, name, w / 2, FACE_LABEL_PADDING);
int x0 = anchor_x - w / 2;
int y0 = anchor_y + FACE_LABEL_GAP;
if (y0 + h > EPD_HEIGHT) {
y0 = anchor_y - FACE_LABEL_GAP - h; /* no room below -- place above the face instead */
}
if (x0 < 0) {
x0 = 0;
} else if (x0 + w > EPD_WIDTH) {
x0 = EPD_WIDTH - w;
}
if (y0 < 0) {
y0 = 0;
} else if (y0 + h > EPD_HEIGHT) {
y0 = EPD_HEIGHT - h;
}
x0 -= x0 % 2; /* keep byte-aligned (2px/byte) */
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = x0;
out->y0 = y0;
return ESP_OK;
}
/* Battery glyph dimensions -- a static outline (body rectangle + small
* terminal nub on the right), deliberately NOT a fill-level graphic. */
#define BATTERY_ICON_W 44
#define BATTERY_ICON_H 24
#define BATTERY_ICON_STROKE 2
#define BATTERY_NUB_W 6
#define BATTERY_NUB_H 12
#define BATTERY_ICON_TEXT_GAP 8
#define BATTERY_REGION_GAP 8 /* vertical gap below the manage QR box */
static void draw_battery_icon(uint8_t *buf, int stride, int width, int height, int x0, int y0)
{
for (int y = 0; y < BATTERY_ICON_H; y++) {
for (int x = 0; x < BATTERY_ICON_W; x++) {
bool edge = x < BATTERY_ICON_STROKE || x >= BATTERY_ICON_W - BATTERY_ICON_STROKE ||
y < BATTERY_ICON_STROKE || y >= BATTERY_ICON_H - BATTERY_ICON_STROKE;
if (edge) {
epd_draw_pixel_ex(buf, stride, width, height, x0 + x, y0 + y, EPD_COLOR_BLACK);
}
}
}
int nub_y = y0 + (BATTERY_ICON_H - BATTERY_NUB_H) / 2;
for (int y = 0; y < BATTERY_NUB_H; y++) {
for (int x = 0; x < BATTERY_NUB_W; x++) {
epd_draw_pixel_ex(buf, stride, width, height, x0 + BATTERY_ICON_W + x, nub_y + y, EPD_COLOR_BLACK);
}
}
}
/* White-padded box with the battery glyph and "NN%" beside it, placed
* directly below an already-positioned anchor region (the top-right
* manage QR box), right-aligned to the anchor's right edge. */
static esp_err_t render_battery_region(int percent, const manage_overlay_region_t *anchor,
manage_overlay_region_t *out)
{
char text[8];
snprintf(text, sizeof(text), "%d%%", percent);
int icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W;
int text_w = (int)strlen(text) * Font24.Width;
int content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w;
int content_h = Font24.Height > BATTERY_ICON_H ? Font24.Height : BATTERY_ICON_H;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
draw_battery_icon(buf, stride, w, h, PADDING, PADDING + (content_h - BATTERY_ICON_H) / 2);
epd_draw_text_ex(buf, stride, w, h, &Font24, text, PADDING + icon_total_w + BATTERY_ICON_TEXT_GAP,
PADDING + (content_h - Font24.Height) / 2);
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = anchor->x0 + anchor->w - w;
out->x0 -= out->x0 % 2; /* keep byte-aligned (2px/byte) */
out->y0 = anchor->y0 + anchor->h + BATTERY_REGION_GAP;
return ESP_OK;
}
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
{
out->count = 0;
esp_err_t err = render_qr_region(content->management_url, "SCAN TO", "MANAGE", CORNER_TOP_RIGHT,
&out->regions[out->count]);
if (err != ESP_OK) {
return err;
}
out->count++;
if (content->battery_percent >= 0 && content->battery_percent <= 100) {
/* Anchored below the manage QR box just rendered (regions[0]). */
if (render_battery_region(content->battery_percent, &out->regions[0], &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
if (content->location_line1 != NULL && content->location_line1[0] != '\0') {
const char *line2 =
(content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL;
if (render_text_region(content->location_line1, line2, CORNER_TOP_LEFT, &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
if (content->taken_at != NULL && content->taken_at[0] != '\0') {
if (render_text_region(content->taken_at, NULL, CORNER_BOTTOM_RIGHT, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
if (content->share_url != NULL && content->share_url[0] != '\0') {
if (render_qr_region(content->share_url, "SCAN TO", "DOWNLOAD", CORNER_BOTTOM_LEFT,
&out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
int face_count = content->face_label_count;
if (face_count > MANAGE_FACE_LABELS_MAX) {
face_count = MANAGE_FACE_LABELS_MAX;
}
for (int i = 0; content->face_labels != NULL && i < face_count; i++) {
const manage_face_label_t *label = &content->face_labels[i];
if (label->name[0] == '\0') {
continue;
}
if (render_face_label_region(label->name, label->x, label->y, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
return ESP_OK;
}
void manage_overlay_free(manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
free(overlay->regions[i].buf);
overlay->regions[i].buf = NULL;
}
overlay->count = 0;
}
-61
View File
@@ -1,61 +0,0 @@
#pragma once
#include <stdint.h>
#include "esp_err.h"
/* 5 fixed regions (manage QR, battery indicator, location, date, share
* QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face
* labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is
* capped small deliberately, not arbitrarily -- each label is its own
* malloc'd buffer, and the fixed regions alone already use a meaningful
* chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay
* memory well clear of what the WiFi/HTTP stack needs alongside it. */
#define MANAGE_FACE_LABELS_MAX 4
#define MANAGE_OVERLAY_MAX_REGIONS (5 + MANAGE_FACE_LABELS_MAX)
typedef struct {
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */
int x0, y0; /* top-left corner, panel pixel coordinates (x0 is always even) */
int w, h; /* pixel dimensions (w is always even) */
} manage_overlay_region_t;
typedef struct {
manage_overlay_region_t regions[MANAGE_OVERLAY_MAX_REGIONS];
int count;
} manage_overlay_set_t;
typedef struct {
char name[16];
int x, y; /* anchor point (bottom-center of the face), panel pixel coordinates */
} manage_face_label_t;
typedef struct {
const char *management_url; /* top-right QR + "SCAN TO"/"MANAGE" caption -- always shown */
const char *location_line1; /* top-left text, line 1 (city); NULL/empty skips this region */
const char *location_line2; /* top-left text, line 2 (state/country); NULL/empty is fine if line1 is set */
const char *taken_at; /* bottom-right text; NULL/empty skips this region */
const char *share_url; /* bottom-left QR + "SCAN TO"/"DOWNLOAD" caption; NULL/empty skips this region */
const manage_face_label_t *face_labels; /* named-face labels ("level 2" menu); NULL/empty count skips these */
int face_label_count; /* clamped to MANAGE_FACE_LABELS_MAX internally */
int battery_percent; /* 0-100 shows an icon + percent below the manage QR; -1 skips it */
} manage_overlay_content_t;
/**
* Renders the manage-button overlay: always a "scan to manage" QR in the
* top-right corner, plus whichever of location_line1/taken_at/share_url
* are non-NULL/non-empty in their own corners (top-left, bottom-right,
* bottom-left respectively), plus one region per entry in face_labels
* (positioned near that face rather than a fixed corner -- see
* render_face_label_region() in the .c file for the clamping logic).
* Each region is its own separately malloc'd small buffer (not a full
* EPD_FRAME_BYTES frame). A failure rendering the top-right region fails
* the whole call; a failure rendering any other region just skips that
* region and keeps going. Caller must call manage_overlay_free() on out
* regardless of the return value (out->count reflects however many
* regions were actually populated).
*/
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out);
/** Frees every populated region's buffer in overlay. */
void manage_overlay_free(manage_overlay_set_t *overlay);
+11 -3
View File
@@ -17,7 +17,7 @@ static const char *TAG = "ota_update";
#define OTA_HTTP_TIMEOUT_MS 30000
/* Built the same way as every other tools-server URL -- scheme/cert/
* token handling all come from build_url()'s conventions. Duplicated
* id/token handling all come from build_url()'s conventions. Duplicated
* tiny helper rather than exporting frame_client.c's static build_url();
* kept byte-identical in behavior (see frame_client.c). */
static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
@@ -29,8 +29,16 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
} else {
len = (size_t)snprintf(out, out_size, "http://%s/frame/firmware", toolsserver);
}
if (cfg->access_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
if (len < out_size) {
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
}
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
if (token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", token);
}
}
+6 -2
View File
@@ -98,10 +98,14 @@
</div>
<div class="input-group">
<label for="access_token">Access Token (optional)</label>
<input type="text" id="access_token" name="access_token" placeholder="only if the server's MANAGEMENT_TOKEN is set" maxlength="64">
<label for="access_token">Access Token (optional &mdash; only for older servers)</label>
<input type="text" id="access_token" name="access_token" placeholder="usually blank; current servers issue one automatically" maxlength="64">
</div>
<p style="font-size: 13px; color: #555;">After saving, this page will
take you to the server to claim your frame &mdash; reconnect to
your normal WiFi if it doesn't happen automatically.</p>
<button type="submit">Submit</button>
</form>
+86 -4
View File
@@ -83,10 +83,39 @@ esp_err_t frame_config_load(frame_config_t *out)
return token_err;
}
/* Optional: absent until the server has pushed a per-frame token
* (see frame_config_set_device_token). */
len = sizeof(out->device_token);
token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle);
return token_err;
}
nvs_close(handle);
return ESP_OK;
}
void frame_device_id_get(char *out, size_t out_size)
{
uint8_t mac[6] = {0};
ESP_ERROR_CHECK(esp_read_mac(mac, ESP_MAC_WIFI_STA));
snprintf(out, out_size, "%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
void frame_config_set_device_token(const char *token)
{
nvs_handle_t handle;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
return;
}
nvs_set_str(handle, "device_token", token);
nvs_commit(handle);
nvs_close(handle);
ESP_LOGI(TAG, "Stored server-issued device token");
}
esp_err_t frame_config_save(const frame_config_t *cfg)
{
nvs_handle_t handle;
@@ -106,6 +135,10 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
err = nvs_set_str(handle, "access_token", cfg->access_token);
}
if (err == ESP_OK) {
/* Re-provisioning restarts the identity handshake: the server
* (possibly a different one now) re-issues a device token when
* the frame next introduces itself. */
nvs_erase_key(handle, "device_token");
/* Fresh (re)provisioning -- the next successful connection should
* show the status screen again. */
err = nvs_set_u8(handle, "connected_once", 0);
@@ -159,6 +192,7 @@ void frame_config_clear(void)
nvs_erase_key(handle, "sta_pass");
nvs_erase_key(handle, "toolsserver");
nvs_erase_key(handle, "access_token");
nvs_erase_key(handle, "device_token");
nvs_erase_key(handle, "connected_once");
nvs_commit(handle);
nvs_close(handle);
@@ -412,15 +446,63 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
strlen(cfg.access_token) ? "set" : "none");
static const char resp[] =
"<html><body><h3>Saved. Restarting and connecting to your WiFi...</h3></body></html>";
/* The success page hands the browser off to the server's claim page,
* carrying this device's id -- how a frame gets linked to a user
* account. This page is entirely self-contained (no external
* resources) so it renders fully from what we send now, before the
* softAP goes away -- a phone mid-load of a remote asset would just
* time out once the AP drops. The visible countdown ticks down for
* PROVISIONING_COUNTDOWN_S seconds and then redirects; the AP is kept
* alive for one second longer than that (see the vTaskDelay below) so
* the countdown always finishes, and the phone has that whole window
* to rejoin its normal WiFi and let the redirect land on the real
* server. "Redirect now" covers a phone that's already reconnected.
* Scheme handling matches frame_client.c's build_url(): a bare host
* gets http://. */
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
char claim_url[FRAME_CFG_SERVER_MAX_LEN + 64];
const char *scheme = "";
if (strncmp(cfg.toolsserver, "http://", 7) != 0 && strncmp(cfg.toolsserver, "https://", 8) != 0) {
scheme = "http://";
}
snprintf(claim_url, sizeof(claim_url), "%s%s/claim?device_id=%s", scheme, cfg.toolsserver, device_id);
#define PROVISIONING_COUNTDOWN_S 10
char resp[1536];
snprintf(resp, sizeof(resp),
"<!doctype html><html><head>"
"<meta http-equiv=\"refresh\" content=\"%d;url=%s\">"
"<style>body{font-family:sans-serif;text-align:center;padding:2em}"
"#now{display:inline-block;margin-top:1em;padding:.6em 1.2em;"
"background:#2563eb;color:#fff;text-decoration:none;border-radius:8px}</style></head>"
"<body><h3>Saved &mdash; the frame is restarting</h3>"
"<p>Reconnect to your normal WiFi if it doesn't happen automatically.</p>"
"<p>Redirecting you in <span id=\"n\">%d</span> seconds&hellip;</p>"
"<p><a id=\"now\" href=\"%s\">Redirect now</a></p>"
"<script>"
"var n=%d,e=document.getElementById('n');"
"var t=setInterval(function(){"
"n--;if(e)e.textContent=n;"
"if(n<=0){clearInterval(t);location.href='%s';}"
"},1000);"
"</script>"
"</body></html>",
PROVISIONING_COUNTDOWN_S, claim_url, PROVISIONING_COUNTDOWN_S, claim_url,
PROVISIONING_COUNTDOWN_S, claim_url);
httpd_resp_set_type(req, "text/html");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
/* Let the response flush to the client before rebooting into STA mode. */
vTaskDelay(pdMS_TO_TICKS(1000));
/* Keep the softAP up for the full visible countdown (plus a 1s margin
* for the response to flush and the JS timer to fire) before tearing
* it down -- see the comment above for why. */
vTaskDelay(pdMS_TO_TICKS((PROVISIONING_COUNTDOWN_S + 1) * 1000));
esp_restart();
#undef PROVISIONING_COUNTDOWN_S
return ESP_OK;
}
+25 -1
View File
@@ -11,13 +11,37 @@
#define FRAME_CFG_TOKEN_MAX_LEN 64
#define FRAME_AP_PASSWORD_LEN 10
#define FRAME_DEVICE_ID_LEN 12 /* 6-byte STA MAC as lowercase hex */
typedef struct {
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1];
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; matches the server's MANAGEMENT_TOKEN */
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; legacy shared MANAGEMENT_TOKEN */
/* Per-frame token issued by the server via GET /frame/config after
* this device first introduces itself by id -- preferred over
* access_token once present (see frame_client.c's build_url). Not
* set at the captive portal; empty until the server pushes one. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_config_t;
/**
* This device's stable identity as reported to the server (?id= on every
* request): the full 6-byte STA MAC as 12 lowercase hex chars. Derived
* from the same MAC the provisioning AP SSID suffix comes from; never
* stored. out must hold at least FRAME_DEVICE_ID_LEN + 1 bytes.
*/
void frame_device_id_get(char *out, size_t out_size);
/**
* Persists (only) the server-issued per-frame device token -- called
* from the wake cycle when GET /frame/config delivers one. Deliberately
* touches nothing else: unlike frame_config_save() it must not reset
* the connected-once flag or invalidate the WiFi fast-connect cache,
* since nothing about the network changed.
*/
void frame_config_set_device_token(const char *token);
/**
* Loads the saved home-network config from NVS.
* Returns ESP_ERR_NVS_NOT_FOUND if the device has never been provisioned.
+1 -1
View File
@@ -1 +1 @@
1.1.2
1.3.0
+189 -216
View File
@@ -8,225 +8,189 @@ algorithm itself -- it just streams the response straight to the panel.
## Setup
1. **Get an Immich API key**: in Immich, go to Account Settings -> API Keys
-> New API Key. Needs read access to albums/assets/faces, plus
`sharedLink.create` (for the manage overlay's "scan to download" QR,
which creates a temporary public share link) -- a plain read-only key
will 403 on that one specific feature while everything else works.
2. **Copy the compose file and fill in your Immich details**:
1. **Copy the compose file and run the server**:
```
cp docker-compose.yml.example docker-compose.yml
```
Edit `docker-compose.yml` and set `IMMICH_URL`/`IMMICH_API_KEY` under
`environment:`. `docker-compose.yml` is gitignored (it'll hold your real
API key) -- `docker-compose.yml.example` is the one that's committed.
3. **Run the server**:
```
docker compose up -d
```
4. Open `http://<this-machine>:8420/` in a browser, click **Load Albums**,
pick one, and **Save**. (The Immich URL/API key fields will already be
populated from the environment; changing them in the UI has no effect
as long as the env vars are set -- they win on every load.)
5. On the ESP32's captive portal setup form, set the **Tools Server** field
to `<this-machine>:8420`. This server always speaks plain HTTP itself --
for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of
it and enter the proxy's `https://` address instead (see
`firmware/README.md`'s HTTPS section for what the ESP32 side needs).
6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to gate
the *entire server* -- the web UI (`/`, `/api/*`) and every
device-facing `/frame/*` endpoint -- behind a shared secret (leave
unset to keep it all open, the previous default -- fine on a trusted
LAN). If set, paste the same value into the ESP32's captive portal
setup form's **Access Token** field: the device then sends it on
every request it makes, and the manage-menu/share QR codes embed it
automatically (`?token=...`) so scanning them just works. Visiting
the web UI without a valid token in the URL shows a plain token-entry
prompt instead of the config UI; `/health` stays open regardless
(pure liveness, nothing sensitive in it).
7. **Optional: auto-update firmware from Gitea releases.** If you're
2. **First-run setup**: open `http://<this-machine>:8420/` -- you'll be
walked through creating the admin account. Every user has their own
login; the admin can enroll more from the Admin page (family members
can also self-enroll through the frame-claim flow, below).
3. **Connect your Immich library** (per user, in Settings): your Immich
URL and an API key. The key needs read access to
albums/assets/faces, plus `sharedLink.create` (for the on-frame
"scan to download" QR, which creates a temporary public share link)
-- a plain read-only key will 403 on that one feature while
everything else works. Frames you own pull from *your* library.
(`IMMICH_URL`/`IMMICH_API_KEY` env vars in `docker-compose.yml` still
work as an operator-level fallback and seed the first admin's
settings when migrating an older deployment.)
4. **Provision a frame**: power it on, join its `ESPRESSO_XXXXXX` WiFi
(instructions show on the panel), fill in your WiFi details and this
server's address (**Tools Server**, e.g. `<this-machine>:8420`).
After saving, your browser is redirected to this server's claim page
and the frame links to your account -- creating an account on the
spot if you don't have one (a valid frame is the invitation). The
server speaks plain HTTP itself -- for HTTPS, put a TLS-terminating
reverse proxy in front and enter the proxy's `https://` address
instead (see `firmware/README.md`'s HTTPS section).
5. **Each frame gets its own device token automatically** -- the server
issues it on the frame's first check-in, so there's nothing to
configure. The captive portal's **Access Token** field only matters
when pointing new firmware at an old (pre-multi-frame) server.
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the
*migration* credential: a frame flashed with pre-multi-frame
firmware authenticates with it until it's updated and bound (the
Admin page shows the migration state per frame and a "Close legacy
window" button for when it's done).
6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt`
changes on `main`. In the web UI's "Firmware update" card, set the
**Gitea repo URL** to that repo (e.g. `https://git.example.com/owner/repo`);
if the repo is private, also set `GITEA_FIRMWARE_TOKEN` (a read-only
PAT) in `docker-compose.yml`. Which board's build to fetch is learned
from the frame itself (its `X-Frame-Board` header, `CONFIG_FRAME_BOARD_NAME`
on the firmware side) -- nothing to pick by hand, though the frame
does need to have checked in at least once first. The server then
changes on `main`. In a frame's **Configuration** tab, set the
**Gitea repo URL**; if the repo is private, also set
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`.
Which board's build to fetch is learned from the frame itself (its
`X-Frame-Board` header) -- nothing to pick by hand. The server then
periodically checks for a newer release and either shows an "Update
frame" button or, with **Automatically apply updates** checked,
stages it itself -- either way the frame only actually updates on its
own next wake (see `POST /api/firmware` above).
stages it itself -- either way the frame only actually updates on
its own next wake.
## Users, frames, and control
- **Users** log in with a session cookie; passwords are scrypt-hashed;
mutating requests are CSRF-protected. Sign-up paths: first-run setup
(admin #1), admin enrollment (Admin page), or the claim flow (a valid
unclaimed frame's `device_id` gates self-service signup).
- **Frames** identify themselves by `?id=` (MAC-derived) on every
request and authenticate with a per-frame device token the server
issues at first check-in. Unknown frames self-register as unclaimed;
claiming (via `/claim?device_id=...`) sets the owner -- whose Immich
library the frame renders from -- and links the account. Admins can
link additional users to any frame; every linked user sees it in
their sidebar.
- **Control** is a soft lock per frame: everyone linked can *view*;
changing settings/queue requires holding control, and "Take control"
always succeeds (the 409 error names the current holder). The
physical buttons on the frame ignore all of this.
- **The on-frame manage QR** opens a limited no-login page (`/m/<token>`):
view current + upcoming, "show next", advance, back -- nothing else.
The share QR stays public (it creates a 30-minute Immich share link
for exactly the photo shown).
- **Email (optional).** An admin sets an SMTP server once (`/admin` --
server, port, username/password, from address, STARTTLS on/off; a
"send test email to myself" button, delivered to the admin's own
email); each user sets their own email in Settings. Once both are in
place: **"Forgot password?"** on the login page emails a one-hour
reset link (a generic "check your email" response either way, so the
endpoint can't be used to enumerate accounts), and a frame's
Configuration tab can set a **battery-alert threshold** -- an email to
the frame's owner the first time a report drops to or below it, not
again until a recharge is detected and it crosses again. No SMTP
configured, or no email on the relevant account, and both features
silently no-op rather than erroring.
## Endpoints
- `GET /` -- config UI (album, order, refresh interval, face-aware crop
toggle, upcoming-photos count, now-displaying + drag-to-reorder
upcoming grid -- not Immich URL/API key, see Setup above)
- `GET /api/albums` -- lists Immich albums (used by the config UI)
- `POST /api/config` -- saves
album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len/quiet_hours_*/timezone.
`orientation` (`landscape`, `portrait`, `landscape_flipped`,
`portrait_flipped`) matches how the frame is physically hung: photos
are composed/cropped for that shape (portrait crops at 480x800), then
rotated into the panel's native 800x480 byte layout server-side --
the device never knows. Note the device-side manage-menu overlay
(QRs, text, battery indicator, face labels) still renders in native
panel orientation, so on a portrait-hung frame it appears rotated
90° to the viewer -- QR codes scan fine at any rotation, but the text
reads sideways. A known limitation, not planned to change soon.
`quiet_hours_enabled`/`quiet_hours_start`/`quiet_hours_end`
(`"HH:MM"`, may wrap past midnight, e.g. `22:00`-`07:00`) don't touch
the device at all -- purely a server decision about what
`refresh_interval_s` to hand back from `GET /frame/config` below,
computed in `_effective_refresh_interval_s`. Interpreted in the
`timezone` set from the web UI's "Timezone" dropdown (an IANA zone
name, e.g. `America/New_York`; defaults to `UTC`) -- no
docker-compose.yml edit or container restart needed to change it. The
device can still land one wake right
at the start of the window (nothing server-side can prevent that
without touching the firmware, since the device doesn't know wall-clock
time), but from that wake on it's told to sleep exactly until the
window ends. The "overdue" indicator in `/api/queue`'s `device` object
also accounts for this -- it won't falsely flag a device that's
legitimately sleeping through a long quiet-hours window
- `GET /frame/image` -- returns the current photo pre-processed into the
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
`/frames/{id}/stats`, `/m/{manage_token}`.
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
- `GET /frame/image` -- the frame's current image, pre-processed into
the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
by default: it only actually advances to the next photo once
`refresh_interval_s` has elapsed since the current one was set, so
calling it repeatedly (e.g. the device rebooting unexpectedly) just
redisplays the same photo instead of skipping ahead.
- `POST /frame/advance` -- forces an immediate advance to the next photo,
ignoring `refresh_interval_s`, and resets the interval clock from now.
Same response shape as `/frame/image`. Used by the device's next-photo
button (see `firmware/README.md`). Every photo actually displayed this
way (or via the normal timer-based advance) is pushed onto a bounded
history (`app/photo_queue.py`, last 20) that `/frame/back` below can
return to.
- `POST /frame/back` -- returns to the previously-current photo (the
exact mirror of `/frame/advance`), and resets the interval clock from
now. A no-op (still 200, same photo) if there's no history yet.
Pressing advance afterwards returns to where you were before going
back -- it displaces the current photo onto the front of the upcoming
queue rather than discarding it. Same response shape as
`/frame/image`. Used by the device's back-photo button.
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version": "1.2.3" | null}`,
polled by the frame each wake alongside its reachability check.
`firmware_version` is whatever's currently uploaded via
`POST /api/firmware` below (`null` if nothing's been uploaded) -- the
device compares it against its own running version
(`esp_app_get_description()->version`, sent as an `X-Frame-Version`
request header, stored as `device_firmware_version`) to decide whether
to OTA. The device also sends an `X-Frame-Board` header
(`CONFIG_FRAME_BOARD_NAME`, e.g. `"xiao"`), stored as
`device_board_variant` -- how the Gitea auto-update feature below
learns which board to fetch a release for, instead of a user picking
it
- `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... |
null, "location_line2": ... | null, "taken_at": ... | null}` for the
current photo (same idempotent current-photo semantics as
`/frame/image`). `location_line1`/`location_line2` are `city` /
`state-or-country` if Immich reverse-geocoded the photo's GPS EXIF
(both `null` if not) -- for US/Canada, the region line is the
abbreviated state/province (`"CA"`, `"ON"`); elsewhere it's the full
country name. `taken_at` is `MM/DD/YY` from the photo's EXIF capture
date, else `null`. Used by the device's manage button to build its
overlay text
- `GET /frame/share/{asset_id}` -- creates a 30-minute public, view-only
Immich share link for `asset_id` and redirects (302) to it. Only works
for the photo currently showing or in the upcoming queue on this frame
-- not any arbitrary Immich asset. The link is created on first hit
(i.e. when someone actually scans the manage overlay's share QR), not
when the button's pressed, so the 30-minute window starts when it's
actually used
- `GET /frame/face-labels` -- `{"count": N, "name_0": ..., "x_0": ...,
"y_0": ..., ...}` (up to 4 slots) -- named people from Immich's face
recognition, positioned in final 800x480 frame pixel space. Only faces
Immich already has an identified name for are included (no face
detection/recognition happens in this project, see
`app/face_labels.py`); `count: 0` if none are named. Used by the
device manage button's escalated second menu level
- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
battery reading, stored with a timestamp plus two histories: a
per-discharge-cycle one (reset whenever a report jumps up enough to
look like a recharge) feeding the "on battery for"/estimate numbers,
and a permanent, never-reset log (capped at `BATTERY_LOG_MAX`, ~2
years at hourly reports) feeding the web UI's battery graph. Only sent
when the device is actually running on battery (see
`firmware/README.md`'s Battery section) -- a frame on mains power
never reports
- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the
full permanent battery history above; used by the web UI's "Battery
history" chart
- `GET /api/stats` -- lifetime, never-reset counters: `first_seen`,
`device_wakes`, `photos_displayed`, `photos_removed`,
`battery_reports`, `recharge_cycles`, `ota_updates_applied`,
`config_saves` (see `FrameStats` in `app/config.py`). Purely
informational -- nothing else reads these back -- shown in a
collapsed "Stats" section in the web UI
- `POST /api/firmware` -- multipart upload (`file`) of a built
`espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects
anything that isn't a valid image for this project) and stores it as
the available firmware; devices pick it up via `GET /frame/config`
above on their next wake
- `GET /frame/firmware` -- streams back whatever was last uploaded via
`POST /api/firmware`, for the device's OTA fetch. 404 if nothing's
been uploaded yet
- `GET /api/firmware/check` -- throttled (`gitea_releases.UPDATE_CHECK_INTERVAL_S`,
15 min) check of the configured Gitea repo's latest release for the
frame's board variant (learned from the device, see `device_board_variant`
below -- not user-configured). `{"enabled": false}` if no repo URL is
configured; otherwise `{"enabled": true, "board": "xiao" | null,
"latest_version": "1.2.3" | null, "staged_version": "1.2.2" | null,
"update_available": bool}`. `update_available` stays false until the
board is known, regardless of what Gitea has. If "Automatically apply
updates" is on and a newer release is found, this call also stages it
immediately (same effect as a manual upload) -- otherwise the web UI
shows an "Update frame" button
- `POST /api/firmware/apply-latest` -- the "Update frame" button: pulls
and stages the latest Gitea release right now, bypassing the check
throttle. 400 if no repo is configured or no device has checked in
yet (board unknown); 404 if the repo has no releases, or the latest
release has no asset for the frame's board
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
"device": {"last_seen": ts | null, "overdue": bool,
"firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null,
"battery": {"percent": N, "as_of": ts} | null, "on_battery_since": ts | null,
"battery_estimate_s": N | null}}`, each queue entry an asset id +
thumbnail URL; used by the config UI's "Device" panel
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
changed server-side since the client's last fetch (e.g. a top-up/trim)
-- unrecognized IDs in the body are dropped, and any currently-queued
photo missing from the body is appended rather than lost, instead of
rejecting the whole request
- `POST /api/queue/promote` -- moves one photo to the front of the queue;
body is `{"asset_id": "..."}`. Used by "Show next" in the web UI --
unlike `/reorder`, doesn't depend on the client knowing the queue's
full current order, so it can't fail from staleness
- `POST /api/queue/remove` -- permanently excludes a photo from this
frame's rotation; body is `{"asset_id": "..."}`. Doesn't touch Immich
or the album -- the photo just stops being selected by this frame
again (`app/photo_queue.py`'s `excluded_asset_ids`/`remove_from_rotation()`).
Works on the current photo too, in which case it immediately advances
to a different one (without recording the removed photo in history --
going back to a photo you just removed wouldn't make sense). Used by
the "×" button in the web UI on both the current-photo thumbnail and
each upcoming card
- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so
the browser never needs the Immich API key directly
- `GET /health` -- liveness check
by default: it only actually advances once `refresh_interval_s` has
elapsed since the current photo was set, so an unexpected reboot just
redisplays the same photo. An unclaimed or not-yet-configured frame
gets a rendered instruction placeholder (with a claim QR) instead of
an error, so a fresh device never error-loops.
- `POST /frame/advance` / `POST /frame/back` -- the next/back photo
buttons: force an immediate move (mirror images of each other; back
pops a bounded 20-entry history and pushes the displaced photo onto
the front of the queue). Same response shape as `/frame/image`.
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version":
... | null, "device_token": ...?}`, polled each wake. Captures the
`X-Frame-Version`/`X-Frame-Board` headers (running firmware + board
variant). `device_token` appears only during the one-time identity
handshake -- until the device authenticates with its issued token
once -- and the flat firmware parser's 512-byte buffer bounds how big
this response may grow.
- `GET /frame/photo-info` -- location/date overlay text for the manage
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
share link and 302s to it; scoped to the photo currently showing or
queued on *this* frame only.
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser.
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
history (feeds the runtime estimate) plus a permanent per-frame
battery log (the Stats chart). Only sent on battery power. Also where
the battery-alert threshold (below) is checked and, at most once per
discharge cycle, emailed to the owner.
- `GET /frame/firmware` -- streams the frame's staged OTA image.
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
- `GET .../queue` -- current + upcoming (each entry id + thumbnail
URL), the control state (`{"controller": name, "you": bool}`), and
the device telemetry block (`last_seen`, `overdue` -- quiet-hours
aware -- firmware versions, battery + runtime estimate).
- `POST .../queue/reorder|promote|remove` -- reorder is drift-tolerant
(stale ids dropped, missing ids appended); promote is "Show next";
remove permanently excludes from this frame's rotation (never touches
Immich) and advances if it was current.
- `GET .../albums` -- the owner's Immich albums.
- `POST .../config` -- **partial** update: only provided fields change
(`name`, `album_id` -- resets queue/history on change --, `order`,
`refresh_interval_s`, `display_mode` (`crop_fill`/`crop_faces`/
`stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`),
`queue_target_len`, `orientation` (composed logically then rotated
server-side; the on-device manage overlay still renders native, a
known limitation), `quiet_hours_*` + `timezone` (a pure server-side
decision shaping what `refresh_interval_s` gets handed to the
device), `firmware_update_repo_url`, `firmware_auto_update`,
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --,
`palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
blue/green order --, `palette_reset` -- `true` clears back to the
default palette --, `color_boost`/`contrast_boost` -- PIL
`ImageEnhance` factors, 0-2, 1 = unchanged --, `dither_strength` --
0-1, blends toward a flat/undithered quantization before running
Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength).
- `GET .../preview/original`, `GET .../preview/rendered` -- the
before/after comparison on the Configuration tab: the current
photo's Immich preview untouched (JPEG), and that same photo run
through this frame's actual saved rendering pipeline (PNG, upright
logical orientation, not packed device bytes) -- reflects saved
settings, not unsaved slider positions.
- `POST .../take-control` -- always succeeds for a linked user.
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
`GET .../firmware/check` (throttled 15 min; `?force=true` bypasses),
`POST .../firmware/apply-latest`.
### Manage-QR API (`/api/m/{manage_token}/...` -- token in path, no login)
- `GET queue`, `POST promote`, `POST advance`, `POST back`,
`GET thumbnail/{asset_id}` (scoped to this frame's current/queued
photos). Nothing else.
- `GET /health` -- liveness check, always open.
## Notes
- Album/order/refresh-interval/current photo/upcoming queue/etc. are
stored in `./data/config.json` on the host via the compose volume
mount. Immich URL/API key are too if set via the web UI, but
`IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup above) always take
precedence when present.
- All state (settings, current photo, upcoming queue, battery history,
stats) lives in a SQLite database at `./data/espresso.db` on the host
via the compose volume mount (`DATABASE_URL` env var to override --
any SQLAlchemy URL works, so a future move to Postgres is a config
change). A pre-database deployment's `./data/config.json` is imported
automatically on first boot (it becomes frame #1) and left untouched
afterwards as the rollback path. `IMMICH_URL`/`IMMICH_API_KEY` env
vars (see Setup above) still take precedence when present.
- The upcoming queue is a bounded lookahead, not the whole album --
"Upcoming photos to show" in the config UI (`queue_target_len`, 5-50,
default 20) controls its size and takes effect immediately (the queue
@@ -235,19 +199,28 @@ algorithm itself -- it just streams the response straight to the panel.
in sequential or shuffle order per the Order setting. Dragging photos
in the web UI (or using "Show next") only rearranges what's already in
that lookahead; it doesn't add or remove photos from the album.
- Every endpoint except `/` and `/health` -- the web UI's `/api/*` and
every device-facing `/frame/*` -- requires `?token=` (or the
`mgmt_token` cookie the web UI sets after a valid one) once
`MANAGEMENT_TOKEN` is set (see Setup above); unset, everything stays
open like before, which is still fine on a trusted home LAN. `/frame/share`
additionally stays scoped to only ever create a link for a photo this
frame is actually showing or has queued, not any Immich asset ID
someone might guess -- a second layer a leaked token alone wouldn't
bypass.
- The 6-color palette RGB values in `app/image_pipeline.py` are
approximations, not measured values (Waveshare doesn't publish exact
color primaries for this panel) -- tune them once you can compare a
rendered test image against the real panel.
- Auth in one breath: browsers use sessions (+CSRF), devices use
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
credential for pre-multi-frame firmware. `/frame/share` stays scoped
to photos this frame is actually showing or has queued, not any
Immich asset ID someone might guess -- a second layer a leaked device
token alone wouldn't bypass.
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import,
never vendored or modified, so this project's own code stays under its
own license; LGPL's copyleft terms apply to that library itself, not
to code that merely links against it dynamically.
- The 6-color palette RGB values in `app/image_pipeline.py`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(Waveshare doesn't publish exact color primaries for this panel).
Each frame's Configuration tab has an **Advanced configuration**
section (collapsed by default) with a color picker per ink color --
tune them once you can compare a rendered photo against the real
panel, and "Reset to defaults" to go back. Different panel units can
vary enough to be worth calibrating per frame.
## Deploying a pre-built image
+389
View File
@@ -0,0 +1,389 @@
"""Authentication: password hashing, user sessions + CSRF, the legacy
shared-token gate, and device resolution.
Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per-
session CSRF token required on mutating requests) -- humans.
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
browser routes so the deployed frame's on-panel manage QR (which
embeds ?token=) keeps working until Phase C replaces it with the
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
credential, not an ambient cookie a cross-site request could ride).
- Device credentials (?id= + ?token=, see require_device below).
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import time
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from .db import get_db
from .migration import new_device_token, new_manage_token
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession
logger = logging.getLogger(__name__)
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
SESSION_COOKIE = "session"
SESSION_LIFETIME_S = 30 * 86400
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
# and the parameters are baked into each stored hash so they can be
# raised later without invalidating existing ones.
_SCRYPT_N = 16384
_SCRYPT_R = 8
_SCRYPT_P = 1
def hash_password(password: str) -> str:
salt = os.urandom(16)
digest = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, hash_hex = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex), n=int(n), r=int(r), p=int(p)
)
return hmac.compare_digest(digest.hex(), hash_hex)
except (ValueError, AttributeError):
return False
def _hash_session_token(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def create_session(db: Session, user: User) -> tuple[str, UserSession]:
"""Returns (cookie_value, session row). Only the sha256 of the cookie
value is stored, so a leaked database doesn't yield usable cookies."""
cookie_value = secrets.token_urlsafe(32)
now = time.time()
session = UserSession(
token_hash=_hash_session_token(cookie_value),
user_id=user.id,
csrf_token=secrets.token_urlsafe(32),
created_at=now,
expires_at=now + SESSION_LIFETIME_S,
)
db.add(session)
# Opportunistic prune -- no background scheduler in this project.
for stale in db.scalars(select(UserSession).where(UserSession.expires_at < now)):
db.delete(stale)
db.commit()
return cookie_value, session
def destroy_session(db: Session, request: Request) -> None:
cookie_value = request.cookies.get(SESSION_COOKIE)
if not cookie_value:
return
session = db.scalars(
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
).first()
if session is not None:
db.delete(session)
db.commit()
def current_session(request: Request, db: Session) -> UserSession | None:
cookie_value = request.cookies.get(SESSION_COOKIE)
if not cookie_value:
return None
session = db.scalars(
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
).first()
now = time.time()
if session is None or session.expires_at < now:
return None
if session.expires_at - now < SESSION_REFRESH_BELOW_S:
session.expires_at = now + SESSION_LIFETIME_S
db.commit()
return session
def current_user(request: Request, db: Session) -> User | None:
session = current_session(request, db)
if session is None:
return None
return db.get(User, session.user_id)
def users_exist(db: Session) -> bool:
return db.scalars(select(User).limit(1)).first() is not None
def get_server_settings(db: Session) -> ServerSettings:
"""The SMTP config singleton -- migration.py guarantees row id=1
exists (created at startup if missing), so this is never None."""
settings = db.get(ServerSettings, 1)
assert settings is not None
return settings
def create_password_reset_token(db: Session, user: User) -> str:
token = secrets.token_urlsafe(32)
now = time.time()
# Opportunistic prune, same pattern as sessions/pending claims.
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
db.delete(stale)
db.add(PasswordResetToken(
token=token, user_id=user.id, created_at=now,
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
))
db.commit()
return token
def consume_password_reset_token(db: Session, token: str) -> User | None:
"""Looks up the token and, if valid, deletes it (single-use) and
returns the user it was issued for. None for an unknown/expired
token -- callers show a generic error either way."""
row = db.get(PasswordResetToken, token)
if row is None or row.expires_at < time.time():
return None
user = db.get(User, row.user_id)
db.delete(row)
db.commit()
return user
def _csrf_ok(request: Request, session: UserSession) -> bool:
supplied = request.headers.get("X-CSRF-Token") or ""
return hmac.compare_digest(supplied, session.csrf_token)
def require_user_api(request: Request, db: Session = Depends(get_db)) -> User:
"""JSON-API dependency: a logged-in user, with CSRF enforced on
mutating methods (the session rides an ambient cookie; the CSRF
header is what proves the request came from our own JS, not a
cross-site form)."""
session = current_session(request, db)
if session is None:
raise HTTPException(401, "Not logged in")
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
raise HTTPException(403, "Missing or invalid CSRF token")
user = db.get(User, session.user_id)
if user is None:
raise HTTPException(401, "Not logged in")
return user
def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
user = require_user_api(request, db)
if not user.is_admin:
raise HTTPException(403, "Admin only")
return user
def user_frames(db: Session, user: User) -> list[Frame]:
"""The frames this user sees in their sidebar: linked ones, or all of
them for an admin (admins are the household operators -- they see
unclaimed/new frames too, that's how those get adopted)."""
if user.is_admin:
return list(db.scalars(select(Frame).order_by(Frame.id)))
return list(
db.scalars(
select(Frame)
.join(UserFrame, UserFrame.frame_id == Frame.id)
.where(UserFrame.user_id == user.id)
.order_by(Frame.id)
)
)
def can_view_frame(db: Session, user: User, frame: Frame) -> bool:
return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None
def require_frame_view(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""JSON-API dependency: a logged-in user who is linked to this frame
(or an admin). 404 -- not 403 -- for frames outside the user's view,
so the API doesn't confirm which frame ids exist."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
return frame
def require_frame_control(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""View access plus the soft control lock: only the user currently
holding control may mutate settings/queue. The 409 payload names the
holder so the UI can offer "take control" instead of a dead end.
Physical device buttons don't go through this -- device actions are
device actions."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
if frame.controlled_by_user_id != user.id:
holder = frame.controlled_by
raise HTTPException(
409,
{
"error": "not_controller",
"holder": (holder.display_name or holder.username) if holder else None,
},
)
return frame
def management_token() -> str:
"""The legacy shared secret. Env-only, never stored -- same as the old
server, where the env var overrode anything on disk on every load."""
return os.environ.get("MANAGEMENT_TOKEN", "")
def browser_token_valid(request: Request) -> bool:
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means
token-holders don't exist -- but unlike Phase A this no longer means
"open": once users exist, sessions are the primary gate and this is
only the compatibility path for the deployed frame's manage QR
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
token = management_token()
if not token:
return False
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == token
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
"""Dependency for the web UI's /api/* routes: a real user session
(CSRF-checked on mutations, returns the User), or the legacy shared
token (returns None -- token bearers act as an anonymous operator,
exactly the pre-user model). While NO users exist yet (fresh install
or freshly migrated, before /setup has been run) the API stays open
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
since there's nobody to log in as yet."""
session = current_session(request, db)
if session is not None:
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
raise HTTPException(403, "Missing or invalid CSRF token")
user = db.get(User, session.user_id)
if user is not None:
return user
if browser_token_valid(request):
return None
if not users_exist(db) and not management_token():
return None
raise HTTPException(401, "Not logged in")
def _register_frame(db: Session, device_id: str) -> Frame:
"""A device id we've never seen: self-register it as an unclaimed
frame (this fires from ANY /frame/* route -- the wake cycle hits
/frame/image before /frame/config). If a user already submitted a
claim for this id (they beat the device to the server after
provisioning), attach it now."""
frame = Frame(
name=f"Frame {device_id[-6:]}",
device_id=device_id,
device_token=new_device_token(),
manage_token=new_manage_token(),
created_at=time.time(),
)
db.add(frame)
db.flush()
now = time.time()
# Opportunistically prune expired claims while we're here.
for stale in db.scalars(select(PendingClaim).where(PendingClaim.expires_at < now)):
db.delete(stale)
pending = db.get(PendingClaim, device_id)
if pending is not None and pending.expires_at >= now:
frame.owner_user_id = pending.user_id
frame.claimed_at = now
db.add(UserFrame(user_id=pending.user_id, frame_id=frame.id))
db.delete(pending)
logger.info("Frame %s self-registered and attached pending claim by user %d",
device_id, pending.user_id)
else:
logger.info("Frame %s self-registered (unclaimed)", device_id)
return frame
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
"""Resolves and authenticates the frame behind a /frame/* request.
New firmware sends ?id=<12-hex-mac>&token=<per-frame device token>.
Deployed legacy firmware sends only ?token=<shared MANAGEMENT_TOKEN>
(or nothing, on an open server) -- those requests resolve to the
unique legacy_token_enabled frame for as long as that migration
window stays open. The first id-bearing request arriving with legacy
credentials while the legacy frame has no device_id yet BINDS that id
to it -- that's the moment the deployed frame comes back up on new
firmware after its OTA, and it must not register as a second frame.
"""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
legacy = management_token()
legacy_ok = not legacy or token == legacy
if device_id:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None:
legacy_frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
legacy_frame.device_id = device_id
frame = legacy_frame
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
else:
frame = _register_frame(db, device_id)
else:
token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
if not token_ok:
if frame.legacy_token_enabled and legacy_ok:
pass
elif not frame.device_token_ack:
# Handshake window: the device registered but hasn't
# received its token yet (the wake cycle fetches the
# image BEFORE polling /frame/config, where the token
# is delivered) -- the id stays the credential, same
# trust level as the open registration that created
# the row. Closes permanently on the first
# authenticated request.
pass
else:
raise HTTPException(401, "Missing or invalid access token")
else:
if not legacy_ok:
raise HTTPException(401, "Missing or invalid access token")
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is None:
# Nothing to resolve a no-id request to. migration.py always
# creates frame #1 at startup, so this only happens if it was
# deleted -- treat like an unknown device.
raise HTTPException(401, "No frame accepts legacy credentials")
frame.last_seen = time.time()
db.commit()
return frame
+110
View File
@@ -0,0 +1,110 @@
"""Fetch, parse, and merge per-user ICS calendar feeds for calendar frame
mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
get_or_refresh_calendar_events) supply plain (owner_display_name, url)
pairs, not ORM objects, so this module stays testable against fixture .ics
text with no database or app involved.
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
to get right (see its own docs), not worth reinventing. It's LGPL-3.0 (an
ordinary runtime pip dependency, never vendored/modified -- see the
server README's Notes section for why that doesn't put this project's own
code under LGPL terms).
"""
from __future__ import annotations
from datetime import date, datetime
import httpx
import icalendar
import recurring_ical_events
HTTP_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
CHECK_INTERVAL_S = 20 * 60 # don't refetch/reparse any feed more often than this
# How far back/forward each merge-fetch expands recurring events. Households
# look back far less than they plan ahead, hence the asymmetry. Browsing
# outside this window (calendar_browse_offset) just yields an empty view,
# not an error -- self-heals on the next normal wake regardless.
EXPAND_WINDOW_PAST_DAYS = 30
EXPAND_WINDOW_FUTURE_DAYS = 200
class CalendarFetchError(Exception):
"""One feed was unreachable, not valid ICS, or too large. Raised by
fetch_source_events(); merge_events() is what catches this per-source
so one broken feed can't blank out another's events."""
def fetch_source_events(url: str, window_start: date, window_end: date) -> list[dict]:
"""One feed: download, parse, expand recurrences within
[window_start, window_end]. Raises CalendarFetchError on any problem
-- network, malformed ICS, or an oversized response."""
try:
with httpx.stream("GET", url, timeout=HTTP_TIMEOUT_S, follow_redirects=True) as resp:
resp.raise_for_status()
chunks = []
total = 0
for chunk in resp.iter_bytes():
total += len(chunk)
if total > FETCH_MAX_BYTES:
raise CalendarFetchError(f"Feed exceeds {FETCH_MAX_BYTES} bytes")
chunks.append(chunk)
body = b"".join(chunks)
except httpx.HTTPError as e:
raise CalendarFetchError(str(e)) from e
try:
cal = icalendar.Calendar.from_ical(body)
occurrences = recurring_ical_events.of(cal).between(window_start, window_end)
except Exception as e: # icalendar/recurring_ical_events raise a mix of ValueError-family exceptions
raise CalendarFetchError(f"Could not parse ICS feed: {e}") from e
events = []
for occ in occurrences:
dtstart = occ.get("DTSTART")
dtend = occ.get("DTEND")
if dtstart is None:
continue
start_dt = dtstart.dt
end_dt = dtend.dt if dtend is not None else start_dt
all_day = not isinstance(start_dt, datetime) # date, not datetime -- VALUE=DATE
events.append({
"summary": str(occ.get("SUMMARY") or "(untitled)"),
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"all_day": all_day,
})
return events
def merge_events(
sources: list[tuple[str, str]], window_start: date, window_end: date
) -> tuple[list[dict], str]:
"""sources: [(owner_display_name, ics_url), ...]. Fetches each
independently -- one broken feed never blanks another's events.
Returns (merged_time_sorted_events, fetch_summary); fetch_summary is
"" when every source succeeded, else "N of M calendars unavailable"
(never *which* source -- naming whose feed is down to everyone who
looks at a shared household display is a bigger overshare than the
outage itself)."""
merged: list[dict] = []
failures = 0
for owner_display_name, url in sources:
try:
events = fetch_source_events(url, window_start, window_end)
except CalendarFetchError:
failures += 1
continue
for event in events:
event["owner_display_name"] = owner_display_name
merged.append(event)
merged.sort(key=lambda e: e["start"])
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
return merged, summary
+292
View File
@@ -0,0 +1,292 @@
"""Renders calendar frame mode's three views (agenda/week/month) into the
panel's packed format, following image_pipeline.render_placeholder's own
precedent: build an RGB canvas with ImageDraw/ImageFont, then the same
_quantize/_transpose_and_pack every other renderer ends on.
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "owner_display_name"}.
"""
from __future__ import annotations
import calendar as calendar_module
import io
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import (
DEFAULT_PALETTE_RGB,
_apply_manage_overlay,
_quantize,
_transpose_and_pack,
compose_into,
logical_render_size,
)
CALENDAR_VIEWS = ["agenda", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "week": "Week", "month": "Month"}
MARGIN = 20
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
RULE = (200, 200, 200)
# Cycled per distinct owner_display_name so a merged multi-person calendar
# can visually tell whose event is whose -- the panel's own non-black/
# white ink colors, skipping black/white (index 0/1 in DEFAULT_PALETTE_RGB)
# since those are already the page's text/background.
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
def _owner_color(owner_display_name: str, owners_seen: list[str]) -> tuple[int, int, int]:
if owner_display_name not in owners_seen:
owners_seen.append(owner_display_name)
return OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)]
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
"""Parses event["start"] and, for timed events, converts to `tz` --
calendar_feed.py stores whatever timezone each source event carried
(often UTC), but display/bucketing needs to happen in the frame's own
timezone."""
dt = datetime.fromisoformat(event["start"])
if event["all_day"]:
return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date()
return dt.astimezone(tz)
def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]:
on_day = [e for e in events if _local_date(e, tz) == day]
on_day.sort(key=lambda e: (not e["all_day"], e["start"]))
return on_day
def _local_date(event: dict, tz: ZoneInfo) -> date:
start = _event_start(event, tz)
return start if isinstance(start, date) and not isinstance(start, datetime) else start.date()
def _add_months(d: date, months: int) -> date:
total = d.month - 1 + months
year = d.year + total // 12
month = total % 12 + 1
day = min(d.day, calendar_module.monthrange(year, month)[1])
return date(year, month, day)
def _fmt_time(dt: datetime) -> str:
text = dt.strftime("%I:%M %p").lstrip("0")
return text if text else "12:00 AM"
def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
"""Pixel-width-aware truncation (unlike device.py's char-count
_truncate, tuned for a fixed firmware font at a fixed size) -- this
module draws at several different sizes, so truncation has to
measure the actual font/size in play."""
if draw.textlength(text, font=font) <= max_width:
return text
ellipsis = "..."
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width:
lo = mid
else:
hi = mid - 1
return text[:lo] + ellipsis if lo else ellipsis
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None) -> Image.Image:
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
text_x0 = MARGIN
text_w = logical_w - MARGIN * 2
if photo_inlay is not None:
# Long axis split: landscape splits left/right, portrait top/bottom.
if logical_w >= logical_h:
photo_w = logical_w // 2
photo = compose_into(photo_inlay, None, photo_w, logical_h, "crop_fill")
img.paste(photo, (0, 0))
text_x0 = photo_w + MARGIN
text_w = logical_w - photo_w - MARGIN * 2
else:
photo_h = logical_h // 2
photo = compose_into(photo_inlay, None, logical_w, photo_h, "crop_fill")
img.paste(photo, (0, 0))
draw = ImageDraw.Draw(img)
# Smaller title when the inlay halves the available width -- "Wednesday,
# July 22" at full size doesn't fit ~360px, and a *narrower* column is
# exactly when a smaller font (rather than truncating to "Wednesday...")
# keeps it actually informative.
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
body_font = ImageFont.load_default(size=22)
text_y0 = MARGIN if photo_inlay is None or logical_w >= logical_h else logical_h // 2 + MARGIN
day = datetime.now(tz).date() + timedelta(days=browse_offset)
header = day.strftime("%A, %B ") + str(day.day)
draw.text((text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), fill=FG, font=title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
day_events = _events_on_day(events, day, tz)
owners_seen: list[str] = []
row_h = body_font.size + 14
max_rows = max(0, (logical_h - MARGIN - y) // row_h)
if not day_events:
draw.text((text_x0, y), "Nothing scheduled", fill=MUTED, font=body_font)
for i, event in enumerate(day_events):
if i >= max_rows:
draw.text((text_x0, y), f"+{len(day_events) - max_rows} more", fill=MUTED, font=body_font)
break
color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
line = f"{time_str} {event['summary']}"
draw.text((text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), fill=FG, font=body_font)
y += row_h
return img
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image:
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=18)
chip_font = ImageFont.load_default(size=14)
today = datetime.now(tz).date()
week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=browse_offset)
col_w = (logical_w - MARGIN * 2) // 7
header_h = 44
owners_seen: list[str] = []
for col in range(7):
day = week_start + timedelta(days=col)
x0 = MARGIN + col * col_w
if col > 0:
draw.line([(x0, MARGIN), (x0, logical_h - MARGIN)], fill=RULE)
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
draw.text((x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), fill=FG, font=header_font)
y = MARGIN + header_h
row_h = chip_font.size + 10
max_rows = max(0, (logical_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events):
if i >= max_rows:
draw.text((x0 + 6, y), f"+{len(day_events) - max_rows}", fill=MUTED, font=chip_font)
break
color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color)
text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
draw.text((x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), fill=FG, font=chip_font)
y += row_h
return img
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=16)
day_font = ImageFont.load_default(size=18)
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks = list(calendar_module.Calendar(firstweekday=0).monthdatescalendar(target_month.year, target_month.month))
col_w = (logical_w - MARGIN * 2) // 7
header_h = 28
grid_top = MARGIN + header_h
row_h = (logical_h - MARGIN - grid_top) // len(weeks)
for col, name in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]):
draw.text((MARGIN + col * col_w + 6, MARGIN), name, fill=MUTED, font=header_font)
owners_seen: list[str] = []
dot_r = 4
for row, week in enumerate(weeks):
for col, day in enumerate(week):
x0 = MARGIN + col * col_w
y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month
color = FG if in_month else MUTED
if day == today:
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
draw.text((x0 + 6, y0 + 4), str(day.day), fill=color, font=day_font)
day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8
dot_y = y0 + row_h - 14
for i, event in enumerate(day_events[:4]):
color = _owner_color(event["owner_display_name"], owners_seen)
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color)
dot_x += dot_r * 2 + 4
if len(day_events) > 4:
draw.text((dot_x, dot_y - 4), f"+{len(day_events) - 4}", fill=MUTED, font=header_font)
return img
_BUILDERS = {"agenda": _build_agenda, "week": _build_week, "month": _build_month}
def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str,
photo_inlay: Image.Image | None, fetch_summary: str) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
builder = _BUILDERS.get(view, _build_agenda)
if builder is _build_agenda:
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
else:
img = builder(events, browse_offset, orientation, tz)
if fetch_summary:
draw = ImageDraw.Draw(img)
font = ImageFont.load_default(size=14)
logical_w, logical_h = img.size
draw.text((MARGIN, logical_h - MARGIN - font.size), fetch_summary, fill=MUTED, font=font)
return img
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
fetch_summary: str = "", manage: dict | None = None) -> bytes:
"""Renders one of CALENDAR_VIEWS to the panel's packed format. Always
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
other renderer honors."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
fetch_summary: str = "", manage: dict | None = None) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+36 -107
View File
@@ -1,137 +1,85 @@
"""JSON-file-backed config: Immich connection, selected album, and cursor
state (which photo /frame/image serves next)."""
"""LEGACY config.json model -- kept only so migration.py can import an
existing single-frame deployment's state into the database on first
boot. Nothing else should import this module; runtime state lives in
SQLite (see models.py/db.py).
The file at CONFIG_PATH is deliberately never modified or deleted by the
migration: it's the rollback path (redeploying the pre-database server
image picks it right back up).
"""
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from pathlib import Path
from threading import RLock
from typing import Iterator
from pydantic import BaseModel
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
# Reentrant so load()/save() can each take it internally for their own I/O
# while a caller also holds it for a whole locked() span (see below).
_lock = RLock()
class FrameStats(BaseModel):
"""Cumulative, lifetime counters -- purely informational, never read
back to drive any behavior, so there's no harm in them being a little
approximate at the edges. Shown in a collapsed "Stats" section in the
web UI (GET /api/stats). Never reset except by deleting config.json."""
first_seen: float = 0.0 # first time this frame ever checked in
device_wakes: int = 0 # wake cycles, counted once each via GET /frame/config
photos_displayed: int = 0 # times the current photo actually changed (any cause)
photos_removed: int = 0 # times a photo was permanently excluded from rotation
battery_reports: int = 0 # POST /frame/battery calls
recharge_cycles: int = 0 # times a battery recharge was detected
ota_updates_applied: int = 0 # times the device's reported firmware version changed
config_saves: int = 0 # POST /api/config calls
first_seen: float = 0.0
device_wakes: int = 0
photos_displayed: int = 0
photos_removed: int = 0
battery_reports: int = 0
recharge_cycles: int = 0
ota_updates_applied: int = 0
config_saves: int = 0
class FrameConfig(BaseModel):
immich_url: str = ""
immich_api_key: str = ""
management_token: str = "" # gates the web UI (see main.py); empty = no gate, open on trusted LAN
management_token: str = ""
album_id: str = ""
order: str = "sequential" # or "shuffle"
order: str = "sequential"
refresh_interval_s: int = 3600
# Quiet hours: no point waking the device overnight just to swap a
# photo nobody's looking at. Times are "HH:MM" interpreted in
# `timezone` below and may wrap past midnight (e.g. start=22:00,
# end=07:00). Purely a server-side decision -- the device is unaware,
# it just gets told a longer refresh_interval_s by GET /frame/config
# while quiet hours are in effect (see main.py's
# _effective_refresh_interval_s).
quiet_hours_enabled: bool = False
quiet_hours_start: str = "22:00"
quiet_hours_end: str = "07:00"
# IANA zone name (e.g. "America/New_York") quiet_hours_start/end are
# interpreted in. Set from the web UI rather than the container's TZ
# environment variable, so it survives container recreation and
# doesn't need a docker-compose.yml edit to change.
timezone: str = "UTC"
smart_crop_faces: bool = True
# How the physical frame is hung: landscape (native), portrait,
# landscape_flipped, portrait_flipped. Purely a server-side render
# decision -- the device always receives native 800x480 bytes.
orientation: str = "landscape"
# 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
queue_target_len: int = 20 # how many upcoming photos to keep queued/shown in the web UI
history: list[str] = [] # bounded stack of previously-current asset ids, most recent last
excluded_asset_ids: list[str] = [] # permanently removed from this frame's rotation (not deleted from Immich)
queue_cursor: int = 0
queue_target_len: int = 20
history: list[str] = []
excluded_asset_ids: list[str] = []
# Last battery report from the device (POST /frame/battery); -1 = never
# reported / not battery-powered. battery_as_of mirrors the
# current_asset_set_at timestamp pattern.
battery_percent: int = -1
battery_as_of: float = 0.0
# [timestamp, percent] pairs for the CURRENT discharge cycle only --
# reset whenever a report jumps up enough to indicate a recharge (see
# main.py). Feeds the "on battery for" and "estimated remaining"
# numbers in the web UI's Device panel.
battery_history: list = []
# Every report ever received, never reset by a recharge -- the
# permanent record behind the web UI's battery history graph. Capped
# generously (not a real limit at realistic report rates, just a
# safety bound), unlike battery_history above which is deliberately
# scoped to one cycle.
battery_log: list = []
# Device liveness/telemetry: last_seen is touched by every /frame/*
# request; device_firmware_version/device_board_variant come from the
# X-Frame-Version/X-Frame-Board headers the device sends with its
# config poll (CONFIG_FRAME_BOARD_NAME on the firmware side).
last_seen: float = 0.0
device_firmware_version: str = ""
device_board_variant: str = "" # "" until a device has ever checked in
# Version parsed out of the most recently uploaded OTA image
# (POST /api/firmware); "" = none uploaded yet.
device_board_variant: str = ""
firmware_available_version: str = ""
# Gitea-hosted firmware auto-update (see app/gitea_releases.py).
# repo_url empty = feature off, no Gitea calls made at all. Which
# release asset to pull is learned from the device itself
# (device_board_variant below, from its X-Frame-Board header) rather
# than picked by the user -- must match one of the names
# .gitea/workflows/firmware-release-build.yml publishes
# (firmware-<board_variant>.bin).
firmware_update_repo_url: str = "" # e.g. "https://git.example.com/owner/repo"
firmware_auto_update: bool = False # pull+stage a newer release with no button click
# Optional Gitea PAT (read-only access is enough) for a private repo's
# releases; blank is fine for a public repo. GITEA_FIRMWARE_TOKEN env
# var overrides, mirroring MANAGEMENT_TOKEN below -- never exposed to
# the web UI template or any JSON response.
firmware_update_repo_url: str = ""
firmware_auto_update: bool = False
firmware_update_token: str = ""
firmware_update_checked_at: float = 0.0 # throttle bookkeeping, see gitea_releases.UPDATE_CHECK_INTERVAL_S
firmware_gitea_latest_version: str = "" # latest release's version, from its tag name
firmware_update_checked_at: float = 0.0
firmware_gitea_latest_version: str = ""
stats: FrameStats = FrameStats()
def load() -> FrameConfig:
with _lock:
if not CONFIG_PATH.exists():
cfg = FrameConfig()
else:
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
"""Reads the legacy file with the same env-override behavior the old
server applied on every load -- which is exactly how env-configured
IMMICH_URL/IMMICH_API_KEY get baked into the database at migration
time even though they were never written to the file itself."""
if not CONFIG_PATH.exists():
cfg = FrameConfig()
else:
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
# IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN/GITEA_FIRMWARE_TOKEN set in
# the environment (e.g. docker-compose.yml, see
# docker-compose.yml.example) take precedence over whatever's saved in
# CONFIG_PATH, so credentials never need to go through the web UI.
env_url = os.environ.get("IMMICH_URL")
env_key = os.environ.get("IMMICH_API_KEY")
env_token = os.environ.get("MANAGEMENT_TOKEN")
@@ -146,22 +94,3 @@ def load() -> FrameConfig:
cfg.firmware_update_token = env_gitea_token
return cfg
def save(cfg: FrameConfig) -> None:
with _lock:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(cfg.model_dump_json(indent=2))
@contextmanager
def locked() -> Iterator[None]:
"""Serializes an entire load-mutate-save cycle. load()/save() each
only lock their own I/O, which isn't enough by itself: uvicorn
dispatches sync routes to a thread pool, so two concurrent requests
(e.g. the device's own poll landing alongside a web UI edit) can each
load() the same on-disk state and the second save() silently clobber
the first's changes. Route handlers that mutate config should wrap
their whole load/mutate/save span in this."""
with _lock:
yield
+88
View File
@@ -0,0 +1,88 @@
"""Engine, sessions, and the per-frame lock that replaces the old
whole-config.json RLock.
Single uvicorn worker (see Dockerfile) -- handlers are sync and run in
the threadpool, so this is ordinary multi-threading in one process: the
same regime the old config.locked() RLock handled, now scoped per frame.
"""
from __future__ import annotations
import os
import threading
from contextlib import contextmanager
from typing import Iterator
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from .models import Frame
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
_is_sqlite = DATABASE_URL.startswith("sqlite")
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if _is_sqlite else {},
)
if _is_sqlite:
@event.listens_for(engine, "connect")
def _sqlite_pragmas(dbapi_connection, _record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
# expire_on_commit=False so a Frame resolved by the require_device
# dependency (which commits its last_seen touch) stays usable in the
# route handler without a re-select per attribute. Freshness inside
# mutation spans is handled explicitly by frame_locked()'s refresh.
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db() -> Iterator[Session]:
"""FastAPI dependency: one session per request (FastAPI caches the
dependency, so require_device and the route handler share it)."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# One lock per frame id, created on demand. Guarded by a module lock so
# two threads can't race to create different Lock objects for the same
# frame (which would defeat the whole point).
_frame_locks: dict[int, threading.Lock] = {}
_frame_locks_guard = threading.Lock()
def _get_lock(frame_id: int) -> threading.Lock:
with _frame_locks_guard:
lock = _frame_locks.get(frame_id)
if lock is None:
lock = threading.Lock()
_frame_locks[frame_id] = lock
return lock
@contextmanager
def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
"""Serializes a whole read-modify-write span on one frame -- the
direct successor of the old config.locked(). The refresh() inside the
lock is what makes it correct: without it the session could hold
attribute state read *before* another thread's committed write, and
saving would silently clobber it (the same lost-update race the old
pattern's 're-read inside the lock' comment guarded against)."""
with _get_lock(frame_id):
frame = db.get(Frame, frame_id)
if frame is None:
raise LookupError(f"Frame {frame_id} does not exist")
db.refresh(frame)
yield frame
db.commit()
+33 -42
View File
@@ -1,6 +1,6 @@
"""Maps named faces (from Immich's own face recognition/People feature)
onto their position in the final rendered 800x480 frame, for the
manage-button overlay's escalated "who's in this photo" menu level.
onto their position in the final rendered frame, for the manage-button
overlay's named-face labels (see manage_overlay.py, which draws them).
No face detection or recognition happens here or anywhere else in this
project -- Immich's GET /api/faces?id={assetId} already returns each
@@ -15,36 +15,32 @@ import io
from PIL import Image, ImageOps
from .image_pipeline import (
_face_aware_crop_box,
_plain_center_crop_box,
logical_render_size,
logical_to_native,
)
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size
# Small caps, not arbitrary: each label is its own malloc'd overlay
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
# four existing fixed corner regions already use a meaningful chunk of
# the ESP32-C6's limited RAM. Capping at 4 short names keeps the total
# overlay memory budget well clear of the WiFi/HTTP stack's own needs.
MAX_LABELED_FACES = 4
NAME_MAX_LEN = 10
# Not a memory constraint anymore (the overlay renders server-side now,
# not malloc'd per-label on the device) -- purely a legibility cap. A
# photo with a dozen named people would just be visual clutter regardless
# of what's rendering it.
MAX_LABELED_FACES = 6
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
orientation: str = "landscape") -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
800x480 panel pixel space at each named face's bottom-center point.
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
logical (pre-rotation) frame space at each named face's bottom-center
point -- manage_overlay.compose() draws these directly onto the
logical-space image before it's rotated into native panel space, so
no rotation happens here (contrast with the old firmware-side
version, which drew post-rotation and needed logical_to_native).
Faces without an Immich-identified person name are skipped entirely.
preview_bytes must be the same preview image render_frame() used for
the currently-displayed frame, and smart_crop_faces/orientation must
match the settings that were active then -- otherwise the crop box and
rotation computed here won't match what's actually on screen.
the currently-displayed frame, and display_mode/orientation must
match the settings that were active then -- otherwise the placement
computed here won't match what's actually on screen.
The crop math runs in logical (pre-rotation) space, matching
render_frame()'s composition step; each anchor is then rotated into
native panel coordinates via logical_to_native(), since the firmware
draws labels in native space.
The placement math matches render_frame()'s own composition step
exactly (see image_pipeline._placement_transform, shared so the two
can't drift apart).
"""
named = [face for face in faces if (face.get("person") or {}).get("name")]
if not named:
@@ -53,33 +49,28 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_face
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
if smart_crop_faces and faces:
left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
crop_w, crop_h = right - left, bottom - top
else:
left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, logical_w, logical_h)
scale_x, scale_y, offset_x, offset_y = _placement_transform(
fitted.width, fitted.height, logical_w, logical_h, display_mode, faces
)
labels = []
for face in named[:MAX_LABELED_FACES]:
if not _has_bounding_box(face):
continue
face_w = face.get("imageWidth") or fitted.width
face_h = face.get("imageHeight") or fitted.height
scale_x = fitted.width / face_w
scale_y = fitted.height / face_h
img_scale_x = fitted.width / face_w
img_scale_y = fitted.height / face_h
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
bottom_y = face["boundingBoxY2"] * scale_y
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
bottom_y = face["boundingBoxY2"] * img_scale_y
frame_x = (center_x - left) * (logical_w / crop_w)
frame_y = (bottom_y - top) * (logical_h / crop_h)
frame_x = center_x * scale_x + offset_x
frame_y = bottom_y * scale_y + offset_y
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
continue # this face got cropped out of the final frame entirely
name = face["person"]["name"]
if len(name) > NAME_MAX_LEN:
name = name[: NAME_MAX_LEN - 3] + "..."
native_x, native_y = logical_to_native(frame_x, frame_y, orientation)
labels.append({"name": name, "x": native_x, "y": native_y})
labels.append({"name": face["person"]["name"], "x": int(frame_x), "y": int(frame_y)})
return labels
+7 -6
View File
@@ -1,7 +1,8 @@
"""Local firmware image storage + esp_app_desc_t parsing. Shared by the
manual upload path (POST /api/firmware) and the Gitea auto-update path
(see gitea_releases.py) -- both end up writing the same firmware.bin slot
that GET /frame/firmware streams to the device."""
"""Per-frame firmware image storage + esp_app_desc_t parsing. Shared by
the manual upload path and the Gitea auto-update path -- both end up
writing the same per-frame slot that GET /frame/firmware streams to the
device. The migration moves the old single /data/firmware.bin into frame
#1's slot."""
from __future__ import annotations
@@ -18,8 +19,8 @@ APP_DESC_MAGIC = 0xABCD5432
EXPECTED_PROJECT_NAME = "espresso_frame"
def firmware_path():
return config.CONFIG_PATH.parent / "firmware.bin"
def firmware_path(frame_id: int):
return config.CONFIG_PATH.parent / "firmware" / f"{frame_id}.bin"
def parse_app_version(data: bytes) -> str:
+282 -42
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from PIL import Image, ImageOps
import io
from PIL import Image, ImageEnhance, ImageOps
EPD_WIDTH = 800
EPD_HEIGHT = 480
@@ -45,39 +47,56 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
return int(logical_h - 1 - y), int(x)
return int(x), int(y)
# Approximate sRGB for each of the panel's 6 ink colors. These are
# reasonable placeholders, not measured values -- Waveshare doesn't publish
# exact color primaries for this panel. Tune them once you can compare a
# rendered test image against the real panel.
PALETTE_RGB = [
# (0, 0, 0), # BLACK
# (255, 255, 255), # WHITE
# (255, 219, 0), # YELLOW
# (207, 0, 15), # RED
# (0, 39, 133), # BLUE
# (0, 133, 55), # GREEN
(0, 0, 0),
(255, 255, 255),
(255, 243, 56),
(191, 0, 0),
(100, 64, 255),
(67, 138, 28)
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable
# placeholders, not measured values (Waveshare doesn't publish exact
# color primaries for this panel). This is the fallback for any frame
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
# Configuration tab -- "Advanced configuration" -- once you can compare
# a rendered test image against the real panel; different panel units
# can vary enough to be worth calibrating per frame).
DEFAULT_PALETTE_RGB = [
(0, 0, 0), # BLACK
(255, 255, 255), # WHITE
(255, 219, 0), # YELLOW
(207, 0, 15), # RED
(0, 39, 133), # BLUE
(0, 133, 55), # GREEN
]
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream.
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
# hardware protocol, never user-configurable. 0x4 is intentionally unused
# upstream.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
def _build_palette_image() -> Image.Image:
def palette_to_hex(palette_rgb: list) -> list[str]:
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
configuration color pickers."""
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
a 6-hex-digit color (what <input type="color"> always sends, but a
direct API call might not)."""
hex_str = hex_str.strip().lstrip("#")
if len(hex_str) != 6:
return None
try:
return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))
except ValueError:
return None
def _build_palette_image(palette_rgb: list) -> Image.Image:
pal_img = Image.new("P", (1, 1))
pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb])
pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb])
return pal_img
_PALETTE_IMAGE = _build_palette_image()
def _plain_center_crop_box(
img_width: int, img_height: int, target_width: int, target_height: int
) -> tuple[float, float, int, int]:
@@ -99,6 +118,16 @@ def _plain_center_crop_box(
return left, top, crop_w, crop_h
def _has_bounding_box(face: dict) -> bool:
"""Immich has occasionally been observed to return a face entry with
a still-pending or otherwise incomplete bounding box (a null field)
-- treat it as undetected rather than crash on arithmetic with None."""
return all(
face.get(k) is not None
for k in ("boundingBoxX1", "boundingBoxX2", "boundingBoxY1", "boundingBoxY2")
)
def _face_aware_crop_box(
img_width: int, img_height: int, target_width: int, target_height: int, faces: list[dict]
) -> tuple[int, int, int, int]:
@@ -119,6 +148,8 @@ def _face_aware_crop_box(
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for face in faces:
if not _has_bounding_box(face):
continue
face_w = face.get("imageWidth") or img_width
face_h = face.get("imageHeight") or img_height
scale_x = img_width / face_w
@@ -152,29 +183,109 @@ def _face_aware_crop_box(
return (int(left), int(top), int(left) + crop_w, int(top) + crop_h)
def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape") -> bytes:
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
# Display modes: how a photo's aspect ratio gets reconciled with the
# panel's. "crop_faces" falls back to "crop_fill" behavior when no faces
# were detected/passed. DEFAULT_DISPLAY_MODE matches this project's old
# always-on smart_crop_faces=True default.
DISPLAY_MODES = ["crop_fill", "crop_faces", "stretch_fill", "letterbox"]
DISPLAY_MODE_LABELS = {
"crop_fill": "Crop to fill",
"crop_faces": "Crop to faces",
"stretch_fill": "Stretch to fill",
"letterbox": "Shrink to fit",
}
DEFAULT_DISPLAY_MODE = "crop_faces"
LETTERBOX_BG = (255, 255, 255)
If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops
toward keeping them on screen instead of a plain center-crop.
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
the frame physically hangs, then rotates into native panel space --
the output byte layout is identical either way.
"""
logical_w, logical_h = logical_render_size(orientation)
def _placement_transform(
img_width: int, img_height: int, target_w: int, target_h: int,
display_mode: str, faces: list[dict] | None = None,
) -> tuple[float, float, float, float]:
"""Returns (scale_x, scale_y, offset_x, offset_y) mapping a point in
source-image pixel space to a point in target logical space, for the
given display_mode. Shared by render_frame (which also does the
actual pixel crop/resize/pad) and face_labels.py (label position
math) -- they must stay in exact agreement or overlay labels drift
off the people they're meant to point at."""
if display_mode == "stretch_fill":
return target_w / img_width, target_h / img_height, 0.0, 0.0
if display_mode == "letterbox":
scale = min(target_w / img_width, target_h / img_height)
return scale, scale, (target_w - img_width * scale) / 2, (target_h - img_height * scale) / 2
if display_mode == "crop_faces" and faces:
left, top, right, bottom = _face_aware_crop_box(img_width, img_height, target_w, target_h, faces)
crop_w, crop_h = right - left, bottom - top
else:
left, top, crop_w, crop_h = _plain_center_crop_box(img_width, img_height, target_w, target_h)
scale_x, scale_y = target_w / crop_w, target_h / crop_h
return scale_x, scale_y, -left * scale_x, -top * scale_y
def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, target_h: int,
display_mode: str) -> Image.Image:
"""Crop/resize/letterbox `source` per display_mode into an arbitrary
target_w x target_h box -- returns an RGB image, before enhancement or
quantization. See render_frame for what each display_mode does.
_compose() is the common case of this (target = the full panel, at
logical_render_size(orientation)); this more general form also backs
calendar_render.py's agenda photo-inlay, which composes into just a
sub-region of the panel instead of the whole thing."""
fitted = ImageOps.exif_transpose(source.convert("RGB"))
if faces:
box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
else:
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
if display_mode == "stretch_fill":
return fitted.resize((target_w, target_h), Image.LANCZOS)
if display_mode == "letterbox":
scale = min(target_w / fitted.width, target_h / fitted.height)
new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale))
resized = fitted.resize((new_w, new_h), Image.LANCZOS)
canvas = Image.new("RGB", (target_w, target_h), LETTERBOX_BG)
canvas.paste(resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
return canvas
if display_mode == "crop_faces" and faces:
box = _face_aware_crop_box(fitted.width, fitted.height, target_w, target_h, faces)
return fitted.crop(box).resize((target_w, target_h), Image.LANCZOS)
return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image:
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
image at logical_render_size(orientation), before enhancement or
quantization. See render_frame for what each display_mode does."""
return compose_into(source, faces, *logical_render_size(orientation), display_mode)
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
if color_boost != 1.0:
img = ImageEnhance.Color(img).enhance(color_boost)
if contrast_boost != 1.0:
img = ImageEnhance.Contrast(img).enhance(contrast_boost)
return img
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
"""RGB -> palette-quantized P-mode image, same size/orientation as
`img` (no rotation here). dither_strength blends `img` toward its own
flat (undithered) quantization before running Floyd-Steinberg on the
blend: at 0 there's zero quantization error left to diffuse (so the
result IS the flat quantization, no dithering texture at all); at 1
it's `img` unchanged (full-strength dithering, this project's
original always-on behavior); values between give a smooth continuum
of dithering intensity rather than an on/off toggle."""
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
if dither_strength >= 1.0:
return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
if dither_strength <= 0.0:
return img.quantize(palette=palette_image, dither=Image.Dither.NONE)
flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB")
blended = Image.blend(flat, img, dither_strength)
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
"""Rotates a logical-space quantized image into native panel space
and packs it 2 pixels/byte the way epd7in3e.c expects. Always
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes."""
transpose = ORIENTATION_TRANSPOSE.get(orientation)
if transpose is not None:
quantized = quantized.transpose(transpose)
@@ -190,3 +301,132 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
i += 1
return bytes(out)
def _apply_manage_overlay(img: Image.Image, manage: dict | None) -> Image.Image:
"""Composites the manage-button overlay (scan-to-manage QR, battery,
location/date/share-QR, named face labels) onto an already-composed,
already-enhanced image, if requested -- see manage_overlay.compose().
Local import: manage_overlay is an optional, occasionally-used
concern (only /frame/*?manage=1 requests need it), same reasoning
render_placeholder already applies to its own `import qrcode`."""
if manage is None:
return img
from . import manage_overlay
return manage_overlay.compose(img, **manage)
def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0,
manage: dict | None = None) -> bytes:
"""Fits `source` to the panel's resolution, applies color/contrast
enhancement, quantizes it to the 6-color palette, and packs 2
pixels/byte the way epd7in3e.c expects. Always returns exactly
EPD_WIDTH*EPD_HEIGHT/2 bytes.
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
is reconciled with the panel's: crop_fill (center-crop to fill,
excess trimmed), crop_faces (as crop_fill, but shifts the crop to
keep `faces` on screen -- falls back to crop_fill if none), stretch_fill
(fills exactly, aspect ratio not preserved), letterbox (whole photo
visible, letterboxed with LETTERBOX_BG where it doesn't fill).
`color_boost`/`contrast_boost` are PIL ImageEnhance factors (1.0 =
unchanged, matching PIL's own convention); `dither_strength` is
0.0-1.0 (see _quantize).
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
the frame physically hangs, then rotates into native panel space --
the output byte layout is identical either way.
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
see Frame.palette_rgb) -- None uses the default.
`manage` is a dict of manage_overlay.compose()'s kwargs (management_url,
battery_percent, location_lines, taken_at, share_url, face_labels), or
None to skip it -- see routers/device.py's build_manage_content(),
which callers pass this straight through from. Applied after
enhancement, before quantization, so the overlay's pure black/white
graphics aren't affected by color/contrast boost.
"""
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _transpose_and_pack(quantized, orientation)
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0,
manage: dict | None = None) -> bytes:
"""Identical composition/enhancement/quantization pipeline as
render_frame, but returned as a normal browser-viewable PNG in
logical (upright, as-the-frame-actually-hangs) orientation rather
than packed native-panel bytes and rotation -- what the web UI's
"how it will look on the frame" preview shows."""
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
manage: dict | None = None) -> bytes:
"""A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a
frame that isn't claimed or configured yet, so a fresh device shows
instructions instead of an error screen and never error-loops.
`manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured
yet."""
from PIL import ImageDraw, ImageFont
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
draw = ImageDraw.Draw(img)
title_font = ImageFont.load_default(size=34)
body_font = ImageFont.load_default(size=24)
qr_img = None
if qr_url:
import qrcode
qr = qrcode.QRCode(border=1, box_size=1)
qr.add_data(qr_url)
qr.make(fit=True)
raw = qr.make_image().get_image().convert("RGB")
# Integer upscale with NEAREST keeps modules crisp on the panel.
target = 220
scale = max(1, target // raw.width)
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
# Vertical layout: text block, then QR under it, centered as a group.
line_heights = []
for i, line in enumerate(lines):
font = title_font if i == 0 else body_font
bbox = draw.textbbox((0, 0), line, font=font)
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
gap = 14
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
y = max(20, (logical_h - total_h) // 2)
for line, font, w, h in line_heights:
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
y += h + gap
if qr_img:
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
+68
View File
@@ -0,0 +1,68 @@
"""SMTP email sending -- password resets and battery-threshold alerts.
Config lives in the server_settings singleton row (admin-configured via
/admin, see routers/pages.py), not env vars -- it's operator
infrastructure a household admin sets up once through the UI, same
spirit as the rest of this project's "no separate config file" stance
post-redesign. Uses stdlib smtplib; no new dependency.
send_email() never raises -- a broken mail server shouldn't 500 a
password-reset request or a battery report; callers get a bool and log
a warning on failure."""
from __future__ import annotations
import email.utils
import logging
import smtplib
import ssl
from email.mime.text import MIMEText
from .models import ServerSettings
logger = logging.getLogger(__name__)
SMTP_TIMEOUT_S = 10
def send_email(settings: ServerSettings, to_address: str, subject: str, body: str) -> bool:
if not settings.smtp_host or not to_address:
return False
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = settings.smtp_from_address or settings.smtp_username or "noreply@localhost"
msg["To"] = to_address
# email.mime doesn't set either of these on its own -- and a missing
# Message-ID in particular is enough for a strict content filter
# (e.g. Amavis's header-sanity check) to quarantine an otherwise
# cleanly SPF/DKIM/DMARC-passing message outright. Domain in the
# generated id matches the From address so it's traceable back here.
msg["Date"] = email.utils.formatdate(localtime=True)
msg["Message-ID"] = email.utils.make_msgid(domain=msg["From"].rsplit("@", 1)[-1])
try:
# "ssl" (implicit TLS, port 465 typically) needs a TLS socket from
# the very first byte -- SMTP_SSL, not SMTP+starttls(). Connecting
# a plaintext SMTP() to a TLS-only port fails outright (garbled
# banner/timeout), it doesn't degrade gracefully, so this has to
# be a real branch rather than "starttls() or not".
if settings.smtp_encryption == "ssl":
with smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port,
timeout=SMTP_TIMEOUT_S, context=ssl.create_default_context(),
) as smtp:
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(msg)
else:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_S) as smtp:
if settings.smtp_encryption == "starttls":
smtp.starttls(context=ssl.create_default_context())
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(msg)
return True
except (OSError, smtplib.SMTPException, ssl.SSLError) as e:
logger.warning("Failed to send email to %s: %s", to_address, e)
return False
+82 -887
View File
@@ -1,199 +1,53 @@
"""ESPresso Frame server: pulls photos from Immich, pre-processes them for
the panel, and serves the ESP32 a ready-to-display frame."""
"""ESPresso Frame server: pulls photos from Immich, pre-processes them
for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/:
device.py the firmware-facing /frame/* protocol (paths frozen)
api_frames.py the web UI's JSON API, /api/frames/{id}/...
frame_pages.py the per-frame Photos/Configuration/Stats pages
pages.py setup/login/claim/settings/admin
manage.py the limited manage-QR surface (/m/, /api/m/)
Storage is SQLite via models.py/db.py; migration.py imports a
pre-database config.json deployment on first boot."""
from __future__ import annotations
import io
import logging
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo, available_timezones
import httpx
from fastapi import Depends, FastAPI, File, HTTPException, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image
from pydantic import BaseModel
from sqlalchemy import select
from . import config, gitea_releases, photo_queue
from .face_labels import compute_face_labels
from .firmware import firmware_path, parse_app_version
from .image_pipeline import render_frame
from .immich_client import ImmichClient
from . import migration
from .auth import (
browser_token_valid,
current_user,
management_token,
user_frames,
users_exist,
)
from .db import SessionLocal
from .models import Frame
from .routers import api_frames, device, frame_pages, manage, pages
from .routers.common import shell_context
logger = logging.getLogger(__name__)
# Schema + legacy-config import, before the first request is served.
migration.run_migrations()
app = FastAPI(title="ESPresso Frame Server")
templates = Jinja2Templates(directory="app/templates")
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
app.mount("/static", StaticFiles(directory="app/static"), name="static")
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
# Populated once from the OS's zoneinfo database (installed via the
# `tzdata` apt package in the Dockerfile) and offered as a <select> in the
# web UI's "Timezone" field -- see api_config_save/index below.
ALL_TIMEZONES = sorted(available_timezones())
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- see FrameConfig.battery_log
RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
OVERDUE_FACTOR = 1.5
def _valid_hhmm(s: str) -> bool:
try:
datetime.strptime(s, "%H:%M")
return True
except ValueError:
return False
def _zoneinfo(name: str) -> ZoneInfo:
"""Falls back to UTC for an unrecognized zone name -- defensive only;
api_config_save already validates against ALL_TIMEZONES before saving,
so this only matters for a config.json hand-edited or written by an
older version of this file."""
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo("UTC")
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
"""Whether `now` falls inside the quiet-hours window, and the next
boundary: if inside, when it ends; if outside, when it next starts.
Handles a window that wraps past midnight (e.g. 22:00-07:00). Returns
(False, None) for a degenerate window (start == end)."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
if start == end:
return False, None
if start < end:
# Same-day window, e.g. 13:00-15:00. End is exclusive, so being
# exactly at `end` counts as already outside the window.
if start <= now < end:
return True, end
if now < start:
return False, start
return False, start + timedelta(days=1)
# Wraps midnight, e.g. 22:00-07:00.
if now >= start:
return True, end + timedelta(days=1)
if now < end:
return True, end
return False, start
def _quiet_hours_span_s(start_str: str, end_str: str) -> int:
"""Duration of the quiet-hours window in seconds, wrap-aware."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
span_min = ((eh * 60 + em) - (sh * 60 + sm)) % (24 * 60)
return span_min * 60
def _effective_refresh_interval_s(cfg: config.FrameConfig) -> int:
"""The refresh interval actually handed to the device: its configured
value, unless quiet hours are enabled, in which case it's clamped so
the device sleeps through the whole window instead of waking inside
it. A device already mid-sleep when quiet hours begin can still land
one wake inside the window (nothing server-side can prevent that
without touching the firmware) -- but from that wake on, it's told to
sleep exactly until the window ends."""
if not cfg.quiet_hours_enabled:
return cfg.refresh_interval_s
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
if boundary is None:
return cfg.refresh_interval_s
seconds_to_boundary = max(1, int((boundary - now).total_seconds()))
if in_quiet:
return seconds_to_boundary
return min(cfg.refresh_interval_s, seconds_to_boundary)
def _in_quiet_hours(cfg: config.FrameConfig) -> bool:
"""Whether quiet hours are in effect right now -- separate from
_effective_refresh_interval_s, which only shapes what the *device* is
told to sleep for. This instead gates photo_queue.get_current()'s
time-based advance, since that check runs independent of the device
(also triggered by the web UI's /api/queue, e.g. an open browser tab
polling overnight) and would otherwise happily advance the current
photo mid-quiet-hours on raw elapsed time alone."""
if not cfg.quiet_hours_enabled:
return False
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, _ = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
return in_quiet
def _max_expected_gap_s(cfg: config.FrameConfig) -> int:
"""Longest gap between wakes the device might legitimately have --
normally just refresh_interval_s, but quiet hours can make the real
gap much longer, and the "overdue" check (see api_queue) shouldn't
mistake a device quietly sleeping through the night for a dead one."""
gap = cfg.refresh_interval_s
if cfg.quiet_hours_enabled:
gap = max(gap, _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end))
return gap
def _touch_last_seen() -> None:
"""Records that the device just made contact. Called by every
/frame/* route -- a handful of extra config writes per wake cycle,
which is nothing at hourly wakes."""
with config.locked():
cfg = config.load()
cfg.last_seen = time.time()
config.save(cfg)
def _token_valid(request: Request, cfg: config.FrameConfig) -> bool:
"""No management_token configured (MANAGEMENT_TOKEN env var, see
docker-compose.yml.example) means the whole server stays open on a
trusted LAN, matching this project's original default. Once one's
set, a request is authorized by either a ?token= query param (what
the ESP32 sends on every device request, and what the manage-menu/
share QR codes embed for a human scanning them) or the cookie
index() sets after a valid query-param hit (so the web UI's own
fetch()/<img> calls, which carry no query string, stay authorized
for the rest of that browsing visit)."""
if not cfg.management_token:
return True
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == cfg.management_token
def require_access_token(request: Request) -> None:
"""Dependency for every route except / and /health: the web UI's
/api/* and every device-facing /frame/*. index() handles the
unauthorized case itself (a friendlier HTML prompt, not a bare 401)
since that's the one route a human is actually meant to land on with
no token yet; the ESP32 sends its token as ?token= on every request
it makes (see frame_client.c's build_url()), so device endpoints
just 401 outright on a missing/wrong one. /health stays open -- it
reveals nothing but process liveness, and gating it would break
plain infra/uptime monitoring for no real security benefit."""
if not _token_valid(request, config.load()):
raise HTTPException(401, "Missing or invalid access token")
app.include_router(device.router)
app.include_router(api_frames.router)
app.include_router(frame_pages.router)
app.include_router(pages.router)
app.include_router(manage.router)
@app.get("/health")
@@ -201,712 +55,53 @@ def health() -> dict:
return {"status": "ok"}
@app.get("/frame/config", dependencies=[Depends(require_access_token)])
def frame_config(request: Request):
"""Device-facing settings, polled by the frame alongside its
reachability check. Always returns 200 with current settings
(defaults if nothing's been saved yet) -- no Immich-configured gate,
since this doubles as the "is the server up" signal. Also captures
the device's running firmware version and board variant (X-Frame-
Version/X-Frame-Board headers -- the latter is how the Gitea
auto-update feature learns which release asset to fetch, instead of
a user picking it in the web UI) and advertises the uploaded OTA
image's version, so the device's update check costs zero extra
round trips."""
reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "")
with config.locked():
cfg = config.load()
cfg.last_seen = time.time()
if cfg.stats.first_seen == 0:
cfg.stats.first_seen = cfg.last_seen
cfg.stats.device_wakes += 1
if reported_version:
if cfg.device_firmware_version and reported_version != cfg.device_firmware_version:
cfg.stats.ota_updates_applied += 1
cfg.device_firmware_version = reported_version
if reported_board:
cfg.device_board_variant = reported_board
config.save(cfg)
return {
"refresh_interval_s": _effective_refresh_interval_s(cfg),
"firmware_version": cfg.firmware_available_version or None,
}
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
"""The on-frame manage QR points at the server root with the device's
own credentials (new firmware: ?id=&token=; deployed firmware:
?token=<legacy shared token>). Those scans get the frame's limited
manage page -- never the full UI, which requires a login.
allow_legacy is False before /setup has run: at that point a bare
?token= hit is the admin coming through the token prompt to do
first-run setup, not a QR scan."""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
if device_id and token:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is not None and token == frame.device_token:
return f"/m/{frame.manage_token}"
if allow_legacy and token and management_token() and token == management_token():
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is not None:
return f"/m/{frame.manage_token}"
return None
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
cfg = config.load()
if not _token_valid(request, cfg):
supplied = request.query_params.get("token")
return templates.TemplateResponse(
"token_prompt.html", {"request": request, "wrong": supplied is not None}
)
response = templates.TemplateResponse(
"index.html", {"request": request, "cfg": cfg, "timezones": ALL_TIMEZONES}
)
supplied = request.query_params.get("token")
if cfg.management_token and supplied == cfg.management_token:
# Query-param access (typically the manage-menu QR code) earns a
# cookie so the rest of this visit's fetch()/<img> calls -- which
# never carry the query string -- stay authorized too.
response.set_cookie(
MANAGEMENT_TOKEN_COOKIE, supplied, max_age=86400 * 365, httponly=True, samesite="lax"
)
return response
@app.get("/api/albums", dependencies=[Depends(require_access_token)])
def api_albums():
cfg = config.load()
if not cfg.immich_url or not cfg.immich_api_key:
raise HTTPException(400, "Immich URL/API key not configured yet")
try:
albums = ImmichClient(cfg.immich_url, cfg.immich_api_key).list_albums()
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@app.post("/api/config", dependencies=[Depends(require_access_token)])
def api_config_save(
album_id: str = Form(""),
order: str = Form("sequential"),
refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20),
orientation: str = Form("landscape"),
quiet_hours_enabled: bool = Form(False),
quiet_hours_start: str = Form("22:00"),
quiet_hours_end: str = Form("07:00"),
timezone: str = Form("UTC"),
firmware_update_repo_url: str = Form(""),
firmware_auto_update: bool = Form(False),
):
# Immich URL/API key/Gitea token are env-var only (IMMICH_URL/
# IMMICH_API_KEY/GITEA_FIRMWARE_TOKEN, see docker-compose.yml.example)
# -- config.load() already applies them, and this handler doesn't touch
# cfg.immich_url/immich_api_key/firmware_update_token at all, so
# there's nothing here that could overwrite or clear them.
with config.locked():
cfg = config.load()
if album_id != cfg.album_id:
# 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.history = []
cfg.excluded_asset_ids = []
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))
cfg.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
cfg.quiet_hours_enabled = quiet_hours_enabled
if _valid_hhmm(quiet_hours_start):
cfg.quiet_hours_start = quiet_hours_start
if _valid_hhmm(quiet_hours_end):
cfg.quiet_hours_end = quiet_hours_end
if timezone in ALL_TIMEZONES:
cfg.timezone = timezone
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
cfg.firmware_auto_update = firmware_auto_update
cfg.stats.config_saves += 1
config.save(cfg)
return {"status": "saved"}
@app.get("/api/stats", dependencies=[Depends(require_access_token)])
def api_stats():
return config.load().stats.model_dump()
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")
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
def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str) -> bytes:
try:
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)
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)
source = Image.open(io.BytesIO(jpeg_bytes))
return render_frame(source, faces=faces, orientation=cfg.orientation)
@app.get("/frame/image", dependencies=[Depends(require_access_token)])
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."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
config.save(cfg)
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
@app.post("/frame/advance", dependencies=[Depends(require_access_token)])
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."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
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.post("/frame/back", dependencies=[Depends(require_access_token)])
def frame_back():
"""Returns to the previously-current photo (the mirror image of
/frame/advance -- see photo_queue.back_forced()), and resets the
interval clock from now. A no-op (still 200, current photo
unchanged) if there's no history to go back to -- same "always
returns something displayable" contract as /frame/advance, rather
than erroring. Used by the device's back-photo button."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
photo_queue.back_forced(cfg, assets)
config.save(cfg)
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
@app.post("/frame/battery", dependencies=[Depends(require_access_token)])
def frame_battery(body: BatteryReport):
"""Battery level reported by the device (only when running on battery
-- it stays silent on mains, where the charging voltage would read
misleadingly full). Stored with a timestamp plus a per-discharge-
cycle history that feeds the Device panel's "on battery for" and
"estimated remaining" numbers."""
if not 0 <= body.percent <= 100:
raise HTTPException(400, "percent must be 0-100")
now = time.time()
with config.locked():
cfg = config.load()
cfg.stats.battery_reports += 1
if cfg.battery_history and body.percent >= cfg.battery_history[-1][1] + RECHARGE_JUMP_PCT:
# Percent jumped up meaningfully -- the battery was recharged
# (or swapped). Start a fresh discharge cycle so runtime and
# discharge-rate estimates never span a charge.
cfg.battery_history = []
cfg.stats.recharge_cycles += 1
cfg.battery_history.append([now, body.percent])
cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:]
cfg.battery_log.append([now, body.percent])
cfg.battery_log = cfg.battery_log[-BATTERY_LOG_MAX:]
cfg.battery_percent = body.percent
cfg.battery_as_of = now
cfg.last_seen = now
config.save(cfg)
return {"status": "saved"}
@app.post("/api/firmware", dependencies=[Depends(require_access_token)])
def api_firmware_upload(file: UploadFile = File(...)):
"""Uploads a firmware image for OTA. The version is parsed out of the
image itself (esp_app_desc_t) rather than trusted from a filename or
form field, and the project name is checked so an unrelated .bin
can't be pushed to the frame by mistake."""
data = file.file.read()
version = parse_app_version(data)
firmware_path().write_bytes(data)
with config.locked():
cfg = config.load()
cfg.firmware_available_version = version
config.save(cfg)
return {"status": "saved", "version": version, "size": len(data)}
@app.get("/frame/firmware", dependencies=[Depends(require_access_token)])
def frame_firmware():
"""The uploaded OTA image, streamed to the device (esp_https_ota).
404 until something has been uploaded."""
_touch_last_seen()
path = firmware_path()
if not path.exists():
raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream")
def _fetch_latest_release(cfg: config.FrameConfig) -> dict | None:
try:
return gitea_releases.fetch_latest_release(cfg.firmware_update_repo_url, cfg.firmware_update_token)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Gitea at {cfg.firmware_update_repo_url}: {e}") from e
def _apply_gitea_update(cfg: config.FrameConfig) -> str:
"""Downloads the configured Gitea repo's latest release asset for this
frame's board variant and stages it exactly like a manual
POST /api/firmware upload would. The board comes from the device
itself (device_board_variant, learned from its X-Frame-Board header
on GET /frame/config -- see frame_config()), not a user picker, so
there's nothing to fetch until a device has checked in at least
once. Network I/O happens before the lock is taken, matching the
load/mutate/save concurrency pattern used elsewhere (see
config.locked())."""
if not cfg.device_board_variant:
raise HTTPException(400, "No frame has checked in yet -- can't tell which board's build to fetch")
release = _fetch_latest_release(cfg)
if not release:
raise HTTPException(404, "No releases found in the configured Gitea repo")
asset_name = gitea_releases.asset_name_for_board(cfg.device_board_variant)
asset_url = release["assets"].get(asset_name)
if not asset_url:
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
try:
data = gitea_releases.download_asset(asset_url, cfg.firmware_update_token)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
version = parse_app_version(data) # same validation the manual upload path applies
firmware_path().write_bytes(data)
with config.locked():
cfg = config.load()
cfg.firmware_available_version = version
cfg.firmware_gitea_latest_version = version
cfg.firmware_update_checked_at = time.time()
config.save(cfg)
return version
@app.get("/api/firmware/check", dependencies=[Depends(require_access_token)])
def api_firmware_check():
"""Throttled check of the configured Gitea repo's latest release
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
the release's tag name, not its binaries. If firmware_auto_update is
on and a newer version is found, applies it immediately; otherwise
just reports it so the web UI can offer the "Update frame" button.
Applying (auto or manual) needs to know the frame's board, which is
learned from the device's own X-Frame-Board header rather than
picked by the user -- update_available stays false until a device
has checked in at least once, regardless of what Gitea has."""
cfg = config.load()
if not cfg.firmware_update_repo_url:
return {"enabled": False}
now = time.time()
if now - cfg.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
# Deliberately not updated on failure (see below) -- checked_at only
# advances on a successful reach, so a Gitea outage gets retried
# every poll instead of waiting out the full throttle interval.
release = _fetch_latest_release(cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
cfg.firmware_update_checked_at = now
if release:
cfg.firmware_gitea_latest_version = release["version"]
config.save(cfg)
cfg = config.load()
update_available = (
bool(cfg.firmware_gitea_latest_version)
and cfg.firmware_gitea_latest_version != cfg.firmware_available_version
and bool(cfg.device_board_variant)
)
if update_available and cfg.firmware_auto_update:
_apply_gitea_update(cfg)
cfg = config.load()
update_available = False
return {
"enabled": True,
"board": cfg.device_board_variant or None,
"latest_version": cfg.firmware_gitea_latest_version or None,
"staged_version": cfg.firmware_available_version or None,
"update_available": update_available,
}
@app.post("/api/firmware/apply-latest", dependencies=[Depends(require_access_token)])
def api_firmware_apply_latest():
"""The "Update frame" button: applies the latest Gitea release right
now, bypassing the check throttle -- this is an explicit user action,
not a background poll."""
cfg = config.load()
if not cfg.firmware_update_repo_url:
raise HTTPException(400, "No Gitea firmware repo configured")
version = _apply_gitea_update(cfg)
return {"status": "saved", "version": version}
def _battery_estimate_s(cfg: config.FrameConfig) -> int | None:
"""Linear remaining-time estimate from the current discharge cycle's
observed rate, or None when there's not enough signal to be honest
about (too little time observed, or too little drop -- a flat line
extrapolates to garbage)."""
hist = cfg.battery_history
if len(hist) < 2:
return None
first_ts, first_pct = hist[0]
last_ts, last_pct = hist[-1]
span = last_ts - first_ts
drop = first_pct - last_pct
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
return None
rate = drop / span # percent per second
return int(last_pct / rate)
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
@app.get("/frame/photo-info", dependencies=[Depends(require_access_token)])
def frame_photo_info():
"""Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same
idempotent current-photo semantics as /frame/image -- doesn't advance
anything."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
config.save(cfg)
if not cfg.current_asset_id:
raise HTTPException(404, "No current photo")
try:
asset = client.get_asset(cfg.current_asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
exif = asset.get("exifInfo") or {}
location = _format_location(exif)
return {
"asset_id": cfg.current_asset_id,
"location_line1": location[0] if location else None,
"location_line2": location[1] if location and location[1] else None,
"taken_at": _format_taken_at(exif),
}
@app.get("/frame/share/{asset_id}", dependencies=[Depends(require_access_token)])
def frame_share(asset_id: str):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to (the firmware bakes ?token= into that QR the same way it
does for the management QR, see frame_client.c's build_url()). The
link is created lazily, when this actually gets hit (i.e. when
someone scans it), not when the manage button was pressed, so the
30-minute window starts when it's actually used. Also scoped to the
photo currently showing or queued -- not any arbitrary Immich asset
id -- as a second layer even a leaked token wouldn't bypass."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
if asset_id != cfg.current_asset_id and asset_id not in cfg.queue:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
@app.get("/frame/face-labels", dependencies=[Depends(require_access_token)])
def frame_face_labels():
"""Named-face positions for the manage button's escalated "level 2"
menu -- who's in the current photo, per Immich's own face
recognition (no detection/recognition happens here, see
app/face_labels.py). Response is a flattened, fixed-slot shape
(name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a JSON array, so
the device's hand-rolled parser can read it with the same flat-
scalar helpers it already has, instead of needing a real array
parser. Empty (count: 0) if no faces are named, or if anything about
fetching them fails -- this is a "nice to have" addition to the
overlay, not worth failing the whole menu over."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
config.save(cfg)
if not cfg.current_asset_id:
return {"count": 0}
try:
faces = client.get_asset_faces(cfg.current_asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch faces for asset %s: %s", cfg.current_asset_id, e)
return {"count": 0}
if not any((face.get("person") or {}).get("name") for face in faces):
return {"count": 0} # skip the extra preview download in the common no-named-faces case
try:
preview_bytes = client.download_asset_preview(cfg.current_asset_id)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", cfg.current_asset_id, e)
return {"count": 0}
labels = compute_face_labels(preview_bytes, faces, cfg.smart_crop_faces, cfg.orientation)
result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels):
result[f"name_{i}"] = label["name"]
result[f"x_{i}"] = label["x"]
result[f"y_{i}"] = label["y"]
return result
@app.get("/api/queue", dependencies=[Depends(require_access_token)])
def api_queue():
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
current_changed = photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg))
queue_before = list(cfg.queue)
photo_queue.sync_queue_length(cfg, assets)
if current_changed or cfg.queue != queue_before:
config.save(cfg)
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
now = time.time()
return {
"current": entry(cfg.current_asset_id) if cfg.current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in cfg.queue],
"device": {
"last_seen": cfg.last_seen or None,
"overdue": bool(cfg.last_seen and now - cfg.last_seen > _max_expected_gap_s(cfg) * OVERDUE_FACTOR),
"firmware_version": cfg.device_firmware_version or None,
"firmware_available": cfg.firmware_available_version or None,
"battery": (
{"percent": cfg.battery_percent, "as_of": cfg.battery_as_of}
if cfg.battery_percent >= 0
else None
),
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
"battery_estimate_s": _battery_estimate_s(cfg),
},
}
@app.get("/api/battery-log", dependencies=[Depends(require_access_token)])
def api_battery_log():
cfg = config.load()
return {"log": cfg.battery_log}
class QueueReorderRequest(BaseModel):
queue: list[str]
@app.post("/api/queue/reorder", dependencies=[Depends(require_access_token)])
def api_queue_reorder(body: QueueReorderRequest):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
with config.locked():
cfg = config.load()
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
cfg.queue = reordered
config.save(cfg)
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@app.post("/api/queue/promote", dependencies=[Depends(require_access_token)])
def api_queue_promote(body: QueuePromoteRequest):
"""Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
client supplying a full, exactly-current snapshot of the queue at
all, so it can't fail due to the queue having shifted server-side
since the browser's last fetch."""
with config.locked():
cfg = config.load()
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
config.save(cfg)
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@app.post("/api/queue/remove", dependencies=[Depends(require_access_token)])
def api_queue_remove(body: QueueRemoveRequest):
"""Permanently removes a photo from this frame's rotation -- "Remove"
in the web UI, on either an upcoming card or the current photo. Does
NOT touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
config.save(cfg)
return {"status": "removed"}
@app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_access_token)])
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)
"""Routing hub: manage-QR scans go to the limited manage page, users
land on their first frame (or an empty-state page), and everyone
else is walked through setup/login."""
with SessionLocal() as db:
have_users = users_exist(db)
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
if manage_redirect is not None:
return RedirectResponse(manage_redirect, status_code=303)
user = current_user(request, db)
if user is None:
if not have_users:
if management_token() and not browser_token_valid(request):
supplied = request.query_params.get("token")
return templates.TemplateResponse(
"token_prompt.html", {"request": request, "wrong": supplied is not None}
)
# Pre-setup: reachable (optionally token-gated), nudge setup.
return RedirectResponse("/setup", status_code=303)
return RedirectResponse("/login", status_code=303)
frames = user_frames(db, user)
if frames:
return RedirectResponse(f"/frames/{frames[0].id}", status_code=303)
return templates.TemplateResponse("frames_empty.html", shell_context(request, db, user))
+212
View File
@@ -0,0 +1,212 @@
"""Composites the manage-button overlay -- "scan to manage" QR, battery,
location/date-taken, share-QR, named face labels -- server-side, onto an
already-composed image (any mode: a photo, or a calendar view), before
quantization. Replaces what used to be firmware/main/manage_qr_overlay.c
generating and positioning all of this on-device.
Corner/spacing constants below are plain Python now, not a protocol
contract with firmware -- adjustable here without touching anything else.
Uses the same toolkit image_pipeline.render_placeholder already does
(PIL ImageDraw/ImageFont, the qrcode library), just doing more with it.
"""
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
PADDING = 16
QR_TEXT_GAP = 8
LINE_GAP = 4
PANEL_MARGIN = 20
QR_TARGET_PX = 180
TITLE_FONT_SIZE = 22
BODY_FONT_SIZE = 20
BATTERY_ICON_W = 40
BATTERY_ICON_H = 22
BATTERY_ICON_STROKE = 2
BATTERY_NUB_W = 5
BATTERY_NUB_H = 10
BATTERY_ICON_TEXT_GAP = 8
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
FACE_LABEL_PADDING = 8
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
def _font(size: int) -> ImageFont.ImageFont:
return ImageFont.load_default(size=size)
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
import qrcode
qr = qrcode.QRCode(border=1, box_size=1)
qr.add_data(url)
qr.make(fit=True)
raw = qr.make_image().get_image().convert("RGB")
scale = max(1, target_px // raw.width)
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]:
"""(width, height) of `lines` stacked with LINE_GAP between them, at
`font` -- the box _draw_text_box below will need."""
w = 0
h = 0
for i, line in enumerate(lines):
bbox = draw.textbbox((0, 0), line, font=font)
w = max(w, bbox[2] - bbox[0])
h += (bbox[3] - bbox[1]) + (LINE_GAP if i else 0)
return w, h
def _draw_centered_lines(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
center_x: int, top: int) -> None:
y = top
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w = bbox[2] - bbox[0]
draw.text((center_x - w // 2, y), line, fill=(0, 0, 0), font=font)
y += (bbox[3] - bbox[1]) + LINE_GAP
def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: list[str],
corner: str) -> tuple[int, int, int, int]:
"""White-padded box with a QR code and centered caption lines below
it, placed in one of the panel's four corners. Returns (x0, y0, w, h)
-- callers that need to anchor something else relative to this box
(the battery, below the manage QR) use it instead of recomputing the
same geometry a second time."""
qr_img = _qr_image(url)
text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0)
content_w = max(qr_img.width, text_w)
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
w = content_w + PADDING * 2
h = content_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
center_x = x0 + w // 2
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
if caption:
_draw_centered_lines(draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
return x0, y0, w, h
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
"""White-padded box with centered text lines, placed in one of the
panel's four corners."""
font = _font(BODY_FONT_SIZE)
text_w, text_h = _text_box(draw, lines, font)
w = text_w + PADDING * 2
h = text_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
_draw_centered_lines(draw, lines, font, x0 + w // 2, y0 + PADDING)
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
img_w, img_h = img_size
box_w, box_h = box_size
if corner == "top-left":
return PANEL_MARGIN, PANEL_MARGIN
if corner == "top-right":
return img_w - PANEL_MARGIN - box_w, PANEL_MARGIN
if corner == "bottom-left":
return PANEL_MARGIN, img_h - PANEL_MARGIN - box_h
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
anchor_w: int, anchor_h: int) -> None:
"""Battery glyph + "NN%" text, right-aligned under the given anchor
box (the manage QR box) -- a sensible default position, not a
constraint anything else has to route around; move this call site's
arguments to place it anywhere else instead."""
font = _font(BODY_FONT_SIZE)
text = f"{percent}%"
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
text_w = draw.textlength(text, font=font)
content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w
content_h = max(font.size, BATTERY_ICON_H)
w = int(content_w + PADDING * 2)
h = int(content_h + PADDING * 2)
x0 = anchor_x0 + anchor_w - w
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
icon_x = x0 + PADDING
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
width=BATTERY_ICON_STROKE)
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
fill=(0, 0, 0))
draw.text((icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
text, fill=(0, 0, 0), font=font)
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
"""White-padded name label centered under an arbitrary (anchor_x,
anchor_y) point, flipped above if there's no room below, clamped to
stay fully on-panel -- unlike the four corner boxes (always in-bounds
by construction), a face can be anywhere, including near an edge."""
font = _font(BODY_FONT_SIZE)
text_w = draw.textlength(name, font=font)
bbox = draw.textbbox((0, 0), name, font=font)
text_h = bbox[3] - bbox[1]
w = int(text_w + FACE_LABEL_PADDING * 2)
h = int(text_h + FACE_LABEL_PADDING * 2)
img_w, img_h = img.size
x0 = anchor_x - w // 2
y0 = anchor_y + FACE_LABEL_GAP
if y0 + h > img_h:
y0 = anchor_y - FACE_LABEL_GAP - h # no room below -- place above instead
x0 = max(0, min(x0, img_w - w))
y0 = max(0, min(y0, img_h - h))
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
draw.text((x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, fill=(0, 0, 0), font=font)
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
location_lines: tuple[str, str] | None = None, taken_at: str | None = None,
share_url: str | None = None, face_labels: list[dict] | None = None) -> Image.Image:
"""Draws the manage overlay onto a copy of `image` (RGB, any mode's
already-composed/enhanced logical-space canvas) and returns it.
management_url's "scan to manage" box always shows; everything else
is optional and simply omitted when not given -- battery_percent
None or out of 0-100 skips the battery box, location_lines/taken_at/
share_url empty/None skip their own box, face_labels empty skips
those."""
img = image.copy()
draw = ImageDraw.Draw(img)
qr_x0, qr_y0, qr_w, qr_h = _draw_qr_box(img, draw, management_url, ["SCAN TO", "MANAGE"], "top-right")
if battery_percent is not None and 0 <= battery_percent <= 100:
_draw_battery(img, draw, battery_percent, qr_x0, qr_y0, qr_w, qr_h)
if location_lines and location_lines[0]:
lines = [line for line in location_lines if line]
_draw_text_box(img, draw, lines, "top-left")
if taken_at:
_draw_text_box(img, draw, [taken_at], "bottom-right")
if share_url:
_draw_qr_box(img, draw, share_url, ["SCAN TO", "DOWNLOAD"], "bottom-left")
for label in face_labels or []:
if label.get("name"):
_draw_face_label(img, draw, label["name"], label["x"], label["y"])
return img
+241
View File
@@ -0,0 +1,241 @@
"""Schema versioning + one-time import of a legacy config.json deployment.
Hand-rolled on purpose (vs alembic): single worker, single SQLite file,
~30 lines of runner. Each migration is (version, fn(connection)); v1 is
just create_all. DDL stays dialect-neutral so a future move to Postgres
is a DATABASE_URL change, not a rewrite.
Run at import time from main.py, before any request is served.
"""
from __future__ import annotations
import logging
import secrets
import shutil
import time
from sqlalchemy import select, text
from . import config
from .db import SessionLocal, engine
from .models import Base, BatteryLog, Frame, ServerSettings
logger = logging.getLogger(__name__)
def _migration_1(conn) -> None:
Base.metadata.create_all(bind=conn)
def _migration_2(conn) -> None:
"""Adds email (users) and battery-alert threshold (frames) columns,
plus the new server_settings/password_reset_tokens tables. ALTER
TABLE ADD COLUMN with a default is safe on SQLite against a live,
already-populated database -- existing rows just get the default."""
conn.execute(text("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1"))
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_sent INTEGER NOT NULL DEFAULT 0"))
Base.metadata.create_all(bind=conn) # creates the two new tables only; existing ones untouched
def _migration_3(conn) -> None:
"""Replaces the STARTTLS-or-nothing smtp_use_tls boolean with a
three-way smtp_encryption ("none"/"starttls"/"ssl") -- implicit TLS
(port 465 typically) is a different handshake entirely, not just a
skipped starttls() call, so it needs its own connection path in
app/mail.py."""
conn.execute(text("ALTER TABLE server_settings ADD COLUMN smtp_encryption TEXT NOT NULL DEFAULT 'starttls'"))
conn.execute(text(
"UPDATE server_settings SET smtp_encryption = CASE WHEN smtp_use_tls THEN 'starttls' ELSE 'none' END"
))
conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))
def _migration_4(conn) -> None:
"""Advanced configuration: a per-frame color palette override. NULL
for every existing row -- exactly "use the default", no behavior
change until a frame's Configuration tab sets one."""
conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT"))
def _migration_5(conn) -> None:
"""Replaces the smart_crop_faces boolean with display_mode (see
image_pipeline.DISPLAY_MODES) -- crop_faces/crop_fill are exactly
the old True/False behavior, stretch_fill/letterbox are new."""
conn.execute(text("ALTER TABLE frames ADD COLUMN display_mode TEXT NOT NULL DEFAULT 'crop_faces'"))
conn.execute(text(
"UPDATE frames SET display_mode = CASE WHEN smart_crop_faces THEN 'crop_faces' ELSE 'crop_fill' END"
))
conn.execute(text("ALTER TABLE frames DROP COLUMN smart_crop_faces"))
def _migration_6(conn) -> None:
"""Advanced configuration: color/contrast enhancement + dithering
strength (image_pipeline.render_frame). Defaults (1.0/1.0/1.0)
reproduce the exact previous rendering -- no behavior change until a
frame's Configuration tab adjusts one."""
conn.execute(text("ALTER TABLE frames ADD COLUMN color_boost REAL NOT NULL DEFAULT 1.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN contrast_boost REAL NOT NULL DEFAULT 1.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))
def _migration_7(conn) -> None:
"""Calendar frame mode: a personal ICS subscription per user
(users.calendar_ics_url), an explicit per-(user,frame) opt-in into a
frame's merged calendar (user_frames.calendar_included, default off
-- linking to a frame does not auto-include your calendar there),
and the frame-level view/inlay/browse-offset/cache settings calendar
mode needs (see calendar_feed.py, calendar_render.py,
routers/device.py's RENDERERS["calendar"]). Every new column has a
behavior-preserving default -- no existing frame's behavior changes
until its mode is actually switched to "calendar"."""
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_ics_url TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE user_frames ADD COLUMN calendar_included INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_view TEXT NOT NULL DEFAULT 'agenda'"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_photo_inlay INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_browse_offset INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_cached_events TEXT"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
(3, _migration_3),
(4, _migration_4),
(5, _migration_5),
(6, _migration_6),
(7, _migration_7),
]
def run_migrations() -> None:
with engine.begin() as conn:
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
if row is None:
# Brand new database: _migration_1's create_all() already
# produces today's full schema straight from models.py.
# Every migration after it is an incremental ALTER/UPDATE
# meant to bring an *existing* install forward -- replaying
# those here would just collide with columns create_all
# already added (e.g. "duplicate column name"). Jump
# straight to the latest version instead.
_migration_1(conn)
latest = MIGRATIONS[-1][0]
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
else:
current = row[0]
for version, fn in MIGRATIONS:
if version > current:
logger.info("Applying schema migration %d", version)
fn(conn)
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
_ensure_frame_one()
_ensure_server_settings()
def new_device_token() -> str:
return secrets.token_urlsafe(32)
def new_manage_token() -> str:
return secrets.token_urlsafe(16)
def _ensure_frame_one() -> None:
"""First boot only (frames table empty): create frame #1 -- imported
verbatim from a legacy config.json if one exists, otherwise fresh
defaults. Either way it's the legacy-token frame: the deployed
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
and require_device resolves those requests here. The frames-nonempty
guard makes this idempotent; config.json is left untouched as the
rollback path."""
with SessionLocal() as db:
if db.scalars(select(Frame).limit(1)).first() is not None:
return
cfg = config.load() # all defaults if the file doesn't exist
had_file = config.CONFIG_PATH.exists()
frame = Frame(
name="Frame 1",
device_id=None,
device_token=new_device_token(),
manage_token=new_manage_token(),
legacy_token_enabled=True,
created_at=time.time(),
immich_url=cfg.immich_url,
immich_api_key=cfg.immich_api_key,
album_id=cfg.album_id,
order=cfg.order,
refresh_interval_s=cfg.refresh_interval_s,
quiet_hours_enabled=cfg.quiet_hours_enabled,
quiet_hours_start=cfg.quiet_hours_start,
quiet_hours_end=cfg.quiet_hours_end,
timezone=cfg.timezone,
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
orientation=cfg.orientation,
queue_target_len=cfg.queue_target_len,
current_asset_id=cfg.current_asset_id,
current_asset_set_at=cfg.current_asset_set_at,
queue=list(cfg.queue),
queue_cursor=cfg.queue_cursor,
history=list(cfg.history),
excluded_asset_ids=list(cfg.excluded_asset_ids),
battery_percent=cfg.battery_percent,
battery_as_of=cfg.battery_as_of,
battery_history=[list(pair) for pair in cfg.battery_history],
last_seen=cfg.last_seen,
device_firmware_version=cfg.device_firmware_version,
device_board_variant=cfg.device_board_variant,
firmware_available_version=cfg.firmware_available_version,
firmware_update_repo_url=cfg.firmware_update_repo_url,
firmware_auto_update=cfg.firmware_auto_update,
firmware_update_token=cfg.firmware_update_token,
firmware_update_checked_at=cfg.firmware_update_checked_at,
firmware_gitea_latest_version=cfg.firmware_gitea_latest_version,
stats_first_seen=cfg.stats.first_seen,
stats_device_wakes=cfg.stats.device_wakes,
stats_photos_displayed=cfg.stats.photos_displayed,
stats_photos_removed=cfg.stats.photos_removed,
stats_battery_reports=cfg.stats.battery_reports,
stats_recharge_cycles=cfg.stats.recharge_cycles,
stats_ota_updates_applied=cfg.stats.ota_updates_applied,
stats_config_saves=cfg.stats.config_saves,
)
db.add(frame)
db.flush() # assign frame.id for the battery log rows
for pair in cfg.battery_log:
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
db.commit()
# The single legacy firmware slot becomes frame #1's per-frame slot.
legacy_bin = config.CONFIG_PATH.parent / "firmware.bin"
if legacy_bin.exists():
per_frame_dir = config.CONFIG_PATH.parent / "firmware"
per_frame_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(legacy_bin, per_frame_dir / f"{frame.id}.bin")
if had_file:
logger.info(
"Imported legacy config.json as frame #%d (%d battery log entries)",
frame.id,
len(cfg.battery_log),
)
else:
logger.info("Fresh install: created default frame #%d", frame.id)
def _ensure_server_settings() -> None:
"""The SMTP config singleton (id=1) -- created with everything blank
(email sending disabled) the first time this runs; /admin edits it in
place from then on."""
with SessionLocal() as db:
if db.get(ServerSettings, 1) is None:
db.add(ServerSettings(id=1))
db.commit()
+306
View File
@@ -0,0 +1,306 @@
"""SQLAlchemy models: users, sessions, frames, links, claims, battery log.
One deliberately WIDE `frames` row per frame (settings + state + telemetry
+ stats together): every device request touches exactly one row, so the
per-frame lock in db.frame_locked() keeps the old whole-config-lock
semantics trivially correct, and SQLite doesn't care about row width.
The queue/history/excluded/battery_history columns are MutableList-mapped
JSON: photo_queue.py mutates them in place (pop/append/insert), which a
plain JSON column would silently not persist -- MutableList marks the row
dirty on in-place changes.
The ORM attribute for the photo ordering setting is `order` (matching the
old FrameConfig field name so photo_queue.py ports unchanged) but the
column is named photo_order to stay clear of the SQL keyword.
"""
from __future__ import annotations
import time
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
# Normalized to lowercase in code before insert/lookup -- portable
# case-insensitive uniqueness without SQLite-only COLLATE NOCASE.
username: Mapped[str] = mapped_column(String, unique=True)
display_name: Mapped[str] = mapped_column(String, default="")
# Pluggable identity: "local" now; an OIDC provider later would set
# provider_subject and leave password_hash NULL.
identity_provider: Mapped[str] = mapped_column(String, default="local")
provider_subject: Mapped[str] = mapped_column(String, default="")
password_hash: Mapped[str | None] = mapped_column(String, nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="")
# Password-reset emails and battery-threshold alerts (frames.owner's
# email -- see routers/device.py's frame_battery) go here; blank = no
# email configured, both features silently no-op for this user.
email: Mapped[str] = mapped_column(String, default="")
# Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar
# frame mode -- see calendar_feed.py. Setting this alone shows up
# nowhere: a linked frame only pulls this user's events in once
# they've also opted in on that frame's own Configuration -> Calendar
# card (UserFrame.calendar_included below).
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
Index(
"ix_users_provider_subject",
"identity_provider",
"provider_subject",
unique=True,
sqlite_where=provider_subject != "",
),
)
class UserSession(Base):
__tablename__ = "sessions"
id: Mapped[int] = mapped_column(primary_key=True)
token_hash: Mapped[str] = mapped_column(String, unique=True) # sha256 hex of cookie value
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
csrf_token: Mapped[str] = mapped_column(String)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float, index=True)
user: Mapped[User] = relationship()
class Frame(Base):
__tablename__ = "frames"
id: Mapped[int] = mapped_column(primary_key=True)
# 12 lowercase hex chars of the device's full STA MAC. NULL only for
# the migrated legacy frame until its device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="")
# Renderer dispatch seam for future calendar/canva modes -- only
# "photos" is registered today (see routers/device.py RENDERERS).
mode: Mapped[str] = mapped_column(String, default="photos")
# Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# "Take control" soft lock -- only this user may mutate settings/queue.
controlled_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
device_token: Mapped[str] = mapped_column(String)
# Device has authenticated with device_token at least once -- stop
# pushing it in /frame/config responses.
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
manage_token: Mapped[str] = mapped_column(String, unique=True)
# Migration window: this frame also accepts the legacy shared
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
# migrated frame #1; cleared from /admin once the device is on
# per-frame auth.
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Migration staging only: Immich creds imported from the legacy
# config.json/env live here until /setup copies them to admin #1.
# Runtime resolution prefers owner creds, then env, then these (see
# routers/common.py immich_creds()).
immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="")
# -- settings (attribute names match the old FrameConfig fields) --
album_id: Mapped[str] = mapped_column(String, default="")
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
timezone: Mapped[str] = mapped_column(String, default="UTC")
# How a photo's aspect ratio is reconciled with the panel's -- see
# image_pipeline.DISPLAY_MODES.
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
orientation: Mapped[str] = mapped_column(String, default="landscape")
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
# default -- most frames never touch this.
palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged
# (see image_pipeline.render_frame).
color_boost: Mapped[float] = mapped_column(Float, default=1.0)
contrast_boost: Mapped[float] = mapped_column(Float, default=1.0)
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
# original always-on full-strength Floyd-Steinberg dithering.
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) --
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
# Agenda view only; reuses this frame's existing photos-mode album/
# queue, not a separate photo setup.
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
# How many periods (unit depends on calendar_view: days/weeks/months)
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
# (non-button) /frame/image request, and whenever calendar_view
# itself changes -- a stale offset means something different in a
# different view's units.
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
# Throttled merge-fetch cache (see routers/common.py's
# get_or_refresh_calendar_events) -- same shape as the
# firmware_update_checked_at/firmware_gitea_latest_version pattern
# below. One shared cache for every included user's merged events,
# not per-user.
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
# calendars unavailable" -- never names which user's feed failed, a
# shared household display shouldn't call out a specific person's
# outage to everyone who looks at it.
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
# -- telemetry --
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
# Current discharge cycle only (reset on recharge detection); the
# permanent record is the battery_log table.
battery_history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
last_seen: Mapped[float] = mapped_column(Float, default=0.0)
device_firmware_version: Mapped[str] = mapped_column(String, default="")
device_board_variant: Mapped[str] = mapped_column(String, default="")
# Battery-low email alert (see routers/device.py's frame_battery).
# -1 = disabled. Sent to the owner's email once per discharge cycle
# (battery_alert_sent resets alongside battery_history whenever a
# recharge is detected, same trigger as stats_recharge_cycles).
battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
firmware_available_version: Mapped[str] = mapped_column(String, default="")
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
firmware_auto_update: Mapped[bool] = mapped_column(Boolean, default=False)
firmware_update_token: Mapped[str] = mapped_column(String, default="")
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
# -- stats (flattened from the old nested FrameStats) --
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
stats_photos_displayed: Mapped[int] = mapped_column(Integer, default=0)
stats_photos_removed: Mapped[int] = mapped_column(Integer, default=0)
stats_battery_reports: Mapped[int] = mapped_column(Integer, default=0)
stats_recharge_cycles: Mapped[int] = mapped_column(Integer, default=0)
stats_ota_updates_applied: Mapped[int] = mapped_column(Integer, default=0)
stats_config_saves: Mapped[int] = mapped_column(Integer, default=0)
owner: Mapped[User | None] = relationship(foreign_keys=[owner_user_id])
controlled_by: Mapped[User | None] = relationship(foreign_keys=[controlled_by_user_id])
class UserFrame(Base):
"""A user linked to a frame: sees it in their sidebar, may view its
pages, and may take control. Ownership (whose Immich creds the frame
renders from) is frames.owner_user_id, separate from linking."""
__tablename__ = "user_frames"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
frame_id: Mapped[int] = mapped_column(
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Explicit per-(user,frame) opt-in for calendar frame mode -- being
# linked to a frame does NOT by itself contribute this user's
# calendar to it (deliberate choice, not an oversight: each person's
# calendar is their own data to share or not, not something a
# frame's controller decides on their behalf). Meaningless if the
# user has no calendar_ics_url set. See routers/api_frames.py's
# api_calendar_included.
calendar_included: Mapped[bool] = mapped_column(Boolean, default=False)
class PendingClaim(Base):
"""A claim submitted before the frame's first check-in (the user beat
the device to the server after provisioning). Attached automatically
when a device with this id self-registers; expired rows are pruned
opportunistically."""
__tablename__ = "pending_claims"
device_id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float)
class ServerSettings(Base):
"""Singleton row (id always 1) holding operator-level SMTP config, set
from /admin -- not env vars, since this is infrastructure a household
admin configures once through the UI rather than at container
deploy time. Used for password-reset emails and battery-threshold
alerts (see app/mail.py). smtp_host empty = email sending disabled;
every send site checks that and no-ops rather than erroring."""
__tablename__ = "server_settings"
id: Mapped[int] = mapped_column(primary_key=True)
smtp_host: Mapped[str] = mapped_column(String, default="")
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_username: Mapped[str] = mapped_column(String, default="")
smtp_password: Mapped[str] = mapped_column(String, default="")
smtp_from_address: Mapped[str] = mapped_column(String, default="")
# "none" (plaintext, port 25 typically), "starttls" (upgrades a
# plaintext connection, port 587 typically), or "ssl" (TLS from the
# first byte -- a different handshake entirely, not just starttls()
# skipped; port 465 typically). See app/mail.py.
smtp_encryption: Mapped[str] = mapped_column(String, default="starttls")
class PasswordResetToken(Base):
"""A single-use, time-limited "forgot password" link. token is the
URL-safe secret itself (not hashed, like PendingClaim/manage_token --
it's a short-lived bearer credential emailed once, not a long-lived
session secret)."""
__tablename__ = "password_reset_tokens"
token: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float)
class BatteryLog(Base):
"""Every battery report ever, per frame -- the permanent record behind
the battery history chart (was a 20k-entry JSON array in config.json)."""
__tablename__ = "battery_log"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
ts: Mapped[float] = mapped_column(Float)
percent: Mapped[int] = mapped_column(Integer)
__table_args__ = (Index("ix_battery_log_frame_ts", "frame_id", "ts"),)
+11 -11
View File
@@ -34,12 +34,12 @@ from __future__ import annotations
import random
import time
from .config import FrameConfig
from .models import Frame
HISTORY_MAX_LEN = 20
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
def _top_up(cfg: Frame, assets: list[dict]) -> None:
valid_ids = {a["id"] for a in assets}
excluded_ids = set(cfg.excluded_asset_ids)
cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids and asset_id not in excluded_ids]
@@ -85,7 +85,7 @@ def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
def advance_forced(cfg: Frame, assets: list[dict]) -> None:
"""Unconditionally moves to the next photo, ignoring elapsed time, and
resets the interval clock from now. Used by the explicit next-photo
action (POST /frame/advance) and by get_current() once the refresh
@@ -105,7 +105,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
cfg.stats_photos_displayed += 1
# 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
@@ -113,7 +113,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
_top_up(cfg, assets)
def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
def back_forced(cfg: Frame, assets: list[dict]) -> bool:
"""Unconditionally moves to the previously-current photo, the mirror
image of advance_forced() -- pops the most recent entry off history,
pushes the photo it's replacing onto the front of queue (so pressing
@@ -132,12 +132,12 @@ def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
cfg.queue.insert(0, cfg.current_asset_id)
cfg.current_asset_id = previous_id
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
cfg.stats_photos_displayed += 1
return True
return False
def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) -> bool:
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
"""Permanently excludes asset_id from this frame's rotation (see the
module docstring) -- doesn't touch Immich, just this frame's own
selection. Scrubs it out of queue and history too, so it can't
@@ -149,7 +149,7 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
changed as a result."""
if asset_id not in cfg.excluded_asset_ids:
cfg.excluded_asset_ids.append(asset_id)
cfg.stats.photos_removed += 1
cfg.stats_photos_removed += 1
cfg.queue = [a for a in cfg.queue if a != asset_id]
cfg.history = [a for a in cfg.history if a != asset_id]
@@ -167,12 +167,12 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
cfg.current_asset_id = remaining[0] if remaining else ""
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
cfg.stats_photos_displayed += 1
_top_up(cfg, assets)
return True
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
otherwise touching current_asset_id. Used by GET /api/queue so a
change to the "upcoming photos to show" setting takes effect on page
@@ -180,7 +180,7 @@ def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
_top_up(cfg, assets)
def get_current(cfg: FrameConfig, assets: list[dict], in_quiet_hours: bool = False) -> bool:
def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) -> 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
+125
View File
@@ -0,0 +1,125 @@
"""Quiet-hours math, extracted verbatim from the old main.py. Everything
takes the frame-like object duck-typed on quiet_hours_enabled/start/end,
timezone, and refresh_interval_s -- both the old FrameConfig and the
Frame ORM model satisfy it."""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo, available_timezones
# Populated once from the OS's zoneinfo database (installed via the
# `tzdata` apt package in the Dockerfile) and offered as a <select> in the
# web UI's "Timezone" field.
ALL_TIMEZONES = sorted(available_timezones())
def valid_hhmm(s: str) -> bool:
try:
datetime.strptime(s, "%H:%M")
return True
except ValueError:
return False
def _zoneinfo(name: str) -> ZoneInfo:
"""Falls back to UTC for an unrecognized zone name -- defensive only;
the config-save route validates against ALL_TIMEZONES before saving,
so this only matters for state hand-edited or written by an older
version of this code."""
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo("UTC")
def local_date(cfg) -> date:
"""`date.today()` in cfg.timezone (falls back to UTC for an
unrecognized zone, same as _zoneinfo) -- what calendar mode's "today"
anchor and browse-offset both key off of, so every part of that
feature agrees on what day it is for a given frame."""
return datetime.now(_zoneinfo(cfg.timezone)).date()
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
"""Whether `now` falls inside the quiet-hours window, and the next
boundary: if inside, when it ends; if outside, when it next starts.
Handles a window that wraps past midnight (e.g. 22:00-07:00). Returns
(False, None) for a degenerate window (start == end)."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
if start == end:
return False, None
if start < end:
# Same-day window, e.g. 13:00-15:00. End is exclusive, so being
# exactly at `end` counts as already outside the window.
if start <= now < end:
return True, end
if now < start:
return False, start
return False, start + timedelta(days=1)
# Wraps midnight, e.g. 22:00-07:00.
if now >= start:
return True, end + timedelta(days=1)
if now < end:
return True, end
return False, start
def _quiet_hours_span_s(start_str: str, end_str: str) -> int:
"""Duration of the quiet-hours window in seconds, wrap-aware."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
span_min = ((eh * 60 + em) - (sh * 60 + sm)) % (24 * 60)
return span_min * 60
def effective_refresh_interval_s(cfg) -> int:
"""The refresh interval actually handed to the device: its configured
value, unless quiet hours are enabled, in which case it's clamped so
the device sleeps through the whole window instead of waking inside
it. A device already mid-sleep when quiet hours begin can still land
one wake inside the window (nothing server-side can prevent that
without touching the firmware) -- but from that wake on, it's told to
sleep exactly until the window ends."""
if not cfg.quiet_hours_enabled:
return cfg.refresh_interval_s
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
if boundary is None:
return cfg.refresh_interval_s
seconds_to_boundary = max(1, int((boundary - now).total_seconds()))
if in_quiet:
return seconds_to_boundary
return min(cfg.refresh_interval_s, seconds_to_boundary)
def in_quiet_hours(cfg) -> bool:
"""Whether quiet hours are in effect right now -- separate from
effective_refresh_interval_s, which only shapes what the *device* is
told to sleep for. This instead gates photo_queue.get_current()'s
time-based advance, since that check runs independent of the device
(also triggered by the web UI's queue endpoint, e.g. an open browser
tab polling overnight) and would otherwise happily advance the
current photo mid-quiet-hours on raw elapsed time alone."""
if not cfg.quiet_hours_enabled:
return False
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, _ = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
return in_quiet
def max_expected_gap_s(cfg) -> int:
"""Longest gap between wakes the device might legitimately have --
normally just refresh_interval_s, but quiet hours can make the real
gap much longer, and the "overdue" check shouldn't mistake a device
quietly sleeping through the night for a dead one."""
gap = cfg.refresh_interval_s
if cfg.quiet_hours_enabled:
gap = max(gap, _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end))
return gap
View File
+600
View File
@@ -0,0 +1,600 @@
"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...
Auth: session-only (require_frame_view for reads, require_frame_control
for mutations -- the "take control" soft lock). The limited manage-QR
surface lives separately under /api/m/ (routers/manage.py), and device
traffic under /frame/* (routers/device.py).
Config saves are PARTIAL updates: each page's form posts only its own
fields (the old single Settings form split across the Photos and
Configuration tabs), so every field is optional and only provided ones
are touched. Checkboxes are sent explicitly as "true"/"false" strings by
the page JS -- an absent field means "not this form's field", never
"unchecked".
"""
from __future__ import annotations
import logging
import time
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..image_pipeline import (
DEFAULT_DISPLAY_MODE,
DISPLAY_MODES,
PALETTE_LABELS,
hex_to_rgb,
render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, UserFrame
from .common import (
FRAME_MODES,
OVERDUE_FACTOR,
battery_estimate_s,
calendar_sources_for_frame,
fetch_source_and_faces,
get_or_refresh_calendar_events,
immich_client_for,
immich_creds,
list_assets,
require_configured,
valid_http_url,
)
logger = logging.getLogger(__name__)
router = APIRouter()
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
@router.get("/api/frames/{frame_id}/albums")
def api_albums(frame: Frame = Depends(require_frame_view)):
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)")
try:
albums = immich_client_for(frame).list_albums()
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {url}: {e}") from e
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@router.post("/api/frames/{frame_id}/config")
def api_config_save(
name: str | None = Form(None),
album_id: str | None = Form(None),
order: str | None = Form(None),
refresh_interval_s: int | None = Form(None),
display_mode: str | None = Form(None),
queue_target_len: int | None = Form(None),
orientation: str | None = Form(None),
quiet_hours_enabled: bool | None = Form(None),
quiet_hours_start: str | None = Form(None),
quiet_hours_end: str | None = Form(None),
timezone: str | None = Form(None),
firmware_update_repo_url: str | None = Form(None),
firmware_auto_update: bool | None = Form(None),
battery_alert_threshold_pct: int | None = Form(None),
palette: list[str] | None = Form(None),
palette_reset: bool | None = Form(None),
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
mode: str | None = Form(None),
calendar_view: str | None = Form(None),
calendar_photo_inlay: bool | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
with frame_locked(db, frame.id) as cfg:
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
if album_id is not None and album_id != cfg.album_id:
# 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.history = []
cfg.excluded_asset_ids = []
cfg.album_id = album_id
if order is not None:
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
if refresh_interval_s is not None:
cfg.refresh_interval_s = max(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
)
if display_mode is not None:
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
if queue_target_len is not None:
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
if quiet_hours_enabled is not None:
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
cfg.quiet_hours_start = quiet_hours_start
if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end):
cfg.quiet_hours_end = quiet_hours_end
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
cfg.timezone = timezone
if firmware_update_repo_url is not None:
stripped = firmware_update_repo_url.strip()
if stripped and not valid_http_url(stripped):
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
cfg.firmware_update_repo_url = stripped
if firmware_auto_update is not None:
cfg.firmware_auto_update = firmware_auto_update
if battery_alert_threshold_pct is not None:
cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
# A changed threshold should be able to fire again immediately,
# not stay suppressed by a flag set under the old value.
cfg.battery_alert_sent = False
if palette_reset:
cfg.palette_rgb = None
elif palette is not None:
if len(palette) != len(PALETTE_LABELS):
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
parsed = [hex_to_rgb(h) for h in palette]
if any(rgb is None for rgb in parsed):
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
cfg.palette_rgb = [list(rgb) for rgb in parsed]
if color_boost is not None:
cfg.color_boost = max(0.0, min(2.0, color_boost))
if contrast_boost is not None:
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
if mode is not None:
cfg.mode = mode if mode in FRAME_MODES else "photos"
if calendar_view is not None:
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
if new_view != cfg.calendar_view:
# A stale offset means something different in a different
# view's units (days vs. weeks vs. months) -- same
# reasoning as album_id's reset above.
cfg.calendar_browse_offset = 0
cfg.calendar_view = new_view
if calendar_photo_inlay is not None:
cfg.calendar_photo_inlay = calendar_photo_inlay
cfg.stats_config_saves += 1
return {"status": "saved"}
@router.post("/api/frames/{frame_id}/take-control")
def api_take_control(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""Always succeeds for any linked user -- the lock is deliberately
soft. The previous holder just sees who has it now."""
user = require_user_api(request, db)
previous = frame.controlled_by
frame.controlled_by_user_id = user.id
db.commit()
logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id,
previous.username if previous else "nobody")
return {"status": "saved", "controller": user.display_name or user.username}
@router.get("/api/frames/{frame_id}/stats")
def api_stats(frame: Frame = Depends(require_frame_view)):
return {
"first_seen": frame.stats_first_seen,
"device_wakes": frame.stats_device_wakes,
"photos_displayed": frame.stats_photos_displayed,
"photos_removed": frame.stats_photos_removed,
"battery_reports": frame.stats_battery_reports,
"recharge_cycles": frame.stats_recharge_cycles,
"ota_updates_applied": frame.stats_ota_updates_applied,
"config_saves": frame.stats_config_saves,
}
@router.get("/api/frames/{frame_id}/queue")
def api_queue(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
user = require_user_api(request, db)
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
snapshot = {
"current_asset_id": cfg.current_asset_id,
"queue": list(cfg.queue),
"last_seen": cfg.last_seen,
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
"firmware_version": cfg.device_firmware_version,
"firmware_available": cfg.firmware_available_version,
"battery_percent": cfg.battery_percent,
"battery_as_of": cfg.battery_as_of,
"battery_estimate_s": battery_estimate_s(cfg),
"controller_id": cfg.controlled_by_user_id,
"controller": (
(cfg.controlled_by.display_name or cfg.controlled_by.username)
if cfg.controlled_by
else None
),
}
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}
now = time.time()
return {
"current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
"upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
"control": {
"controller": snapshot["controller"],
"you": snapshot["controller_id"] == user.id,
},
"device": {
"last_seen": snapshot["last_seen"] or None,
"overdue": bool(
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
),
"firmware_version": snapshot["firmware_version"] or None,
"firmware_available": snapshot["firmware_available"] or None,
"battery": (
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
if snapshot["battery_percent"] >= 0
else None
),
"battery_estimate_s": snapshot["battery_estimate_s"],
},
}
@router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute(
select(BatteryLog.ts, BatteryLog.percent)
.where(BatteryLog.frame_id == frame.id)
.order_by(BatteryLog.ts)
).all()
return {"log": [[ts, percent] for ts, percent in rows]}
class QueueReorderRequest(BaseModel):
queue: list[str]
@router.post("/api/frames/{frame_id}/queue/reorder")
def api_queue_reorder(
body: QueueReorderRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
with frame_locked(db, frame.id) as cfg:
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
cfg.queue = reordered
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/frames/{frame_id}/queue/promote")
def api_queue_promote(
body: QueuePromoteRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Moves a single photo to the front of the queue -- "Show next".
Unlike reorder, doesn't depend on the client knowing the queue's
exact current order, so it can't fail from staleness."""
with frame_locked(db, frame.id) as cfg:
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@router.post("/api/frames/{frame_id}/queue/remove")
def api_queue_remove(
body: QueueRemoveRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Permanently removes a photo from this frame's rotation. Does NOT
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
return {"status": "removed"}
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
"""Scoped to what this frame is actually showing/queuing -- a user
merely linked to view this frame shouldn't be able to pull thumbnails
for arbitrary asset ids in the owner's Immich library, only the
frame's own curated album. Same rule device.frame_share and
manage.manage_thumbnail already enforce."""
require_configured(frame)
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame)
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)
def _current_asset_id(frame: Frame, db: Session) -> str:
"""Same idempotent get_current() dance /api/frames/{id}/queue uses --
picks a current photo if none is set yet, otherwise just reads it,
never advances early."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
asset_id = cfg.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
return asset_id
@router.get("/api/frames/{frame_id}/preview/original")
def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The Immich preview image behind the currently-displayed photo,
unprocessed -- the "now displaying" side of the Configuration tab's
before/after comparison."""
asset_id = _current_asset_id(frame, db)
client = immich_client_for(frame)
try:
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
return Response(content=jpeg_bytes, media_type="image/jpeg")
@router.get("/api/frames/{frame_id}/preview/rendered")
def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The same photo run through this frame's actual saved rendering
pipeline (display mode, palette, color/contrast/dithering) and
exported as a PNG -- the "how it will look on the frame" side of the
comparison. Not a live preview of unsaved slider values; reflects
whatever's currently saved."""
asset_id = _current_asset_id(frame, db)
client = immich_client_for(frame)
source, faces = fetch_source_and_faces(client, frame, asset_id)
png = render_preview_png(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=frame.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
)
return Response(content=png, media_type="image/png")
class CalendarIncludedRequest(BaseModel):
included: bool
@router.post("/api/frames/{frame_id}/calendar-included")
def api_calendar_included(
body: CalendarIncludedRequest,
request: Request,
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
db: Session = Depends(get_db),
):
"""A user's own opt-in into this frame's merged calendar (see
UserFrame.calendar_included). Deliberately not require_frame_control:
this is the toggling user's own data-sharing preference about their
own calendar, not a frame setting its controller manages on someone
else's behalf -- there's no target user_id in the request body by
design, it always toggles the calling session's own row."""
user = require_user_api(request, db)
row = db.get(UserFrame, (user.id, frame.id))
if row is None:
raise HTTPException(404, "Not linked to this frame")
row.calendar_included = body.included
# Force this frame's merged cache to pick up the change promptly
# rather than waiting out the throttle.
frame.calendar_checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.calendar_included}
def _calendar_photo_inlay(frame: Frame, db: Session):
"""The agenda view's optional photo-inlay source image, or None if
inlay is off, not agenda view, or the frame's photos-mode album isn't
configured. Shared shape between the live render (routers/device.py's
_render_calendar_mode) and this preview endpoint; small enough that
duplicating rather than factoring out is fine, since the two call
sites differ slightly in error handling."""
if not (frame.calendar_view == "agenda" and frame.calendar_photo_inlay):
return None
url, key = immich_creds(frame)
if not (url and key and frame.album_id):
return None
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
return None
import io
from PIL import Image
return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
except HTTPException:
return None
@router.get("/api/frames/{frame_id}/preview/calendar")
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The same merged, cached event set a live device render would use
-- not a live preview of an unsaved calendar_view choice, same
"reflects what's currently saved" convention as preview/rendered."""
if not calendar_sources_for_frame(db, frame):
raise HTTPException(400, "No calendars included on this frame yet")
events, summary = get_or_refresh_calendar_events(db, frame)
photo_inlay = _calendar_photo_inlay(frame, db)
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
png = calendar_render.render_calendar_preview_png(
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
)
return Response(content=png, media_type="image/png")
@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
file: UploadFile = File(...),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Uploads a firmware image for OTA. The version is parsed out of the
image itself (esp_app_desc_t) rather than trusted from a filename or
form field, and the project name is checked so an unrelated .bin
can't be pushed to the frame by mistake."""
data = file.file.read()
version = parse_app_version(data)
path = firmware_path(frame.id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_available_version = version
return {"status": "saved", "version": version, "size": len(data)}
def _fetch_latest_release(frame: Frame) -> dict | None:
try:
return gitea_releases.fetch_latest_release(
frame.firmware_update_repo_url, frame.firmware_update_token
)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Gitea at {frame.firmware_update_repo_url}: {e}") from e
def _apply_gitea_update(db: Session, frame: Frame) -> str:
"""Downloads the configured Gitea repo's latest release asset for this
frame's board variant (learned from the device's X-Frame-Board
header, never picked by hand) and stages it exactly like a manual
upload. Network I/O happens before the lock is taken."""
if not frame.device_board_variant:
raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
release = _fetch_latest_release(frame)
if not release:
raise HTTPException(404, "No releases found in the configured Gitea repo")
asset_name = gitea_releases.asset_name_for_board(frame.device_board_variant)
asset_url = release["assets"].get(asset_name)
if not asset_url:
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
try:
data = gitea_releases.download_asset(asset_url, frame.firmware_update_token)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
version = parse_app_version(data) # same validation the manual upload path applies
path = firmware_path(frame.id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_available_version = version
cfg.firmware_gitea_latest_version = version
cfg.firmware_update_checked_at = time.time()
return version
@router.post("/api/frames/{frame_id}/firmware/check")
def api_firmware_check(
force: bool = False, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
"""Throttled check of the configured Gitea repo's latest release
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
on and a newer version is found, applies it immediately; otherwise
just reports it so the UI can offer the "Update frame" button.
force=true (the "Check now" button) bypasses the throttle.
require_frame_control (not view), and POST (not GET): this can
silently stage new firmware as a side effect (the auto-apply path
below) exactly like /firmware/apply-latest, so it needs the same
guard that route has -- a linked viewer without control shouldn't be
able to trigger that, and as a GET it would've been exempt from the
CSRF check require_user_api only applies to non-GET/HEAD/OPTIONS."""
if not frame.firmware_update_repo_url:
return {"enabled": False}
now = time.time()
if force or now - frame.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
# checked_at only advances on a successful reach, so a Gitea
# outage gets retried every poll instead of waiting out the full
# throttle interval.
release = _fetch_latest_release(frame)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_update_checked_at = now
if release:
cfg.firmware_gitea_latest_version = release["version"]
update_available = (
bool(frame.firmware_gitea_latest_version)
and frame.firmware_gitea_latest_version != frame.firmware_available_version
and bool(frame.device_board_variant)
)
if update_available and frame.firmware_auto_update:
_apply_gitea_update(db, frame)
update_available = False
return {
"enabled": True,
"board": frame.device_board_variant or None,
"latest_version": frame.firmware_gitea_latest_version or None,
"staged_version": frame.firmware_available_version or None,
"update_available": update_available,
}
@router.post("/api/frames/{frame_id}/firmware/apply-latest")
def api_firmware_apply_latest(
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
"""The "Update frame" button: applies the latest Gitea release right
now, bypassing the check throttle -- an explicit user action, not a
background poll."""
if not frame.firmware_update_repo_url:
raise HTTPException(400, "No Gitea firmware repo configured")
version = _apply_gitea_update(db, frame)
return {"status": "saved", "version": version}
+340
View File
@@ -0,0 +1,340 @@
"""Helpers shared by the device and browser routers."""
from __future__ import annotations
import io
import logging
import os
import time
from datetime import datetime, timedelta
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import calendar_feed, quiet_hours
from ..db import frame_locked
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import Frame, User, UserFrame
logger = logging.getLogger(__name__)
FRAME_MODES = ("photos", "calendar")
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery was recharged
# How many of the most recent reports make up that baseline. A lone noisy
# reading (ADC/regulator glitch -- see firmware/main/battery.c) can still
# dip or spike a single report; comparing against just the one immediately
# previous report meant that a normal reading right after a noisy dip
# looked like a 5%+ jump and falsely registered as a recharge. Comparing
# against the max of the last few reports instead means an actual recharge
# still needs to clear all of them, while a single stray low one doesn't
# get to set the bar.
RECHARGE_LOOKBACK = 3
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
OVERDUE_FACTOR = 1.5
def immich_creds(frame: Frame) -> tuple[str, str]:
"""Which Immich this frame renders from. Owner's creds once the frame
is claimed (Phase B+); env vars as the operator-level fallback (the
pre-redesign source of truth); the frame's own staging columns last
(populated by the config.json migration for exactly the case where
the old file held creds but the env no longer does)."""
owner = frame.owner
if owner is not None and owner.immich_url and owner.immich_api_key:
return owner.immich_url, owner.immich_api_key
env_url = os.environ.get("IMMICH_URL", "")
env_key = os.environ.get("IMMICH_API_KEY", "")
if env_url and env_key:
return env_url, env_key
return frame.immich_url, frame.immich_api_key
def immich_client_for(frame: Frame) -> ImmichClient:
url, key = immich_creds(frame)
return ImmichClient(url, key)
def require_configured(frame: Frame) -> None:
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
if not frame.album_id:
raise HTTPException(400, "No album configured yet")
def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
try:
assets = client.list_album_assets(frame.album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
if not assets:
raise HTTPException(404, "Album has no photos")
return assets
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
"""The shared first half of rendering: download the Immich preview
and (only if display_mode needs it) its detected faces. Used by both
render_asset (device-facing) and the web UI's rendered-preview
endpoint (routers/api_frames.py) so they can't drift apart."""
try:
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 frame.display_mode == "crop_faces":
try:
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)
return Image.open(io.BytesIO(jpeg_bytes)), faces
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
source, faces = fetch_source_and_faces(client, frame, asset_id)
return render_frame(source, faces=faces, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength, manage=manage)
def battery_estimate_s(frame: Frame) -> int | None:
"""Linear remaining-time estimate from the current discharge cycle's
observed rate, or None when there's not enough signal to be honest
about (too little time observed, or too little drop -- a flat line
extrapolates to garbage)."""
hist = frame.battery_history
if len(hist) < 2:
return None
first_ts, first_pct = hist[0]
last_ts, last_pct = hist[-1]
span = last_ts - first_ts
drop = first_pct - last_pct
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
return None
rate = drop / span # percent per second
return int(last_pct / rate)
def shell_context(request, db: Session, user, active_frame: Frame | None = None,
active_nav: str | None = None) -> dict:
"""Template context every app-shell (sidebar) page needs: the user's
frame list with an online indicator, the active highlights, and the
session's CSRF token. Import here (not auth) keeps the router
modules' template plumbing in one place."""
import time as _time
from .. import quiet_hours
from ..auth import current_session, user_frames
session = current_session(request, db)
frames = user_frames(db, user)
now = _time.time()
for f in frames:
# Same "not overdue" definition the Device panel uses.
gap = quiet_hours.max_expected_gap_s(f) * OVERDUE_FACTOR
f.recently_seen = bool(f.last_seen and now - f.last_seen <= gap)
return {
"request": request,
"user": user,
"csrf_token": session.csrf_token if session else None,
"sidebar_frames": frames,
"active_frame": active_frame,
"active_nav": active_nav,
}
def valid_http_url(url: str) -> bool:
"""http(s)-only URL check -- generalized from what was api_frames.py's
frame-specific _valid_repo_url, now shared by two call sites (the
Gitea firmware repo URL, and a user's personal calendar ICS URL)."""
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
# --- Location/date-taken text for the manage overlay (see build_manage_content) ---
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
def _manage_content_asset_id(frame: Frame) -> str | None:
"""Whether frame.current_asset_id refers to a photo actually visible
right now, for whichever mode is active -- always true in photos
mode; only true in calendar mode when the agenda view's photo inlay
is on (otherwise current_asset_id could be stale, left over from
whenever photos mode last ran, and showing its location/date/share
info on a manage overlay over a view with no visible photo at all
would be actively misleading, not just unhelpful)."""
relevant = frame.mode != "calendar" or (frame.calendar_view == "agenda" and frame.calendar_photo_inlay)
return frame.current_asset_id if relevant and frame.current_asset_id else None
def build_manage_content(db: Session, frame: Frame, request) -> dict:
"""Gathers everything manage_overlay.compose() needs -- what used to
be two separate device-facing endpoints (/frame/photo-info,
/frame/face-labels, both removed -- see the module docstring in
manage_overlay.py) are now just internal calls made here, once,
server-side, since compositing itself also moved server-side.
management_url and battery_percent always apply; location/date/
share-URL/face-labels only when there's a real current photo (see
_manage_content_asset_id) -- absent otherwise, which
manage_overlay.compose() already treats as "skip that region",
exactly the graceful-degradation behavior the old firmware-fetched
version had."""
base = str(request.base_url).rstrip("/")
content: dict = {
"management_url": f"{base}/m/{frame.manage_token}",
"battery_percent": frame.battery_percent,
}
asset_id = _manage_content_asset_id(frame)
if not asset_id:
return content
client = immich_client_for(frame)
try:
asset = client.get_asset(asset_id)
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e)
return content
exif = asset.get("exifInfo") or {}
content["location_lines"] = _format_location(exif)
content["taken_at"] = _format_taken_at(exif)
content["share_url"] = f"{base}/frame/share/{asset_id}"
if any((face.get("person") or {}).get("name") for face in faces):
try:
preview_bytes = client.download_asset_preview(asset_id)
from ..face_labels import compute_face_labels
content["face_labels"] = compute_face_labels(preview_bytes, faces, frame.display_mode, frame.orientation)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
return content
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]:
"""Every user linked to this frame with BOTH a calendar URL set AND
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact
set calendar_feed.merge_events needs. [(display_name-or-username,
ics_url), ...]."""
rows = db.execute(
select(User)
.join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712
User.calendar_ics_url != "")
).scalars().all()
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows]
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
-- same shape as the Gitea release-check throttle in api_frames.py's
api_firmware_check. One shared cache for the whole merged result
(every included user's events together), not per-user -- ICS feeds
are small and this refetches at most every ~20 minutes regardless of
how many are included, so per-user cache columns would add
bookkeeping for a marginal benefit."""
now = time.time()
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
return frame.calendar_cached_events, frame.calendar_fetch_summary
sources = calendar_sources_for_frame(db, frame)
today = quiet_hours.local_date(frame)
events, summary = calendar_feed.merge_events(
sources,
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
)
with frame_locked(db, frame.id) as locked:
locked.calendar_cached_events = events
locked.calendar_fetch_summary = summary
locked.calendar_checked_at = now
return events, summary
+399
View File
@@ -0,0 +1,399 @@
"""Device-facing /frame/* routes. These paths are FROZEN -- they're baked
into deployed firmware -- so multi-frame support changes only how the
calling frame is resolved (see auth.require_device), never the paths or
response key names the deployed flat parser depends on
("refresh_interval_s", "firmware_version").
manage=1 is the one addition: appended by firmware's manage button to
whichever of these three GET/POST requests it was already about to make
(see firmware/main/frame_client.c's fetch_and_display -- it no longer
does its own overlay fetching/compositing, that's all server-side now,
see manage_overlay.py and common.build_manage_content)."""
from __future__ import annotations
import logging
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import calendar_render, mail, photo_queue, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..firmware import firmware_path
from ..image_pipeline import render_placeholder
from ..models import BatteryLog, Frame
from .common import (
BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT,
RECHARGE_LOOKBACK,
build_manage_content,
get_or_refresh_calendar_events,
immich_client_for,
immich_creds,
list_assets,
render_asset,
require_configured,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
"""What an unclaimed or not-yet-configured frame displays instead of a
photo -- instructions with a QR, rendered at 200 so the device treats
it as a perfectly normal image and never error-loops. The URLs are
built from the request's own base URL: whatever address the device
reached us at is by definition an address that works on this
network."""
base = str(request.base_url).rstrip("/")
if frame.owner_user_id is None and frame.device_id:
claim_url = f"{base}/claim?device_id={frame.device_id}"
return render_placeholder(
["This frame isn't claimed yet", "Scan to link it to your account:"],
qr_url=claim_url,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
if frame.owner_user_id is None:
return render_placeholder(
["Almost there!", f"Open {base} to finish setting up this frame."],
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
return render_placeholder(
["Almost there!", "Pick an album for this frame:", base],
qr_url=base,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
def _frame_configured(frame: Frame) -> bool:
url, key = immich_creds(frame)
return bool(url and key and frame.album_id)
# --- photos mode ---
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
if not _frame_configured(frame):
return _setup_placeholder(frame, request, manage=manage)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
# --- calendar mode ---
def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
from .common import calendar_sources_for_frame
if not calendar_sources_for_frame(db, frame):
return render_placeholder(
["This frame's calendar isn't set up yet",
"Add a calendar in Settings, then include it on",
"this frame's Configuration -> Calendar card."],
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
)
with frame_locked(db, frame.id) as locked:
if is_normal_wake and locked.calendar_browse_offset != 0:
locked.calendar_browse_offset = 0
browse_offset = locked.calendar_browse_offset
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
inlay_wanted = locked.calendar_photo_inlay and view == "agenda"
events, summary = get_or_refresh_calendar_events(db, frame)
photo_inlay = None
if inlay_wanted and _frame_configured(frame):
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if asset_id:
jpeg_bytes = client.download_asset_preview(asset_id)
import io
from PIL import Image
photo_inlay = Image.open(io.BytesIO(jpeg_bytes))
except HTTPException:
pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda
return calendar_render.render_calendar(
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage,
)
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
"""NEXT in calendar mode: moves the displayed period forward one step
(day for agenda, week for week view, month for month view) from
wherever it currently is -- not from "today" -- so repeated presses
walk further forward. See Frame.calendar_browse_offset."""
with frame_locked(db, frame.id) as locked:
locked.calendar_browse_offset += 1
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
with frame_locked(db, frame.id) as locked:
locked.calendar_browse_offset -= 1
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
RENDERERS = {
"photos": _render_photos_mode,
"calendar": _render_calendar_mode,
}
ADVANCE_RENDERERS = {
"photos": _advance_photos_mode,
"calendar": _advance_calendar_mode,
}
BACK_RENDERERS = {
"photos": _back_photos_mode,
"calendar": _back_calendar_mode,
}
@router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its
reachability check. Always returns 200 with current settings -- no
Immich-configured gate, since this doubles as the "is the server up"
signal. Also captures the device's running firmware version and board
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
available OTA image's version, so the device's update check costs
zero extra round trips."""
reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "")
with frame_locked(db, frame.id) as locked:
if locked.stats_first_seen == 0:
locked.stats_first_seen = time.time()
locked.stats_device_wakes += 1
if reported_version:
if locked.device_firmware_version and reported_version != locked.device_firmware_version:
locked.stats_ota_updates_applied += 1
locked.device_firmware_version = reported_version
if reported_board:
locked.device_board_variant = reported_board
response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None,
}
# Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte-
# compatible with its 256-byte parse buffer), and only until the
# device has authenticated with the token once (device_token_ack).
if locked.device_id is not None and not locked.device_token_ack:
response["device_token"] = locked.device_token
return response
def _manage_flag(request: Request) -> bool:
return request.query_params.get("manage") == "1"
@router.get("/frame/image")
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Returns the frame's current image. For photos mode: 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. An unclaimed/
unconfigured frame gets a rendered instruction placeholder (200, not
an error) so a fresh device never error-loops.
?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content.
For calendar mode, this is also the "normal wake" that resets
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = renderer(db, frame, request, manage, True)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/advance")
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate move forward -- the next photo in photos mode,
or the next day/week/month in calendar mode -- ignoring
refresh_interval_s. Used by the device's next-photo button."""
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
@router.post("/frame/back")
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/advance -- back a photo in photos mode, back
a period in calendar mode. A no-op (still 200, unchanged) if there's
nothing to go back to. Used by the device's back-photo button."""
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
@router.post("/frame/battery")
def frame_battery(
body: BatteryReport, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Battery level reported by the device (only when running on battery
-- it stays silent on mains, where the charging voltage would read
misleadingly full). Stored with a timestamp plus a per-discharge-
cycle history that feeds the Device panel's "on battery for" and
"estimated remaining" numbers; every report also lands in the
permanent battery_log table behind the history chart."""
if not 0 <= body.percent <= 100:
raise HTTPException(400, "percent must be 0-100")
now = time.time()
should_alert = False
alert_email = ""
alert_frame_name = ""
with frame_locked(db, frame.id) as locked:
locked.stats_battery_reports += 1
# See RECHARGE_LOOKBACK: compared against the max of the last few
# reports, not just the single previous one, so a lone noisy dip
# can't make the next normal reading look like a recharge.
recent = locked.battery_history[-RECHARGE_LOOKBACK:]
recent_max = max((pct for _, pct in recent), default=None)
if recent_max is not None and body.percent >= recent_max + RECHARGE_JUMP_PCT:
# Percent jumped up meaningfully -- the battery was recharged
# (or swapped). Start a fresh discharge cycle so runtime and
# discharge-rate estimates never span a charge -- and let a
# low-battery alert fire again next time it actually gets low.
locked.battery_history = []
locked.stats_recharge_cycles += 1
locked.battery_alert_sent = False
locked.battery_history.append([now, body.percent])
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
locked.battery_percent = body.percent
locked.battery_as_of = now
db.add(BatteryLog(frame_id=locked.id, ts=now, percent=body.percent))
# Safety bound, not a real limit at realistic report rates --
# mirrors the old JSON list's cap.
count = db.scalar(select(func.count()).select_from(BatteryLog).where(BatteryLog.frame_id == locked.id))
if count is not None and count >= BATTERY_LOG_MAX:
cutoff_ids = select(BatteryLog.id).where(BatteryLog.frame_id == locked.id).order_by(
BatteryLog.ts
).limit(count + 1 - BATTERY_LOG_MAX)
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
# Once per discharge cycle (see the recharge reset above), not
# once per report -- a frame idling at 4% would otherwise get an
# email every wake.
if (
locked.battery_alert_threshold_pct >= 0
and body.percent <= locked.battery_alert_threshold_pct
and not locked.battery_alert_sent
and locked.owner is not None
and locked.owner.email
):
should_alert = True
alert_email = locked.owner.email
alert_frame_name = locked.name or f"Frame {locked.id}"
if should_alert:
# Network I/O outside the lock, same convention as everywhere
# else in this file -- then a short re-lock to record that it
# went out, only on actual success (an SMTP hiccup should let
# the next report's still-below-threshold reading try again
# rather than silently giving up for the rest of the cycle).
settings = get_server_settings(db)
sent = mail.send_email(
settings, alert_email, f"{alert_frame_name}: battery low",
f"{alert_frame_name}'s battery is at {body.percent}%.",
)
if sent:
with frame_locked(db, frame.id) as locked:
locked.battery_alert_sent = True
return {"status": "saved"}
@router.get("/frame/firmware")
def frame_firmware(frame: Frame = Depends(require_device)):
"""The frame's staged OTA image, streamed to the device
(esp_https_ota). 404 until something has been uploaded/fetched."""
path = firmware_path(frame.id)
if not path.exists():
raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream")
@router.get("/frame/share/{asset_id}")
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Also scoped to the photo currently showing or queued on THIS frame --
not any arbitrary Immich asset id -- as a second layer even a leaked
token wouldn't bypass."""
require_configured(frame)
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
+83
View File
@@ -0,0 +1,83 @@
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
Stats tabs, all inside the sidebar app shell. Data loading happens
client-side against /api/frames/{id}/... (routers/api_frames.py); these
routes just authorize and render the scaffold."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
from ..image_pipeline import (
DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS,
PALETTE_LABELS,
palette_to_hex,
)
from ..models import Frame, User, UserFrame
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab: str, **extra):
user = current_user(request, db)
if user is None:
return RedirectResponse(f"/login?next=/frames/{frame_id}", status_code=303)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
ctx = shell_context(request, db, user, active_frame=frame)
ctx.update({"frame": frame, "active_tab": tab, **extra})
return templates.TemplateResponse(template, ctx)
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
def _calendar_users_for_frame(db: Session, frame_id: int) -> list[dict]:
"""Every user linked to this frame, their calendar opt-in state, and
whether they even have a calendar URL set -- what the Configuration
tab's "Included calendars" list needs. Whether a given row is *this*
viewer's own (and therefore editable) is decided in the template,
using the `user` shell_context already provides."""
rows = db.execute(
select(User, UserFrame.calendar_included)
.join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id)
.order_by(User.username)
).all()
return [
{"user_id": u.id, "display_name": u.display_name or u.username,
"has_url": bool(u.calendar_ics_url), "included": included}
for u, included in rows
]
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(
request, db, frame_id, "frame_config.html", "config",
timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
display_mode_labels=DISPLAY_MODE_LABELS,
calendar_views=CALENDAR_VIEW_LABELS,
calendar_users=_calendar_users_for_frame(db, frame_id),
)
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
+119
View File
@@ -0,0 +1,119 @@
"""The limited no-login manage surface behind the on-frame "scan to
manage" QR. The QR resolves to /m/<manage_token> (see main.index's
device-credential redirect); the token grants exactly: view the current
photo + upcoming queue, promote ("show next"), advance, back, and
thumbnails. No settings, no removal, no other frames -- full control
requires logging in."""
from __future__ import annotations
import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
from ..db import frame_locked, get_db
from ..models import Frame
from .common import immich_client_for, list_assets, require_configured
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def require_manage(manage_token: str, db: Session = Depends(get_db)) -> Frame:
frame = db.scalars(select(Frame).where(Frame.manage_token == manage_token)).first()
if frame is None:
raise HTTPException(404, "Unknown manage link")
return frame
@router.get("/m/{manage_token}", response_class=HTMLResponse)
def manage_page(manage_token: str, request: Request, db: Session = Depends(get_db)):
frame = require_manage(manage_token, db)
return templates.TemplateResponse(
"manage.html",
{"request": request, "frame": frame, "manage_token": manage_token},
)
@router.get("/api/m/{manage_token}/queue")
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
current = cfg.current_asset_id
queue = list(cfg.queue)
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
return {
"frame_name": frame.name,
"current": entry(current) if current else None,
"upcoming": [entry(asset_id) for asset_id in queue],
}
class ManagePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/m/{manage_token}/promote")
def manage_promote(
body: ManagePromoteRequest,
frame: Frame = Depends(require_manage),
db: Session = Depends(get_db),
):
with frame_locked(db, frame.id) as cfg:
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [a for a in cfg.queue if a != body.asset_id]
return {"status": "saved"}
@router.post("/api/m/{manage_token}/advance")
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Advances the server-side current photo; the panel itself updates
on the device's next wake (or its next-photo button)."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.advance_forced(cfg, assets)
return {"status": "saved"}
@router.post("/api/m/{manage_token}/back")
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.back_forced(cfg, assets)
return {"status": "saved"}
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
"""Thumbnails scoped to what this frame is actually showing/queuing --
the manage token must not become a general Immich proxy."""
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame)
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)
+681
View File
@@ -0,0 +1,681 @@
"""HTML page routes: first-run setup, login/logout, user settings, and
the admin panel. The per-frame pages (Photos/Configuration/Stats) live in
routers/frame_pages.py.
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
(checked explicitly) rather than the X-CSRF-Token header the JSON API
uses."""
from __future__ import annotations
import hmac
import logging
import time
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import mail
from ..auth import (
SESSION_COOKIE,
SESSION_LIFETIME_S,
consume_password_reset_token,
create_password_reset_token,
create_session,
current_session,
current_user,
destroy_session,
get_server_settings,
hash_password,
users_exist,
verify_password,
)
from ..db import get_db
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
from .common import valid_http_url
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
USERNAME_MAX_LEN = 64
PASSWORD_MIN_LEN = 8
PENDING_CLAIM_TTL_S = 24 * 3600
def _set_session_cookie(response, cookie_value: str) -> None:
# No Secure flag: the server itself is plain HTTP by design (TLS is a
# reverse proxy's job, see README) and a LAN deployment without HTTPS
# must still be able to log in.
response.set_cookie(
SESSION_COOKIE,
cookie_value,
max_age=SESSION_LIFETIME_S,
httponly=True,
samesite="lax",
)
def _check_form_csrf(request: Request, db: Session, csrf_token: str) -> None:
session = current_session(request, db)
if session is None or not hmac.compare_digest(csrf_token, session.csrf_token):
raise HTTPException(403, "Missing or invalid CSRF token")
def _normalize_username(username: str) -> str:
return username.strip().lower()
def _validate_credentials(username: str, password: str) -> str:
username = _normalize_username(username)
if not username or len(username) > USERNAME_MAX_LEN:
raise HTTPException(400, "Invalid username")
if len(password) < PASSWORD_MIN_LEN:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LEN} characters")
return username
@router.get("/setup", response_class=HTMLResponse)
def setup_page(request: Request, db: Session = Depends(get_db)):
if users_exist(db):
return RedirectResponse("/login", status_code=303)
return templates.TemplateResponse("setup.html", {"request": request, "error": None})
@router.post("/setup")
def setup_submit(
request: Request,
username: str = Form(...),
display_name: str = Form(""),
password: str = Form(...),
db: Session = Depends(get_db),
):
"""Creates admin #1 -- only ever available while no users exist, so it
needs no CSRF/session (there is nothing to ride). Links every
existing frame (i.e. the migrated frame #1) to the new admin, makes
them its owner + controller, and inherits the migrated Immich creds
onto their account (that's how env/config.json creds become per-user
state)."""
if users_exist(db):
raise HTTPException(403, "Setup has already been completed")
username = _validate_credentials(username, password)
admin = User(
username=username,
display_name=display_name.strip() or username,
password_hash=hash_password(password),
is_admin=True,
created_at=time.time(),
)
db.add(admin)
db.flush()
for frame in db.scalars(select(Frame)):
db.add(UserFrame(user_id=admin.id, frame_id=frame.id))
if frame.owner_user_id is None:
frame.owner_user_id = admin.id
frame.claimed_at = time.time()
if frame.controlled_by_user_id is None:
frame.controlled_by_user_id = admin.id
if not admin.immich_url and frame.immich_url and frame.immich_api_key:
admin.immich_url = frame.immich_url
admin.immich_api_key = frame.immich_api_key
db.commit()
logger.info("First-run setup: created admin '%s' and linked %s", username,
", ".join(f"frame #{f.id}" for f in db.scalars(select(Frame))) or "no frames")
cookie_value, _ = create_session(db, admin)
response = RedirectResponse("/", status_code=303)
_set_session_cookie(response, cookie_value)
return response
def _safe_next(next_url: str) -> str:
"""Same-site relative paths only -- a login redirect target from a
query param must never become an open redirect."""
if next_url.startswith("/") and not next_url.startswith("//"):
return next_url
return "/"
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request, next: str = "", db: Session = Depends(get_db)):
if not users_exist(db):
return RedirectResponse("/setup", status_code=303)
if current_user(request, db) is not None:
return RedirectResponse(_safe_next(next), status_code=303)
return templates.TemplateResponse(
"login.html", {"request": request, "error": None, "next": next}
)
@router.post("/login")
def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
next: str = Form(""),
db: Session = Depends(get_db),
):
user = db.scalars(
select(User).where(User.username == _normalize_username(username))
).first()
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Wrong username or password.", "next": next},
status_code=401,
)
cookie_value, _ = create_session(db, user)
response = RedirectResponse(_safe_next(next), status_code=303)
_set_session_cookie(response, cookie_value)
return response
@router.post("/logout")
def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
_check_form_csrf(request, db, csrf_token)
destroy_session(db, request)
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE)
return response
@router.get("/forgot-password", response_class=HTMLResponse)
def forgot_password_page(request: Request):
return templates.TemplateResponse(
"forgot_password.html", {"request": request, "sent": False, "error": None}
)
@router.post("/forgot-password", response_class=HTMLResponse)
def forgot_password_submit(
request: Request, email: str = Form(...), db: Session = Depends(get_db)
):
"""Always shows the same "check your email" result regardless of
whether the address matches an account -- otherwise this endpoint
would let anyone enumerate registered emails. Silently no-ops (same
response) if SMTP isn't configured or the user has no email set."""
email = email.strip().lower()
user = db.scalars(select(User).where(User.email != "").where(User.email == email)).first()
if user is not None:
token = create_password_reset_token(db, user)
reset_url = str(request.base_url).rstrip("/") + f"/reset-password/{token}"
settings = get_server_settings(db)
mail.send_email(
settings, user.email, "Reset your ESPresso Frame password",
f"Someone (hopefully you) asked to reset the password for '{user.username}'.\n\n"
f"Reset it here (valid for 1 hour): {reset_url}\n\n"
"If you didn't request this, ignore this email.",
)
return templates.TemplateResponse(
"forgot_password.html", {"request": request, "sent": True, "error": None}
)
@router.get("/reset-password/{token}", response_class=HTMLResponse)
def reset_password_page(token: str, request: Request, db: Session = Depends(get_db)):
row = db.get(PasswordResetToken, token)
valid = row is not None and row.expires_at > time.time()
return templates.TemplateResponse(
"reset_password.html", {"request": request, "token": token, "valid": valid, "error": None}
)
@router.post("/reset-password/{token}", response_class=HTMLResponse)
def reset_password_submit(
token: str, request: Request, password: str = Form(...), db: Session = Depends(get_db)
):
if len(password) < PASSWORD_MIN_LEN:
return templates.TemplateResponse(
"reset_password.html",
{"request": request, "token": token, "valid": True,
"error": f"Password must be at least {PASSWORD_MIN_LEN} characters."},
)
user = consume_password_reset_token(db, token)
if user is None:
return templates.TemplateResponse(
"reset_password.html",
{"request": request, "token": token, "valid": False, "error": None},
)
user.password_hash = hash_password(password)
db.commit()
logger.info("Password reset via email link for user '%s'", user.username)
cookie_value, _ = create_session(db, user)
response = RedirectResponse("/", status_code=303)
_set_session_cookie(response, cookie_value)
return response
def _normalize_device_id(device_id: str) -> str:
device_id = device_id.strip().lower()
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
raise HTTPException(400, "Invalid device id")
return device_id
def _attempt_claim(db: Session, user: User, device_id: str) -> str:
"""Claims the frame for `user` if it has registered, else records a
pending claim the frame's first check-in will attach (see
auth._register_frame). Returns "claimed" or "pending"."""
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
now = time.time()
if frame is not None:
if frame.owner_user_id is not None:
raise HTTPException(409, "That frame is already claimed")
frame.owner_user_id = user.id
frame.claimed_at = now
if frame.controlled_by_user_id is None:
frame.controlled_by_user_id = user.id
if db.get(UserFrame, (user.id, frame.id)) is None:
db.add(UserFrame(user_id=user.id, frame_id=frame.id))
pending = db.get(PendingClaim, device_id)
if pending is not None:
db.delete(pending)
db.commit()
logger.info("User '%s' claimed frame #%d (%s)", user.username, frame.id, device_id)
return "claimed"
pending = db.get(PendingClaim, device_id)
if pending is None:
pending = PendingClaim(device_id=device_id, user_id=user.id, created_at=now,
expires_at=now + PENDING_CLAIM_TTL_S)
db.add(pending)
else:
pending.user_id = user.id
pending.expires_at = now + PENDING_CLAIM_TTL_S
db.commit()
logger.info("User '%s' filed a pending claim for device %s", user.username, device_id)
return "pending"
def _render_claim(request: Request, db: Session, device_id: str, error: str | None = None):
user = current_user(request, db)
session = current_session(request, db) if user else None
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None:
status = "unregistered"
pending = db.get(PendingClaim, device_id)
pending_yours = bool(pending and user and pending.user_id == user.id
and pending.expires_at > time.time())
elif frame.owner_user_id is None:
status, pending_yours = "unclaimed", False
elif user is not None and (
frame.owner_user_id == user.id or db.get(UserFrame, (user.id, frame.id)) is not None
):
status, pending_yours = "claimed_yours", False
else:
status, pending_yours = "claimed", False
return templates.TemplateResponse(
"claim.html",
{
"request": request,
"device_id": device_id,
"status": status,
"pending_yours": pending_yours,
"user": user,
"csrf_token": session.csrf_token if session else None,
"error": error,
},
)
@router.get("/claim", response_class=HTMLResponse)
def claim_page(request: Request, device_id: str = "", db: Session = Depends(get_db)):
"""Where the captive portal's post-provisioning redirect lands. Also
the enrollment gate: a valid device id is what entitles a stranger to
create an account (signup form on this page); everyone else gets
enrolled by the admin."""
device_id = _normalize_device_id(device_id)
return _render_claim(request, db, device_id)
@router.post("/claim")
def claim_submit(
request: Request,
device_id: str = Form(...),
csrf_token: str = Form(""),
db: Session = Depends(get_db),
):
device_id = _normalize_device_id(device_id)
user = current_user(request, db)
if user is None:
return RedirectResponse(f"/login?next=/claim%3Fdevice_id%3D{device_id}", status_code=303)
_check_form_csrf(request, db, csrf_token)
try:
_attempt_claim(db, user, device_id)
except HTTPException as e:
if e.status_code == 409:
return _render_claim(request, db, device_id, error=e.detail)
raise
return RedirectResponse(f"/claim?device_id={device_id}", status_code=303)
@router.post("/claim/signup")
def claim_signup(
request: Request,
device_id: str = Form(...),
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
"""Account creation, gated on a plausible frame claim: the device id
must belong to a frame that is unclaimed (or not yet registered --
the user beat the device here after provisioning). A fabricated id
can create an orphan account whose pending claim expires in 24h --
accepted at household scale, and visible in /admin."""
device_id = _normalize_device_id(device_id)
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is not None and frame.owner_user_id is not None:
return _render_claim(request, db, device_id,
error="That frame is already claimed -- log in instead.")
username = _validate_credentials(username, password)
if db.scalars(select(User).where(User.username == username)).first() is not None:
return _render_claim(request, db, device_id,
error=f"Username '{username}' is taken -- log in instead?")
user = User(
username=username,
display_name=username,
password_hash=hash_password(password),
is_admin=False,
created_at=time.time(),
)
db.add(user)
db.commit()
logger.info("User '%s' signed up via claim gate for device %s", username, device_id)
_attempt_claim(db, user, device_id)
cookie_value, _ = create_session(db, user)
response = RedirectResponse(f"/claim?device_id={device_id}", status_code=303)
_set_session_cookie(response, cookie_value)
return response
def _settings_context(request: Request, db: Session, user, saved: bool, error: str | None) -> dict:
from .common import shell_context
ctx = shell_context(request, db, user, active_nav="settings")
ctx.update({"saved": saved, "error": error})
return ctx
@router.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, db: Session = Depends(get_db)):
user = current_user(request, db)
if user is None:
return RedirectResponse("/login", status_code=303)
return templates.TemplateResponse(
"settings.html", _settings_context(request, db, user, saved=False, error=None)
)
@router.post("/settings", response_class=HTMLResponse)
def settings_submit(
request: Request,
csrf_token: str = Form(""),
display_name: str = Form(""),
email: str = Form(""),
immich_url: str = Form(""),
immich_api_key: str = Form(""),
calendar_ics_url: str = Form(""),
current_password: str = Form(""),
new_password: str = Form(""),
db: Session = Depends(get_db),
):
user = current_user(request, db)
if user is None:
return RedirectResponse("/login", status_code=303)
_check_form_csrf(request, db, csrf_token)
error = None
user.display_name = display_name.strip() or user.username
user.email = email.strip().lower()
user.immich_url = immich_url.strip()
# Blank API key field = keep the existing one (it's never echoed back
# into the form -- a secret that round-trips through HTML is a secret
# in every browser's autofill store).
if immich_api_key.strip():
user.immich_api_key = immich_api_key.strip()
# Unlike the API key, this isn't a secret -- it round-trips visibly in
# the form, so blank means an explicit clear (there needs to be some
# way to actually remove a linked calendar), not "keep existing".
stripped_ics = calendar_ics_url.strip()
if stripped_ics and not valid_http_url(stripped_ics):
error = "Calendar URL must be a plain http:// or https:// URL."
else:
user.calendar_ics_url = stripped_ics
if new_password:
if not user.password_hash or not verify_password(current_password, user.password_hash):
error = "Current password is wrong -- password not changed."
elif len(new_password) < PASSWORD_MIN_LEN:
error = f"New password must be at least {PASSWORD_MIN_LEN} characters."
else:
user.password_hash = hash_password(new_password)
db.commit()
return templates.TemplateResponse(
"settings.html", _settings_context(request, db, user, saved=error is None, error=error)
)
def _require_admin_page(request: Request, db: Session) -> User:
user = current_user(request, db)
if user is None or not user.is_admin:
raise HTTPException(403, "Admin only")
return user
def _render_admin(request: Request, db: Session, admin: User, notice: str | None = None,
error: str | None = None) -> HTMLResponse:
from .common import shell_context
users = list(db.scalars(select(User).order_by(User.id)))
frames = list(db.scalars(select(Frame).order_by(Frame.id)))
links = list(db.scalars(select(UserFrame)))
links_by_frame: dict[int, list[User]] = {}
users_by_id = {u.id: u for u in users}
for link in links:
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
ctx = shell_context(request, db, admin, active_nav="admin")
ctx.update({
"users": users,
"frames": frames,
"links_by_frame": links_by_frame,
"smtp": get_server_settings(db),
"notice": notice,
"error": error,
})
return templates.TemplateResponse("admin.html", ctx)
@router.get("/admin", response_class=HTMLResponse)
def admin_page(request: Request, db: Session = Depends(get_db)):
user = current_user(request, db)
if user is None:
return RedirectResponse("/login", status_code=303)
if not user.is_admin:
raise HTTPException(403, "Admin only")
return _render_admin(request, db, user)
@router.post("/admin/users", response_class=HTMLResponse)
def admin_create_user(
request: Request,
csrf_token: str = Form(""),
username: str = Form(...),
password: str = Form(...),
is_admin: bool = Form(False),
db: Session = Depends(get_db),
):
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
username = _validate_credentials(username, password)
if db.scalars(select(User).where(User.username == username)).first() is not None:
return _render_admin(request, db, admin, error=f"Username '{username}' already exists.")
db.add(User(
username=username,
display_name=username,
password_hash=hash_password(password),
is_admin=is_admin,
created_at=time.time(),
))
db.commit()
return _render_admin(request, db, admin, notice=f"User '{username}' created.")
@router.post("/admin/users/{user_id}/reset-password", response_class=HTMLResponse)
def admin_reset_password(
user_id: int,
request: Request,
csrf_token: str = Form(""),
password: str = Form(...),
db: Session = Depends(get_db),
):
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
target = db.get(User, user_id)
if target is None:
return _render_admin(request, db, admin, error="No such user.")
if len(password) < PASSWORD_MIN_LEN:
return _render_admin(request, db, admin,
error=f"Password must be at least {PASSWORD_MIN_LEN} characters.")
target.password_hash = hash_password(password)
db.commit()
return _render_admin(request, db, admin, notice=f"Password reset for '{target.username}'.")
@router.post("/admin/users/{user_id}/delete", response_class=HTMLResponse)
def admin_delete_user(
user_id: int,
request: Request,
csrf_token: str = Form(""),
db: Session = Depends(get_db),
):
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
if user_id == admin.id:
return _render_admin(request, db, admin, error="You can't delete your own account.")
target = db.get(User, user_id)
if target is None:
return _render_admin(request, db, admin, error="No such user.")
name = target.username
db.delete(target) # sessions/links cascade; frames.owner goes NULL
db.commit()
return _render_admin(request, db, admin, notice=f"User '{name}' deleted.")
@router.post("/admin/frames/{frame_id}/link-user", response_class=HTMLResponse)
def admin_link_user(
frame_id: int,
request: Request,
csrf_token: str = Form(""),
username: str = Form(...),
db: Session = Depends(get_db),
):
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
frame = db.get(Frame, frame_id)
target = db.scalars(select(User).where(User.username == _normalize_username(username))).first()
if frame is None or target is None:
return _render_admin(request, db, admin, error="No such frame or user.")
if db.get(UserFrame, (target.id, frame_id)) is not None:
return _render_admin(request, db, admin, error=f"'{target.username}' is already linked.")
db.add(UserFrame(user_id=target.id, frame_id=frame_id))
if frame.owner_user_id is None:
# Linking to an unclaimed frame claims it -- the admin flow for
# adopting a frame that self-registered without a pending claim.
frame.owner_user_id = target.id
frame.claimed_at = time.time()
db.commit()
return _render_admin(request, db, admin,
notice=f"Linked '{target.username}' to frame #{frame_id}.")
@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
def admin_end_legacy(
frame_id: int,
request: Request,
csrf_token: str = Form(""),
db: Session = Depends(get_db),
):
"""Closes the legacy-token migration window once the device is
confirmed on per-frame auth (device_token_ack + recent last_seen in
the frames table below)."""
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
frame = db.get(Frame, frame_id)
if frame is None:
return _render_admin(request, db, admin, error="No such frame.")
frame.legacy_token_enabled = False
db.commit()
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
@router.post("/admin/smtp", response_class=HTMLResponse)
def admin_smtp_save(
request: Request,
csrf_token: str = Form(""),
smtp_host: str = Form(""),
smtp_port: int = Form(587),
smtp_username: str = Form(""),
smtp_password: str = Form(""),
smtp_from_address: str = Form(""),
smtp_encryption: str = Form("starttls"),
db: Session = Depends(get_db),
):
"""Saves the SMTP config used for password-reset emails and battery-
threshold alerts. Blank password = keep the existing one, same
round-trip-avoidance as the Immich API key field in /settings."""
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
settings = get_server_settings(db)
settings.smtp_host = smtp_host.strip()
settings.smtp_port = max(1, min(65535, smtp_port))
settings.smtp_username = smtp_username.strip()
if smtp_password.strip():
settings.smtp_password = smtp_password.strip()
settings.smtp_from_address = smtp_from_address.strip()
settings.smtp_encryption = smtp_encryption if smtp_encryption in ("none", "starttls", "ssl") else "starttls"
db.commit()
return _render_admin(request, db, admin, notice="SMTP settings saved.")
@router.post("/admin/smtp/test", response_class=HTMLResponse)
def admin_smtp_test(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
if not admin.email:
return _render_admin(request, db, admin, error="Set an email on your own account (Settings) to test SMTP.")
settings = get_server_settings(db)
ok = mail.send_email(
settings, admin.email, "ESPresso Frame test email",
"If you're reading this, SMTP is configured correctly.",
)
if ok:
return _render_admin(request, db, admin, notice=f"Test email sent to {admin.email}.")
return _render_admin(request, db, admin, error="Failed to send -- check the SMTP settings and server logs.")
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
def admin_delete_frame(
frame_id: int,
request: Request,
csrf_token: str = Form(""),
db: Session = Depends(get_db),
):
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
frame = db.get(Frame, frame_id)
if frame is None:
return _render_admin(request, db, admin, error="No such frame.")
db.delete(frame) # links/battery log cascade
db.commit()
return _render_admin(request, db, admin, notice=f"Frame #{frame_id} deleted.")
+89
View File
@@ -0,0 +1,89 @@
// Hand-drawn canvas battery-history chart. Ported intact from the
// original single-page UI. Reads theme colors live so it redraws
// correctly on theme changes (see the themechange listener in
// frame_stats.js).
let lastBatteryLog = null;
function drawBatteryChart(log) {
lastBatteryLog = log;
const wrap = document.getElementById('battery-chart-wrap');
if (!log || log.length < 2) {
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
return;
}
wrap.innerHTML = '';
const width = wrap.clientWidth || 440;
const height = 180;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.display = 'block';
canvas.style.border = `1px solid ${themeColor('--border')}`;
canvas.style.borderRadius = '8px';
wrap.appendChild(canvas);
const ctx = canvas.getContext('2d');
const gridColor = themeColor('--border');
const mutedColor = themeColor('--text-muted');
const accentColor = themeColor('--accent');
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const times = log.map((p) => p[0]);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const spanT = Math.max(1, maxT - minT);
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
ctx.strokeStyle = gridColor;
ctx.fillStyle = mutedColor;
ctx.font = '10px system-ui, sans-serif';
ctx.lineWidth = 1;
ctx.textAlign = 'left';
[0, 25, 50, 75, 100].forEach((pct) => {
const yy = y(pct);
ctx.beginPath();
ctx.moveTo(pad.left, yy);
ctx.lineTo(width - pad.right, yy);
ctx.stroke();
ctx.fillText(String(pct), 2, yy + 3);
});
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
log.forEach((p, i) => {
const px = x(p[0]);
const py = y(p[1]);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
ctx.fillStyle = mutedColor;
ctx.textAlign = 'left';
ctx.fillText(fmt(minT), pad.left, height - 4);
ctx.textAlign = 'right';
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
}
async function loadBatteryLog() {
const wrap = document.getElementById('battery-chart-wrap');
try {
const resp = await fetch(`${window.FRAME_API}/battery-log`);
if (!resp.ok) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
const data = await resp.json();
drawBatteryChart(data.log);
} catch (e) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
}
}
+97
View File
@@ -0,0 +1,97 @@
// Shared plumbing for every page: CSRF-injecting fetch, theme toggle,
// sidebar toggle (mobile), and small formatting helpers. No framework,
// no build step -- plain scripts, load order handled by <script> tags.
// Session-cookie auth needs CSRF proof on mutating requests. Wrapping
// fetch once means no call site has to remember the header. The token
// rides a <meta> tag emitted only for session-authed pages.
(function () {
var meta = document.querySelector('meta[name="csrf-token"]');
if (!meta || !meta.content) return;
var CSRF = meta.content;
var origFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
var url = typeof input === 'string' ? input : (input && input.url) || '';
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
init.headers = new Headers(init.headers || (input && input.headers) || {});
init.headers.set('X-CSRF-Token', CSRF);
}
return origFetch.call(this, input, init);
};
})();
// Theme toggle: explicit choice wins over the OS preference and is
// remembered; with no explicit choice, CSS falls back to
// prefers-color-scheme on its own. (The pre-paint snippet in the page
// <head> applies the stored theme before first render.)
(function () {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
function currentTheme() {
var stored = null;
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
if (stored === 'light' || stored === 'dark') return stored;
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
btn.addEventListener('click', function () {
var theme = currentTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
});
})();
// Mobile sidebar: hamburger opens, backdrop or navigation closes.
(function () {
var shell = document.querySelector('.shell');
var toggle = document.getElementById('sidebar-toggle');
var backdrop = document.querySelector('.sidebar-backdrop');
if (!shell || !toggle) return;
toggle.addEventListener('click', function () { shell.classList.toggle('sidebar-open'); });
if (backdrop) {
backdrop.addEventListener('click', function () { shell.classList.remove('sidebar-open'); });
}
})();
function showStatus(ok, message) {
var el = document.getElementById('result');
if (!el) return;
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
el.firstChild.textContent = message;
}
// A 409 from a control-gated endpoint means someone else holds the
// frame's control lock -- surface who, plus how to take over.
async function apiError(resp) {
var text = await resp.text();
try {
var body = JSON.parse(text);
var detail = body.detail !== undefined ? body.detail : body;
if (detail && detail.error === 'not_controller') {
var holder = detail.holder || 'Someone else';
return holder + ' has control of this frame — use "Take control" to make changes.';
}
if (typeof detail === 'string') return detail;
} catch (e) { /* not JSON */ }
return text;
}
function formatDuration(seconds) {
var d = Math.floor(seconds / 86400);
var h = Math.floor((seconds % 86400) / 3600);
var m = Math.floor((seconds % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
// Reads resolved colors from CSS custom properties rather than
// hardcoding hex values, so canvas drawing matches the current theme.
function themeColor(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
+80
View File
@@ -0,0 +1,80 @@
// Device status bar: always-visible strip (below the page title, above
// the tabs -- see _device_status_bar.html) showing last-seen/firmware/
// battery, so it's not tucked away on just the Stats tab. Shared by
// every frame page; each sets window.FRAME_API before this loads.
let lastDeviceStatus = null;
function renderDeviceStatusBar(device) {
const el = document.getElementById('device-status');
if (!el) return;
el.innerHTML = '';
if (!device || !device.last_seen) {
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
return;
}
const now = Date.now() / 1000;
const rows = [];
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push(['Last seen', `${ago} ago`, device.overdue]);
if (device.firmware_version) {
let fw = `v${device.firmware_version}`;
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
fw += ` (v${device.firmware_available} waiting)`;
}
rows.push(['Firmware', fw, false]);
}
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
// Shown as soon as there's any battery reading at all, even before
// battery_estimate_s can compute a rate (needs 2h+ span and a 2%+
// drop within the current discharge cycle -- see common.py) -- so
// it's clear the number is coming, not that the feature is broken.
const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined;
rows.push([
'Est. battery life left',
hasEstimate ? `~${formatDuration(device.battery_estimate_s)}` : 'Not enough data yet',
false,
]);
}
for (const [label, value, alert] of rows) {
const stat = document.createElement('span');
stat.className = 'device-stat' + (alert ? ' alert' : '');
const labelPart = document.createTextNode(label + ': ');
const valuePart = document.createElement('strong');
valuePart.textContent = value;
stat.appendChild(labelPart);
stat.appendChild(valuePart);
el.appendChild(stat);
}
}
async function loadDeviceStatusBar() {
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
return;
}
const data = await resp.json();
lastDeviceStatus = data.device;
renderDeviceStatusBar(data.device);
} catch (e) { /* retried on the next poll */ }
}
loadDeviceStatusBar();
document.addEventListener('themechange', () => {
if (lastDeviceStatus) {
renderDeviceStatusBar(lastDeviceStatus);
}
});
// Fast tick: re-renders "Last seen" from already-fetched data every
// second so it counts up smoothly without hitting the server that often.
setInterval(() => {
if (lastDeviceStatus) {
renderDeviceStatusBar(lastDeviceStatus);
}
}, 1000);
setInterval(loadDeviceStatusBar, 10000);
+397
View File
@@ -0,0 +1,397 @@
// Configuration tab: frame settings + firmware card + take control.
// window.FRAME_API is set by the template. Checkboxes are always sent
// explicitly as "true"/"false" -- the server treats absent fields as
// "leave unchanged", so a checkbox must never be simply omitted.
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
mode: document.getElementById('frame_mode').value,
name: document.getElementById('frame_name').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
display_mode: document.getElementById('display_mode').value,
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
timezone: document.getElementById('timezone').value || 'UTC',
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
}
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveConfig();
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
}
});
// ---- Calendar card: mode/view toggling, its own save, self opt-in, preview ----
const calendarCard = document.getElementById('calendar-card');
if (calendarCard) {
document.getElementById('frame_mode').addEventListener('change', () => {
calendarCard.style.display = document.getElementById('frame_mode').value === 'calendar' ? 'block' : 'none';
});
const inlayRow = document.getElementById('calendar-inlay-row');
const inlayHint = document.getElementById('calendar-inlay-hint');
document.getElementById('calendar_view').addEventListener('change', () => {
const isAgenda = document.getElementById('calendar_view').value === 'agenda';
inlayRow.style.display = isAgenda ? 'flex' : 'none';
inlayHint.style.display = isAgenda ? 'block' : 'none';
});
document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_view: document.getElementById('calendar_view').value,
calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked),
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// Each person's own opt-in -- auto-saves on toggle, not batched into
// the form above, since it's the toggling user's own preference (see
// api_frames.py's /calendar-included), not a frame-wide setting.
document.querySelectorAll('.calendar-self-toggle').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/calendar-included`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ included: el.checked }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.');
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
});
function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
}
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
}
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'You have control now.');
loadControl();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadControl() {
const banner = document.getElementById('control-banner');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) return; // unconfigured frame: control still works via 409s
const data = await resp.json();
if (data.control && !data.control.you) {
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = data.control.controller
? `${data.control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
} else {
banner.style.display = 'none';
}
} catch (e) { /* banner is best-effort */ }
}
document.getElementById('take-control').addEventListener('click', takeControl);
// ---- Advanced configuration: color palette ----
//
// Hex field and R/G/B number fields are kept in sync live, both
// directions -- editing either updates the other plus the preview
// swatch. Hex stays the field actually read at save time (it's what
// the server already validates as #rrggbb); the R/G/B fields are purely
// an alternate, more precise way to arrive at the same value than
// eyeballing a color-picker swatch.
function paletteHexInputs() {
return Array.from(document.querySelectorAll('.palette-hex'))
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
}
function hexFromRgb(r, g, b) {
const clamp = (v) => Math.max(0, Math.min(255, Math.round(Number(v) || 0)));
return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('');
}
function rgbFromHex(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function paletteFieldsFor(index) {
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
}
function syncPaletteFromHex(index) {
const f = paletteFieldsFor(index);
const rgb = rgbFromHex(f.hex.value);
if (!rgb) return;
[f.r.value, f.g.value, f.b.value] = rgb;
f.swatch.style.background = f.hex.value;
}
function syncPaletteFromRgb(index) {
const f = paletteFieldsFor(index);
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
f.hex.value = hex;
f.swatch.style.background = hex;
}
const palettePickerCount = paletteHexInputs().length;
for (let i = 0; i < palettePickerCount; i++) {
const f = paletteFieldsFor(i);
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
}
// Sliders: live numeric readout next to each, no save until the button
// below is clicked.
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
const input = document.getElementById(id);
const readout = document.getElementById(`${id}_value`);
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
});
async function savePalette(extra) {
const body = new URLSearchParams(extra || {});
for (const input of paletteHexInputs()) {
body.append('palette', input.value);
}
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadPreview();
} catch (e) {
showStatus(false, e.message);
}
}
document.getElementById('palette-save').addEventListener('click', () => {
savePalette({
color_boost: document.getElementById('color_boost').value,
contrast_boost: document.getElementById('contrast_boost').value,
dither_strength: document.getElementById('dither_strength').value,
});
});
document.getElementById('palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs();
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex(i);
});
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
document.getElementById(id).value = '1';
document.getElementById(`${id}_value`).textContent = '1.00';
});
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
});
// ---- Preview: current photo vs. how it renders with saved settings ----
function loadPreview() {
const bust = Date.now(); // avoid a stale cached image after settings change
document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`;
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`;
}
document.getElementById('preview-refresh').addEventListener('click', loadPreview);
loadPreview();
// ---- Battery alerts card ----
document.getElementById('battery-alert-save').addEventListener('click', async () => {
const raw = document.getElementById('battery_alert_threshold_pct').value.trim();
const body = new URLSearchParams({
battery_alert_threshold_pct: raw === '' ? '-1' : raw,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
}
});
// ---- Firmware card ----
document.getElementById('firmware-upload').addEventListener('click', async () => {
const input = document.getElementById('firmware-file');
if (!input.files.length) {
showStatus(false, 'Pick a firmware .bin first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
} catch (e) {
showStatus(false, e.message);
}
});
function showRepoDisplayMode(url) {
document.getElementById('firmware-repo-text').textContent = url;
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
}
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
document.getElementById('firmware-repo-display').style.display = 'none';
document.getElementById('firmware-repo-edit').style.display = 'block';
document.getElementById('firmware_update_repo_url').focus();
});
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
try {
const body = new URLSearchParams({
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
}
});
async function loadFirmwareCheck(force) {
const statusEl = document.getElementById('firmware-gitea-status');
const btn = document.getElementById('firmware-update-btn');
const boardEl = document.getElementById('firmware-board');
try {
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''), { method: 'POST' });
if (!resp.ok) {
if (force) {
showStatus(false, await apiError(resp));
}
return;
}
const data = await resp.json();
if (data.board) {
boardEl.textContent = `Detected board: ${data.board}`;
}
if (!data.enabled) {
statusEl.style.display = 'none';
btn.style.display = 'none';
if (force) {
showStatus(false, 'No Gitea repo URL configured.');
}
return;
}
statusEl.style.display = 'block';
if (!data.board) {
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
btn.style.display = 'inline-block';
} else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none';
} else {
statusEl.textContent = 'No releases found yet.';
btn.style.display = 'none';
}
if (force) {
showStatus(true, 'Checked.');
}
} catch (e) {
// A failed passive poll is silent; an explicit "Check now" click
// still surfaces the error.
if (force) {
showStatus(false, e.message);
}
}
}
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
const btn = document.getElementById('firmware-update-btn');
btn.disabled = true;
try {
const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' });
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
} finally {
btn.disabled = false;
}
});
loadControl();
loadFirmwareCheck();
// The server throttles actual Gitea API calls itself, so this poll is
// cheap either way.
setInterval(loadFirmwareCheck, 60000);
+126
View File
@@ -0,0 +1,126 @@
// Photos tab: now-displaying, album picker, and the upcoming grid
// (rendering/drag logic in queue.js). window.FRAME_API is set by the
// template.
function renderControlBanner(control) {
const banner = document.getElementById('control-banner');
if (!banner) return;
if (!control || control.you) {
banner.style.display = 'none';
return;
}
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = control.controller
? `${control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
}
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'You have control now.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
currentEl.innerHTML =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
renderControlBanner(data.control);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
async function savePhotoSettings() {
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
queue_target_len: document.getElementById('queue_target_len').value,
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
}
document.getElementById('load-albums').addEventListener('click', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/albums`);
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('photos-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await savePhotoSettings();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('take-control').addEventListener('click', takeControl);
loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag.
setInterval(loadQueue, 10000);
+51
View File
@@ -0,0 +1,51 @@
// Stats tab: lifetime counters + battery history chart (chart logic in
// battery_chart.js). Device status now lives in the always-visible bar
// (device_status_bar.js), not here. window.FRAME_API set by template.
function renderStats(stats) {
const el = document.getElementById('stats-box');
el.innerHTML = '';
const now = Date.now() / 1000;
const rows = [
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
['Wake cycles', stats.device_wakes],
['Photos displayed', stats.photos_displayed],
['Photos removed from rotation', stats.photos_removed],
['Battery reports received', stats.battery_reports],
['Battery recharge cycles', stats.recharge_cycles],
['OTA updates applied', stats.ota_updates_applied],
['Settings saved', stats.config_saves],
];
for (const [label, value] of rows) {
const p = document.createElement('p');
p.className = 'sub';
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadStats() {
const el = document.getElementById('stats-box');
try {
const resp = await fetch(`${window.FRAME_API}/stats`);
if (!resp.ok) {
el.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
renderStats(await resp.json());
} catch (e) {
el.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadStats();
loadBatteryLog();
// Redraw the canvas chart with the new theme's colors as soon as the
// toggle is used -- canvas pixels don't repaint themselves the way CSS
// does.
document.addEventListener('themechange', () => {
if (lastBatteryLog) {
drawBatteryChart(lastBatteryLog);
}
});
+240
View File
@@ -0,0 +1,240 @@
// The upcoming-photos grid: rendering plus drag-to-reorder. Ported
// intact from the original single-page UI -- the Pointer Events state
// machine below (hold-to-arm on touch so page scrolling still works) is
// battle-tested; treat changes with suspicion.
//
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
// loadQueue() global (frame_photos.js) to refetch authoritative state.
let upcomingItems = [];
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
// code drives mouse, touch, and pen -- native drag-and-drop is
// mouse-only by spec and never fires at all on phones/tablets.
//
// On touch specifically, a card only "arms" for dragging after a
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
// the whole time up to that point, so a normal touch-and-swipe to
// scroll the page still works even though it starts on a card. Once
// armed, touch-action switches to "none" for the rest of that touch
// so drag tracking gets every pointermove reliably. Mouse skips the
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
const DRAG_HOLD_MS = 250;
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
function vibrate(ms) {
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
// it's a harmless no-op everywhere else rather than needing a
// feature check at every call site.
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
}
function clearDragOverStyling() {
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
}
function endDrag(card) {
if (dragState && dragState.holdTimer) {
clearTimeout(dragState.holdTimer);
}
card.style.touchAction = '';
card.style.transform = '';
card.classList.remove('dragging', 'drag-armed');
clearDragOverStyling();
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
vibrate(15);
moveItem(dragState.fromIndex, dragState.toIndex);
}
dragState = null;
}
function renderUpcoming(items) {
upcomingItems = items;
const grid = document.getElementById('upcoming-grid');
grid.innerHTML = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'photo-card';
card.dataset.index = String(i);
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
card.appendChild(img);
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = String(i + 1);
card.appendChild(badge);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeAsset(item.id);
});
card.appendChild(removeBtn);
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'show-next';
nextBtn.textContent = 'Show next';
nextBtn.addEventListener('click', (e) => {
e.stopPropagation();
showNext(i);
});
card.appendChild(nextBtn);
card.addEventListener('pointerdown', (e) => {
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
return; // let the button's own click handler run, don't start a drag
}
if (e.pointerType === 'mouse' && e.button !== 0) {
return; // left button only
}
dragState = {
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
};
if (e.pointerType === 'touch') {
dragState.holdTimer = setTimeout(() => {
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
dragState.armed = true;
card.classList.add('drag-armed');
card.style.touchAction = 'none';
vibrate(10);
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
}
}, DRAG_HOLD_MS);
} else {
card.setPointerCapture(e.pointerId);
}
});
card.addEventListener('pointermove', (e) => {
if (!dragState || dragState.pointerId !== e.pointerId) {
return;
}
if (!dragState.armed) {
// Still deciding whether this is a hold-to-drag or a scroll --
// moving this much before the hold timer fires means scroll;
// bail out and let the browser's native pan-y handle it.
const dx0 = e.clientX - dragState.startX;
const dy0 = e.clientY - dragState.startY;
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
clearTimeout(dragState.holdTimer);
dragState = null;
}
return;
}
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved) {
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
return;
}
dragState.moved = true;
card.classList.remove('drag-armed');
card.classList.add('dragging');
}
// Follows the finger 1:1 -- the actual "pick it up and carry it"
// feedback.
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
clearDragOverStyling();
if (overCard && overCard !== card) {
overCard.classList.add('drag-over');
dragState.toIndex = Number(overCard.dataset.index);
} else {
dragState.toIndex = undefined;
}
});
card.addEventListener('pointerup', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
card.addEventListener('pointercancel', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
grid.appendChild(card);
});
}
function moveItem(fromIndex, toIndex) {
const items = upcomingItems.slice();
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
renderUpcoming(items);
persistOrder(items);
}
async function showNext(index) {
const assetId = upcomingItems[index].id;
try {
const resp = await fetch(`${window.FRAME_API}/queue/promote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue(); // always refetch the authoritative order rather than guessing locally
}
async function removeAsset(assetId) {
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
return;
}
try {
const resp = await fetch(`${window.FRAME_API}/queue/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue();
}
async function persistOrder(items) {
try {
const resp = await fetch(`${window.FRAME_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 apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}
+555
View File
@@ -0,0 +1,555 @@
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-alt: #f0f1f4;
--border: #e3e5e9;
--text: #16181d;
--text-muted: #666d7a;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--focus-ring: rgba(37, 99, 235, 0.35);
--success-bg: #dcfce7;
--success-text: #166534;
--danger-bg: #fee2e2;
--danger-text: #991b1b;
--warn-bg: #fef9c3;
--warn-text: #854d0e;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(153, 27, 27, 0.85);
--color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
* { box-sizing: border-box; }
html {
color-scheme: var(--color-scheme);
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
transition: background-color .15s ease, color .15s ease;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 28px 20px 72px;
}
.page.page-narrow {
max-width: 420px;
padding-top: 88px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.brand { display: flex; align-items: flex-start; gap: 12px; }
.brand-mark {
font-size: 26px;
line-height: 1;
margin-top: 2px;
}
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
.icon-btn {
flex: none;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
box-shadow: var(--shadow);
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
}
.icon-btn:hover { background: var(--surface-alt); }
.icon-btn:active { transform: scale(0.94); }
.topbar-actions { display: flex; align-items: center; gap: 14px; }
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
.topnav a { color: var(--text-muted); text-decoration: none; }
.topnav a:hover { color: var(--text); }
.inline-form { display: inline; margin: 0; }
button.linklike {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
font-weight: 400;
cursor: pointer;
box-shadow: none;
}
button.linklike:hover { color: var(--text); background: none; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.admin-actions form { margin: 4px 0 0; }
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
.admin-actions input[type="password"] { margin-top: 6px; }
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
.admin-frame:last-child { border-bottom: none; }
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
margin: 0 0 14px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
}
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.palette-table-wrap { overflow-x: auto; margin-top: 14px; }
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.palette-table th {
text-align: left;
color: var(--text-muted);
font-weight: 600;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 0 8px 8px 0;
}
.palette-table td { padding: 4px 8px 4px 0; vertical-align: middle; }
.palette-table input { margin-top: 0; }
.palette-swatch-preview {
display: block;
width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid var(--border);
}
.palette-table input.palette-hex {
width: 92px;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
text-transform: lowercase;
}
.palette-table input.palette-rgb {
width: 58px;
}
.slider-value {
float: right;
font-weight: 400;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
input[type="range"] {
width: 100%;
margin-top: 8px;
accent-color: var(--accent);
padding: 0;
background: none;
}
.preview-compare {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
margin-top: 14px;
}
.preview-img {
width: 100%;
display: block;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-alt);
min-height: 100px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 22px;
box-shadow: var(--shadow);
}
.card + .card { margin-top: 20px; }
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
label:first-child { margin-top: 0; }
input, select {
width: 100%;
padding: 9px 10px;
box-sizing: border-box;
margin-top: 5px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
background: var(--bg);
color: var(--text);
font-family: inherit;
transition: border-color .15s ease, box-shadow .15s ease;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; }
.checkbox-row label { margin-top: 0; font-weight: normal; }
button {
margin-top: 20px;
padding: 10px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-family: inherit;
transition: background-color .15s ease, transform .1s ease;
}
button:hover { background: var(--accent-hover); }
button:active { transform: translateY(1px); }
button.btn-inline {
margin-top: 0;
padding: 3px 10px;
font-size: 12px;
vertical-align: middle;
}
button.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
margin-right: 8px;
}
button.secondary:hover { background: var(--surface-alt); }
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
.status.ok { background: var(--success-bg); color: var(--success-text); }
.status.err { background: var(--danger-bg); color: var(--danger-text); }
.info-box {
margin-bottom: 20px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
background: var(--surface-alt);
color: var(--text-muted);
border: 1px solid var(--border);
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
code {
background: var(--surface-alt);
color: var(--text);
padding: 2px 5px;
border-radius: 4px;
font-size: 0.92em;
}
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
.photo-card {
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
background: var(--surface-alt); border: 1px solid var(--border);
box-shadow: var(--shadow);
transition: box-shadow .15s ease, transform .15s ease;
/* pan-y (not none): lets a normal touch-scroll of the page work
when you touch a card without meaning to drag it. Dragging on
touch instead requires a brief hold first (see the JS below),
which switches this to "none" for the rest of that touch --
only once we're sure it's a deliberate drag, not a scroll. */
touch-action: pan-y;
/* Without this, a press-and-drag gesture also triggers the
browser's native text/content selection (the blue highlight) --
distracting, and on some browsers it fights the pointer-based
drag tracking below closely enough to break it outright. */
user-select: none;
-webkit-user-select: none;
}
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.photo-card:active { cursor: grabbing; }
.photo-card.drag-armed {
box-shadow: 0 0 0 3px var(--focus-ring) inset;
transform: scale(0.97);
}
/* No transform transition here -- the JS drives transform on every
pointermove to track the finger 1:1, and the .15s base transition
would otherwise make it visibly lag behind a fast swipe. */
.photo-card.dragging {
z-index: 20;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
transition: box-shadow .15s ease;
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
cursor: grabbing;
}
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
.photo-card .remove-btn, .thumb-wrap .remove-btn {
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
background: var(--overlay); color: white;
}
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
.photo-card .show-next {
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
}
.thumb-wrap { position: relative; display: inline-block; }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 20px;
align-items: start;
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
/* ------------------------------------------------------------------ */
/* App shell: left sidebar (frame list + account nav) + main content. */
/* Used by app_base.html for all logged-in pages; the narrow auth/ */
/* manage pages keep the simple centered .page layout above. */
/* ------------------------------------------------------------------ */
.shell { display: flex; min-height: 100vh; }
.sidebar {
width: 248px;
flex: none;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 20px 14px 16px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar .brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 18px; }
.sidebar .brand h1 { font-size: 17px; }
.sidebar-section {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding: 14px 8px 6px;
}
.sidebar a.nav-item, .sidebar .nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-size: 14px;
transition: background-color .12s ease;
}
.sidebar a.nav-item:hover { background: var(--surface-alt); }
.sidebar a.nav-item.active { background: var(--surface-alt); font-weight: 600; }
.nav-item .frame-dot {
width: 8px; height: 8px; border-radius: 50%; flex: none;
background: var(--text-muted); opacity: 0.5;
}
.nav-item.online .frame-dot { background: #22c55e; opacity: 1; }
.nav-item .nav-sub { margin-left: auto; font-size: 11.5px; color: var(--text-muted); }
.sidebar-footer { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--border); }
.sidebar-footer .nav-item { color: var(--text-muted); font-size: 13.5px; }
.sidebar-footer form { margin: 0; }
.sidebar-footer button.linklike {
display: block; width: 100%; text-align: left; padding: 9px 10px; border-radius: 8px;
}
.sidebar-footer button.linklike:hover { background: var(--surface-alt); }
.main {
flex: 1;
min-width: 0;
padding: 24px 28px 72px;
max-width: 1160px;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.page-head h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
.page-head .head-actions { display: flex; align-items: center; gap: 10px; }
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.tabs a {
padding: 9px 14px;
font-size: 14px;
color: var(--text-muted);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color .12s ease;
}
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.control-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
padding: 10px 14px;
border-radius: 10px;
font-size: 13.5px;
background: var(--warn-bg);
color: var(--warn-text);
}
.control-banner button { margin: 0; }
/* Always-visible device summary, sitting between the page title and the
tabs (see _device_status_bar.html) -- a compact horizontal row rather
than a full .card, since it has to fit above the tabs on every frame
page without pushing content down. */
.device-status-bar {
padding: 10px 16px;
margin-bottom: 18px;
}
.device-status-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 26px;
row-gap: 6px;
}
.device-status-row .sub { margin: 0; }
.device-stat {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
.device-stat strong { color: var(--text); font-weight: 600; }
.device-stat.alert, .device-stat.alert strong { color: var(--danger-text); }
@media (max-width: 860px) {
.device-status-row { column-gap: 16px; }
}
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
.mobile-bar { display: none; }
.sidebar-backdrop { display: none; }
@media (max-width: 860px) {
.mobile-bar {
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 30;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 14px;
}
.mobile-bar .brand { display: flex; align-items: center; gap: 8px; }
.mobile-bar h1 { font-size: 16px; margin: 0; }
.mobile-bar .icon-btn { width: 34px; height: 34px; box-shadow: none; }
.mobile-bar .spacer { flex: 1; }
.shell { display: block; }
.sidebar {
position: fixed;
left: 0; top: 0; bottom: 0;
z-index: 50;
height: 100vh;
transform: translateX(-105%);
transition: transform .2s ease;
box-shadow: var(--shadow-hover);
}
.shell.sidebar-open .sidebar { transform: translateX(0); }
.sidebar-backdrop {
position: fixed; inset: 0; z-index: 40;
background: var(--overlay);
opacity: 0; pointer-events: none;
transition: opacity .2s ease;
}
.shell.sidebar-open .sidebar-backdrop { display: block; opacity: 1; pointer-events: auto; }
.sidebar-backdrop { display: block; }
.main { padding: 18px 14px 64px; }
}
@@ -0,0 +1,3 @@
<section class="card device-status-bar" id="device-status-bar">
<div id="device-status" class="device-status-row"><p class="sub">Loading...</p></div>
</section>
+5
View File
@@ -0,0 +1,5 @@
<nav class="tabs">
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
</nav>
+135
View File
@@ -0,0 +1,135 @@
{% extends "app_base.html" %}
{% block title %}Admin{% endblock %}
{% block page_title %}Administration{% endblock %}
{% block content %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Users</h2>
<table class="admin-table">
<thead><tr><th>Username</th><th>Display name</th><th>Role</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.display_name }}</td>
<td>{{ "admin" if u.is_admin else "user" }}</td>
<td class="admin-actions">
<details>
<summary>Reset password</summary>
<form method="post" action="/admin/users/{{ u.id }}/reset-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="password" minlength="8" placeholder="New password" required>
<button type="submit" class="secondary btn-inline">Reset</button>
</form>
</details>
{% if u.id != user.id %}
<form method="post" action="/admin/users/{{ u.id }}/delete"
onsubmit="return confirm('Delete user {{ u.username }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2 class="card-title" style="margin-top: 24px;">Email (SMTP)</h2>
<p class="sub">Used for "forgot password" links and battery-low
alerts (set per frame in its Configuration tab). Each user needs
an email set in their own Settings for either to reach them.</p>
<form method="post" action="/admin/smtp">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>SMTP server
<input type="text" name="smtp_host" placeholder="smtp.example.com" value="{{ smtp.smtp_host }}">
</label>
<label>Port
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
</label>
<label>Encryption
<select name="smtp_encryption">
<option value="starttls" {% if smtp.smtp_encryption == "starttls" %}selected{% endif %}>STARTTLS (usually port 587)</option>
<option value="ssl" {% if smtp.smtp_encryption == "ssl" %}selected{% endif %}>SSL/TLS (usually port 465)</option>
<option value="none" {% if smtp.smtp_encryption == "none" %}selected{% endif %}>None (usually port 25)</option>
</select>
</label>
<label>Username
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
</label>
<label>Password
<input type="password" name="smtp_password" autocomplete="off"
placeholder="{% if smtp.smtp_password %}(unchanged -- enter a new one to replace){% else %}smtp password{% endif %}">
</label>
<label>From address
<input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}">
</label>
<button type="submit">Save SMTP settings</button>
</form>
<form method="post" action="/admin/smtp/test" style="margin-top: 8px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Send test email to myself</button>
</form>
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
<form method="post" action="/admin/users">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Username
<input type="text" name="username" maxlength="64" required>
</label>
<label>Password
<input type="password" name="password" minlength="8" required autocomplete="new-password">
</label>
<div class="checkbox-row">
<input type="checkbox" id="is_admin" name="is_admin" value="true">
<label for="is_admin">Administrator</label>
</div>
<button type="submit">Create user</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Frames</h2>
{% for f in frames %}
<div class="admin-frame">
<p class="sub">
<strong>#{{ f.id }} {{ f.name }}</strong><br>
device: <code>{{ f.device_id or "not yet reported" }}</code><br>
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
&middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
&middot; token ack: {{ "yes" if f.device_token_ack else "no" }}
{% if f.legacy_token_enabled %}&middot; <strong>legacy token window OPEN</strong>{% endif %}
</p>
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="username" placeholder="Link user by name" required>
<button type="submit" class="secondary btn-inline">Link</button>
</form>
{% if f.legacy_token_enabled %}
<form method="post" action="/admin/frames/{{ f.id }}/end-legacy" class="admin-inline-form"
onsubmit="return confirm('Close the legacy-token window for frame #{{ f.id }}? Only do this once the device has acknowledged its own token.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Close legacy window</button>
</form>
{% endif %}
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Delete</button>
</form>
</div>
{% endfor %}
{% if not frames %}<p class="sub">No frames yet.</p>{% endif %}
</section>
</div>
</div>
{% endblock %}
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}ESPresso Frame{% endblock %}</title>
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
<script>
// Applied before first paint so there's no flash of the wrong theme.
(function () {
try {
var stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored);
}
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
})();
</script>
<link rel="stylesheet" href="/static/theme.css">
{% block extra_head %}{% endblock %}
</head>
<body>
<div class="shell">
<div class="sidebar-backdrop"></div>
<aside class="sidebar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<h1>ESPresso Frame</h1>
</div>
<div class="sidebar-section">Frames</div>
{% for f in sidebar_frames %}
<a class="nav-item {% if active_frame and active_frame.id == f.id %}active{% endif %} {% if f.recently_seen %}online{% endif %}"
href="/frames/{{ f.id }}">
<span class="frame-dot" aria-hidden="true"></span>
{{ f.name or ("Frame " ~ f.id) }}
{% if f.owner_user_id is none %}
<span class="nav-sub">unclaimed</span>
{% elif f.battery_percent >= 0 %}
<span class="nav-sub battery-badge" title="Battery">🔋{{ f.battery_percent }}%</span>
{% endif %}
</a>
{% endfor %}
{% if not sidebar_frames %}
<p class="sub" style="padding: 0 10px;">No frames yet.</p>
{% endif %}
<div class="sidebar-footer">
<a class="nav-item {% if active_nav == 'settings' %}active{% endif %}" href="/settings">Settings</a>
{% if user.is_admin %}
<a class="nav-item {% if active_nav == 'admin' %}active{% endif %}" href="/admin">Admin</a>
{% endif %}
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</div>
</aside>
<div class="main-wrap" style="flex: 1; min-width: 0;">
<div class="mobile-bar">
<button type="button" id="sidebar-toggle" class="icon-btn" title="Menu" aria-label="Open menu"></button>
<div class="brand"><span aria-hidden="true"></span><h1>ESPresso Frame</h1></div>
<div class="spacer"></div>
</div>
<main class="main">
<div class="page-head">
<h1>{% block page_title %}{% endblock %}</h1>
<div class="head-actions">
{% block head_actions %}{% endblock %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div>
</div>
{% block device_status %}{% endblock %}
{% block tabs %}{% endblock %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<script src="/static/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+3 -318
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}ESPresso Frame{% endblock %}</title>
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
<script>
// Applied before first paint so there's no flash of the wrong theme.
(function () {
@@ -15,298 +16,7 @@
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
})();
</script>
<style>
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-alt: #f0f1f4;
--border: #e3e5e9;
--text: #16181d;
--text-muted: #666d7a;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--focus-ring: rgba(37, 99, 235, 0.35);
--success-bg: #dcfce7;
--success-text: #166534;
--danger-bg: #fee2e2;
--danger-text: #991b1b;
--warn-bg: #fef9c3;
--warn-text: #854d0e;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(153, 27, 27, 0.85);
--color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
* { box-sizing: border-box; }
html {
color-scheme: var(--color-scheme);
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
transition: background-color .15s ease, color .15s ease;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 28px 20px 72px;
}
.page.page-narrow {
max-width: 420px;
padding-top: 88px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.brand { display: flex; align-items: flex-start; gap: 12px; }
.brand-mark {
font-size: 26px;
line-height: 1;
margin-top: 2px;
}
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
.icon-btn {
flex: none;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
box-shadow: var(--shadow);
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
}
.icon-btn:hover { background: var(--surface-alt); }
.icon-btn:active { transform: scale(0.94); }
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
margin: 0 0 14px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
}
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 22px;
box-shadow: var(--shadow);
}
.card + .card { margin-top: 20px; }
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
label:first-child { margin-top: 0; }
input, select {
width: 100%;
padding: 9px 10px;
box-sizing: border-box;
margin-top: 5px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
background: var(--bg);
color: var(--text);
font-family: inherit;
transition: border-color .15s ease, box-shadow .15s ease;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; }
.checkbox-row label { margin-top: 0; font-weight: normal; }
button {
margin-top: 20px;
padding: 10px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-family: inherit;
transition: background-color .15s ease, transform .1s ease;
}
button:hover { background: var(--accent-hover); }
button:active { transform: translateY(1px); }
button.btn-inline {
margin-top: 0;
padding: 3px 10px;
font-size: 12px;
vertical-align: middle;
}
button.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
margin-right: 8px;
}
button.secondary:hover { background: var(--surface-alt); }
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
.status.ok { background: var(--success-bg); color: var(--success-text); }
.status.err { background: var(--danger-bg); color: var(--danger-text); }
.info-box {
margin-bottom: 20px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
background: var(--surface-alt);
color: var(--text-muted);
border: 1px solid var(--border);
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
code {
background: var(--surface-alt);
color: var(--text);
padding: 2px 5px;
border-radius: 4px;
font-size: 0.92em;
}
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
.photo-card {
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
background: var(--surface-alt); border: 1px solid var(--border);
box-shadow: var(--shadow);
transition: box-shadow .15s ease, transform .15s ease;
/* pan-y (not none): lets a normal touch-scroll of the page work
when you touch a card without meaning to drag it. Dragging on
touch instead requires a brief hold first (see the JS below),
which switches this to "none" for the rest of that touch --
only once we're sure it's a deliberate drag, not a scroll. */
touch-action: pan-y;
/* Without this, a press-and-drag gesture also triggers the
browser's native text/content selection (the blue highlight) --
distracting, and on some browsers it fights the pointer-based
drag tracking below closely enough to break it outright. */
user-select: none;
-webkit-user-select: none;
}
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.photo-card:active { cursor: grabbing; }
.photo-card.drag-armed {
box-shadow: 0 0 0 3px var(--focus-ring) inset;
transform: scale(0.97);
}
/* No transform transition here -- the JS drives transform on every
pointermove to track the finger 1:1, and the .15s base transition
would otherwise make it visibly lag behind a fast swipe. */
.photo-card.dragging {
z-index: 20;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
transition: box-shadow .15s ease;
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
cursor: grabbing;
}
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
.photo-card .remove-btn, .thumb-wrap .remove-btn {
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
background: var(--overlay); color: white;
}
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
.photo-card .show-next {
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
}
.thumb-wrap { position: relative; display: inline-block; }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 20px;
align-items: start;
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
</style>
<link rel="stylesheet" href="/static/theme.css">
{% block extra_head %}{% endblock %}
</head>
<body>
@@ -325,32 +35,7 @@
{% block content %}{% endblock %}
</div>
<script>
// Shared theme toggle: explicit choice wins over the OS preference and
// is remembered; with no explicit choice, the CSS above falls back to
// prefers-color-scheme on its own.
(function () {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
function currentTheme() {
var stored = null;
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
if (stored === 'light' || stored === 'dark') return stored;
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
function apply(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
}
btn.addEventListener('click', function () {
apply(currentTheme() === 'dark' ? 'light' : 'dark');
});
})();
</script>
<script src="/static/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Claim your frame</p>
{% endblock %}
{% block extra_head %}
{% if status == "unregistered" %}<meta http-equiv="refresh" content="6">{% endif %}
{% endblock %}
{% block content %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<section class="card">
<h2 class="card-title">Frame <code>{{ device_id }}</code></h2>
{% if status == "claimed_yours" %}
<div class="status ok">This frame is linked to your account.</div>
<p class="sub">It will show up in your frame list. If it was just
provisioned, give it a minute to connect and fetch its first image.</p>
<p><a href="/">Go to your frames</a></p>
{% elif status == "claimed" %}
<p class="sub">This frame already belongs to someone. If it's yours,
ask them (or an admin) to link your account to it.</p>
{% elif status == "unregistered" %}
{% if pending_yours %}
<div class="status ok">Claim recorded.</div>
<p class="sub">Waiting for the frame to connect for the first time --
it links to your account automatically the moment it checks in.
This page refreshes itself; it's safe to close, too.</p>
{% else %}
<p class="sub">The frame hasn't checked in yet -- it's probably still
restarting and joining your WiFi. This page refreshes itself.
{% if user %}You can claim it now anyway; it'll attach when it
arrives.{% endif %}</p>
{% endif %}
{% elif status == "unclaimed" %}
<p class="sub">This frame is connected and ready to be claimed.</p>
{% endif %}
{% if user and status in ("unclaimed", "unregistered") and not pending_yours %}
<form method="post" action="/claim">
<input type="hidden" name="device_id" value="{{ device_id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Claim this frame</button>
</form>
{% endif %}
</section>
{% if not user and status in ("unclaimed", "unregistered") %}
<section class="card">
<h2 class="card-title">Create your account</h2>
<p class="sub">A valid frame is your invitation -- set up an account to
claim it. Already have one?
<a href="/login?next=/claim%3Fdevice_id%3D{{ device_id }}">Log in instead</a>.</p>
<form method="post" action="/claim/signup">
<input type="hidden" name="device_id" value="{{ device_id }}">
<label>Username
<input type="text" name="username" maxlength="64" required autocomplete="username">
</label>
<label>Password
<input type="password" name="password" minlength="8" required autocomplete="new-password">
</label>
<button type="submit">Create account &amp; claim frame</button>
</form>
</section>
{% endif %}
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Reset your password</p>
{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Forgot password</h2>
{% if sent %}
<div class="status ok">If that email is on an account, a reset link is on its way.</div>
<p class="sub" style="margin-top: 14px;">Nothing arriving? The server's
SMTP settings may not be configured yet -- ask your admin.</p>
{% else %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<p class="sub">Enter the email on your account and we'll send a reset link.</p>
<form method="post" action="/forgot-password">
<label>Email
<input type="email" name="email" required autofocus autocomplete="email">
</label>
<button type="submit">Send reset link</button>
</form>
{% endif %}
<p class="sub" style="margin-top: 14px;"><a href="/login">Back to log in</a></p>
</section>
{% endblock %}
+271
View File
@@ -0,0 +1,271 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Display settings</h2>
<form id="config-form">
<label>Frame mode
<select id="frame_mode">
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
</select>
</label>
<label>Frame name
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
</label>
<label>Order
<select id="order">
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
</label>
<label>Display mode
<select id="display_mode">
{% for mode, label in display_mode_labels.items() %}
<option value="{{ mode }}" {% if frame.display_mode == mode %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">How a photo's aspect ratio
is reconciled with the panel's: <strong>Crop to fill</strong>
trims the excess; <strong>Crop to faces</strong> does the same
but shifts the crop to keep people on screen; <strong>Stretch to
fill</strong> fills the panel exactly without cropping (photos
not matching the panel's aspect ratio look stretched);
<strong>Shrink to fit</strong> shows the whole photo, letterboxed
if needed.</p>
<div class="checkbox-row">
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
</div>
<label>Quiet hours start
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
</label>
<label>Timezone
<select id="timezone">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">Quiet hours times are
interpreted in this timezone. The device may still wake once right
at the start of quiet hours -- it can't know ahead of time -- but
goes right back to sleep until they end.</p>
<button type="submit">Save</button>
</form>
</section>
<section class="card" id="calendar-card" style="{% if frame.mode != 'calendar' %}display: none;{% endif %}">
<h2 class="card-title">Calendar</h2>
<form id="calendar-config-form">
<label>View
<select id="calendar_view">
{% for value, label in calendar_views.items() %}
<option value="{{ value }}" {% if frame.calendar_view == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div class="checkbox-row" id="calendar-inlay-row" style="{% if frame.calendar_view != 'agenda' %}display: none;{% endif %}">
<input type="checkbox" id="calendar_photo_inlay" {% if frame.calendar_photo_inlay %}checked{% endif %}>
<label for="calendar_photo_inlay">Show a photo alongside today's agenda</label>
</div>
<p class="sub" id="calendar-inlay-hint" style="margin-top: 4px; {% if frame.calendar_view != 'agenda' %}display: none;{% endif %}">
Uses the same album configured on the Photos tab -- nothing extra to set up.</p>
<button type="submit">Save</button>
</form>
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
<p class="sub">Each linked person decides whether their own calendar
contributes to this frame -- being linked here doesn't include it
automatically.</p>
<ul class="calendar-user-list">
{% for u in calendar_users %}
<li>
{% if u.user_id == user.id %}
{% if u.has_url %}
<label class="checkbox-row" style="margin-top: 6px;">
<input type="checkbox" class="calendar-self-toggle" {% if u.included %}checked{% endif %}>
{{ u.display_name }} (you)
</label>
{% else %}
<p class="sub" style="margin-top: 6px;">{{ u.display_name }} (you) -- no calendar set, add one in <a href="/settings">Settings</a>.</p>
{% endif %}
{% else %}
<p class="sub" style="margin-top: 6px;">{{ u.display_name }}:
{% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% if frame.calendar_fetch_summary %}
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p>
{% endif %}
<h2 class="card-title" style="margin-top: 20px;">Preview</h2>
<p class="sub">How this frame's calendar currently renders.</p>
<img class="preview-img" id="calendar-preview" alt="Calendar preview">
<button type="button" class="secondary" id="calendar-preview-refresh">Refresh preview</button>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p>
<p class="sub" id="firmware-available">
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
</p>
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
</p>
</div>
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ frame.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<p class="sub" style="margin-top: 4px;">While on, this frame installs
whatever the repo above publishes next, with nobody reviewing it
first -- only point it at a repo you trust.</p>
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
<section class="card">
<h2 class="card-title">Battery alerts</h2>
<label>Email me when battery drops below (%)
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
placeholder="disabled">
</label>
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
to the frame owner's email (set in Settings) -- clear the field to
disable. Needs SMTP configured by an admin.</p>
<button type="button" class="secondary" id="battery-alert-save">Save</button>
</section>
<details class="card">
<summary class="card-title">Advanced configuration</summary>
<p class="sub">Color-quantization values used when dithering photos
for this panel -- approximations by default, since exact primaries
aren't published. Tune them by comparing a rendered photo against
the physical panel; different panel units can vary enough to be
worth calibrating per frame.</p>
<div class="palette-table-wrap">
<table class="palette-table">
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
<tbody>
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
{% for label in palette_labels %}
<tr>
<td><span class="palette-swatch-preview" data-index="{{ loop.index0 }}"
style="background: {{ current_hex[loop.index0] }};"></span></td>
<td>{{ label }}</td>
<td><input type="text" class="palette-hex" id="palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
value="{{ current_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
spellcheck="false" autocomplete="off"></td>
<td><input type="number" class="palette-rgb palette-r" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][0] }}"></td>
<td><input type="number" class="palette-rgb palette-g" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][1] }}"></td>
<td><input type="number" class="palette-rgb palette-b" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][2] }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2 class="card-title" style="margin-top: 20px;">Image adjustments</h2>
<label>Color enhancement <span class="slider-value" id="color_boost_value">{{ "%.2f" | format(frame.color_boost) }}</span>
<input type="range" id="color_boost" min="0" max="2" step="0.05" value="{{ frame.color_boost }}">
</label>
<label>Contrast <span class="slider-value" id="contrast_boost_value">{{ "%.2f" | format(frame.contrast_boost) }}</span>
<input type="range" id="contrast_boost" min="0" max="2" step="0.05" value="{{ frame.contrast_boost }}">
</label>
<label>Dithering strength <span class="slider-value" id="dither_strength_value">{{ "%.2f" | format(frame.dither_strength) }}</span>
<input type="range" id="dither_strength" min="0" max="1" step="0.05" value="{{ frame.dither_strength }}">
</label>
<p class="sub" style="margin-top: 8px;">1.00 is unchanged for color/
contrast. Dithering strength trades noise texture for smoother
gradients as it goes down; 0 is a flat, un-dithered quantization.
Use the preview below to compare.</p>
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
</details>
<section class="card">
<h2 class="card-title">Preview</h2>
<p class="sub">The current photo, and exactly how it renders on the
panel with this frame's saved settings above.</p>
<div class="preview-compare">
<div class="preview-pane">
<p class="sub">Now displaying</p>
<img class="preview-img" id="preview-original" alt="Original photo">
</div>
<div class="preview-pane">
<p class="sub">How it will look on the frame</p>
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
</div>
</div>
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_config.js"></script>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Album</h2>
<form id="photos-form">
<label>Album
<select id="album_id">
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
</select>
</label>
<label>Upcoming photos to show
<select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
</section>
</div>
</div>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Upcoming</h2>
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
normal scroll still works), "Show next" to jump it to the front, or
the &times; to remove it from rotation entirely.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/queue.js"></script>
<script src="/static/frame_photos.js"></script>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Battery history</h2>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Lifetime stats</h2>
<div id="stats-box"><p class="sub">Loading...</p></div>
</section>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/battery_chart.js"></script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_stats.js"></script>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "app_base.html" %}
{% block title %}ESPresso Frame{% endblock %}
{% block page_title %}Welcome{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">No frames yet</h2>
<p class="sub">Set up a frame and it'll appear in the sidebar:</p>
<p class="sub">1. Power the frame on -- it opens a WiFi network named
<code>ESPRESSO_XXXXXX</code> and shows join instructions on its panel.</p>
<p class="sub">2. Join that network and fill in your WiFi details plus this
server's address.</p>
<p class="sub">3. Your browser lands on this server's claim page and links
the frame to your account automatically.</p>
<p class="sub" style="margin-top: 12px;">Already provisioned? Ask whoever
claimed it (or an admin) to link your account, or scan the frame's
on-panel manage QR.</p>
</section>
{% endblock %}
-812
View File
@@ -1,812 +0,0 @@
{% extends "base.html" %}
{% block subtitle %}
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
{% endblock %}
{% block content %}
{% if cfg.immich_url %}
<div class="info-box">Immich: <code>{{ cfg.immich_url }}</code> (API key configured). Set via
<code>IMMICH_URL</code>/<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> -- see
<code>docker-compose.yml.example</code>.</div>
{% else %}
<div class="info-box warn">Immich isn't configured yet. Set <code>IMMICH_URL</code> and
<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> (copy
<code>docker-compose.yml.example</code>) and restart the server.</div>
{% endif %}
<div class="layout">
<div class="main-col">
<section class="card config-panel">
<h2 class="card-title">Settings</h2>
<form id="config-form">
<label>Album
<select id="album_id">
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
</select>
</label>
<label>Order
<select id="order">
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if cfg.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if cfg.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if cfg.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if cfg.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
</label>
<div class="checkbox-row">
<input type="checkbox" id="smart_crop_faces" {% if cfg.smart_crop_faces %}checked{% endif %}>
<label for="smart_crop_faces">Center faces in crop</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="quiet_hours_enabled" {% if cfg.quiet_hours_enabled %}checked{% endif %}>
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
</div>
<label>Quiet hours start
<input type="time" id="quiet_hours_start" value="{{ cfg.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
</label>
<label>Timezone
<select id="timezone">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == cfg.timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">Quiet hours times above are
interpreted in this timezone. The device may still wake once right
at the start of quiet hours -- it can't know ahead of time -- but
goes right back to sleep until they end.</p>
<label>Upcoming photos to show
<select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
<div id="result"></div>
</section>
<details class="card">
<summary class="card-title">Stats</summary>
<div id="stats-box"><p class="sub">Loading...</p></div>
</details>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Battery history</h2>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if cfg.device_board_variant %}Detected board: {{ cfg.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p>
<p class="sub" id="firmware-available">
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
</p>
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
<div id="firmware-repo-display" style="margin-top: 16px; {% if not cfg.firmware_update_repo_url %}display: none;{% endif %}">
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ cfg.firmware_update_repo_url }}</code>
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
</p>
</div>
<label id="firmware-repo-edit" style="{% if cfg.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ cfg.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
</div>
</div>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Upcoming</h2>
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
normal scroll still works), "Show next" to jump it to the front, or
the &times; to remove it from rotation entirely.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
{% endblock %}
{% block scripts %}
<script>
const resultEl = document.getElementById('result');
function showStatus(ok, message) {
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
}
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
queue_target_len: document.getElementById('queue_target_len').value,
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
timezone: document.getElementById('timezone').value || 'UTC',
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
});
const resp = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
document.getElementById('load-albums').addEventListener('click', async () => {
try {
await saveConfig();
const resp = await fetch('/api/albums');
if (!resp.ok) {
throw new Error(await resp.text());
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveConfig();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
function showRepoDisplayMode(url) {
document.getElementById('firmware-repo-text').textContent = url;
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
}
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
document.getElementById('firmware-repo-display').style.display = 'none';
document.getElementById('firmware-repo-edit').style.display = 'block';
document.getElementById('firmware_update_repo_url').focus();
});
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
try {
await saveConfig();
showStatus(true, 'Saved.');
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
}
});
let upcomingItems = [];
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
// code drives mouse, touch, and pen -- native drag-and-drop is
// mouse-only by spec and never fires at all on phones/tablets.
//
// On touch specifically, a card only "arms" for dragging after a
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
// the whole time up to that point, so a normal touch-and-swipe to
// scroll the page still works even though it starts on a card. Once
// armed, touch-action switches to "none" for the rest of that touch
// so drag tracking gets every pointermove reliably. Mouse skips the
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
const DRAG_HOLD_MS = 250;
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
function vibrate(ms) {
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
// it's a harmless no-op everywhere else rather than needing a
// feature check at every call site.
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
}
function clearDragOverStyling() {
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
}
function endDrag(card) {
if (dragState && dragState.holdTimer) {
clearTimeout(dragState.holdTimer);
}
card.style.touchAction = '';
card.style.transform = '';
card.classList.remove('dragging', 'drag-armed');
clearDragOverStyling();
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
vibrate(15);
moveItem(dragState.fromIndex, dragState.toIndex);
}
dragState = null;
}
function renderUpcoming(items) {
upcomingItems = items;
const grid = document.getElementById('upcoming-grid');
grid.innerHTML = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'photo-card';
card.dataset.index = String(i);
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
card.appendChild(img);
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = String(i + 1);
card.appendChild(badge);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeAsset(item.id);
});
card.appendChild(removeBtn);
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'show-next';
nextBtn.textContent = 'Show next';
nextBtn.addEventListener('click', (e) => {
e.stopPropagation();
showNext(i);
});
card.appendChild(nextBtn);
card.addEventListener('pointerdown', (e) => {
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
return; // let the button's own click handler run, don't start a drag
}
if (e.pointerType === 'mouse' && e.button !== 0) {
return; // left button only
}
dragState = {
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
};
if (e.pointerType === 'touch') {
dragState.holdTimer = setTimeout(() => {
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
dragState.armed = true;
card.classList.add('drag-armed');
card.style.touchAction = 'none';
vibrate(10);
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
}
}, DRAG_HOLD_MS);
} else {
card.setPointerCapture(e.pointerId);
}
});
card.addEventListener('pointermove', (e) => {
if (!dragState || dragState.pointerId !== e.pointerId) {
return;
}
if (!dragState.armed) {
// Still deciding whether this is a hold-to-drag or a scroll --
// moving this much before the hold timer fires means scroll;
// bail out and let the browser's native pan-y handle it.
const dx0 = e.clientX - dragState.startX;
const dy0 = e.clientY - dragState.startY;
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
clearTimeout(dragState.holdTimer);
dragState = null;
}
return;
}
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved) {
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
return;
}
dragState.moved = true;
card.classList.remove('drag-armed');
card.classList.add('dragging');
}
// Follows the finger 1:1 -- the actual "pick it up and carry it"
// feedback that was missing before (the card used to just fade
// in place while a static outline highlighted the drop target).
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
clearDragOverStyling();
if (overCard && overCard !== card) {
overCard.classList.add('drag-over');
dragState.toIndex = Number(overCard.dataset.index);
} else {
dragState.toIndex = undefined;
}
});
card.addEventListener('pointerup', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
card.addEventListener('pointercancel', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
grid.appendChild(card);
});
}
function moveItem(fromIndex, toIndex) {
const items = upcomingItems.slice();
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
renderUpcoming(items);
persistOrder(items);
}
async function showNext(index) {
const assetId = upcomingItems[index].id;
try {
const resp = await fetch('/api/queue/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue(); // always refetch the authoritative order rather than guessing locally
}
async function removeAsset(assetId) {
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
return;
}
try {
const resp = await fetch('/api/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue();
}
async function persistOrder(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();
}
}
function formatDuration(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function renderDeviceStatus(device) {
const el = document.getElementById('device-status');
el.innerHTML = '';
if (!device || !device.last_seen) {
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
return;
}
const now = Date.now() / 1000;
const rows = [];
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push([`Last seen`, `${ago} ago`, device.overdue]);
if (device.firmware_version) {
let fw = `v${device.firmware_version}`;
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
fw += ` (v${device.firmware_available} waiting)`;
}
rows.push(['Firmware', fw, false]);
}
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
}
if (device.on_battery_since) {
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
}
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
}
for (const [label, value, alert] of rows) {
const p = document.createElement('p');
p.className = 'sub';
if (alert) {
p.style.color = 'var(--danger-text)';
p.style.fontWeight = '600';
}
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
document.getElementById('firmware-upload').addEventListener('click', async () => {
const input = document.getElementById('firmware-file');
if (!input.files.length) {
showStatus(false, 'Pick a firmware .bin first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch('/api/firmware', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
} catch (e) {
showStatus(false, e.message);
}
});
async function loadFirmwareCheck() {
const statusEl = document.getElementById('firmware-gitea-status');
const btn = document.getElementById('firmware-update-btn');
const boardEl = document.getElementById('firmware-board');
try {
const resp = await fetch('/api/firmware/check');
if (!resp.ok) {
return;
}
const data = await resp.json();
if (data.board) {
boardEl.textContent = `Detected board: ${data.board}`;
}
if (!data.enabled) {
statusEl.style.display = 'none';
btn.style.display = 'none';
return;
}
statusEl.style.display = 'block';
if (!data.board) {
statusEl.textContent = "Waiting for the frame to check in before it can look up the right build.";
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
btn.style.display = 'inline-block';
} else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none';
} else {
statusEl.textContent = 'No releases found yet.';
btn.style.display = 'none';
}
} catch (e) {
// A failed check is silent -- the manual upload path still works
// regardless, and this just retries on the next poll.
}
}
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
const btn = document.getElementById('firmware-update-btn');
btn.disabled = true;
try {
const resp = await fetch('/api/firmware/apply-latest', { method: 'POST' });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
} finally {
btn.disabled = false;
}
});
let lastDevice = null;
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
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 = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
lastDevice = data.device;
renderDeviceStatus(data.device);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
// Reads resolved colors from CSS custom properties rather than
// hardcoding hex values, so the chart matches the current theme
// (light/dark) without needing its own separate palette.
function themeColor(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
let lastBatteryLog = null;
function drawBatteryChart(log) {
lastBatteryLog = log;
const wrap = document.getElementById('battery-chart-wrap');
if (!log || log.length < 2) {
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
return;
}
wrap.innerHTML = '';
const width = wrap.clientWidth || 440;
const height = 180;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.display = 'block';
canvas.style.border = `1px solid ${themeColor('--border')}`;
canvas.style.borderRadius = '8px';
wrap.appendChild(canvas);
const ctx = canvas.getContext('2d');
const gridColor = themeColor('--border');
const mutedColor = themeColor('--text-muted');
const accentColor = themeColor('--accent');
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const times = log.map((p) => p[0]);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const spanT = Math.max(1, maxT - minT);
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
ctx.strokeStyle = gridColor;
ctx.fillStyle = mutedColor;
ctx.font = '10px system-ui, sans-serif';
ctx.lineWidth = 1;
ctx.textAlign = 'left';
[0, 25, 50, 75, 100].forEach((pct) => {
const yy = y(pct);
ctx.beginPath();
ctx.moveTo(pad.left, yy);
ctx.lineTo(width - pad.right, yy);
ctx.stroke();
ctx.fillText(String(pct), 2, yy + 3);
});
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
log.forEach((p, i) => {
const px = x(p[0]);
const py = y(p[1]);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
ctx.fillStyle = mutedColor;
ctx.textAlign = 'left';
ctx.fillText(fmt(minT), pad.left, height - 4);
ctx.textAlign = 'right';
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
}
async function loadBatteryLog() {
const wrap = document.getElementById('battery-chart-wrap');
try {
const resp = await fetch('/api/battery-log');
if (!resp.ok) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
const data = await resp.json();
drawBatteryChart(data.log);
} catch (e) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
}
}
function renderStats(stats) {
const el = document.getElementById('stats-box');
el.innerHTML = '';
const now = Date.now() / 1000;
const rows = [
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
['Wake cycles', stats.device_wakes],
['Photos displayed', stats.photos_displayed],
['Photos removed from rotation', stats.photos_removed],
['Battery reports received', stats.battery_reports],
['Battery recharge cycles', stats.recharge_cycles],
['OTA updates applied', stats.ota_updates_applied],
['Settings saved', stats.config_saves],
];
for (const [label, value] of rows) {
const p = document.createElement('p');
p.className = 'sub';
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadStats() {
const el = document.getElementById('stats-box');
try {
const resp = await fetch('/api/stats');
if (!resp.ok) {
el.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
renderStats(await resp.json());
} catch (e) {
el.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadQueue();
loadBatteryLog();
loadStats();
loadFirmwareCheck();
// Redraw the canvas chart (and the "Last seen" alert color) with the
// new theme's colors as soon as the toggle in the header is used --
// canvas pixels don't repaint themselves the way CSS does.
document.addEventListener('themechange', () => {
if (lastBatteryLog) {
drawBatteryChart(lastBatteryLog);
}
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
});
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
// already-fetched device data every second, so they count up smoothly
// (1s ago, 5s ago, 1m ago...) without hitting the server that often.
setInterval(() => {
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
}, 1000);
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere, battery report, firmware version) without a manual
// refresh. Skipped mid-drag (see loadQueue above).
setInterval(loadQueue, 10000);
// Separate, slower poll for the Gitea release check -- cheap either
// way since the server itself throttles actual Gitea API calls to
// once per gitea_releases.UPDATE_CHECK_INTERVAL_S.
setInterval(loadFirmwareCheck, 60000);
</script>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Sign in</p>
{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Log in</h2>
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{ next }}">
<label>Username
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
</label>
<label>Password
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit">Log in</button>
</form>
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Forgot your password?</a></p>
</section>
{% endblock %}
+116
View File
@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block subtitle %}
<p class="sub">{{ frame.name or "Frame" }} &mdash; quick controls</p>
{% endblock %}
{% block content %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Up next</h2>
<p class="sub">Tap "Show next" to move a photo to the front. The frame
picks it up on its next refresh. <a href="/login">Log in</a> for full
settings.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<div style="display: flex; gap: 8px;">
<button type="button" class="secondary" id="btn-back">&larr; Previous</button>
<button type="button" class="secondary" id="btn-advance">Next &rarr;</button>
</div>
<p class="sub" style="margin-top: 8px;">Changes what the frame shows on
its next wake.</p>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>
const TOKEN = {{ manage_token | tojson }};
const resultEl = document.getElementById('result');
function showStatus(ok, message) {
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
}
async function post(path, body) {
const resp = await fetch(`/api/m/${TOKEN}/${path}`, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
async function loadQueue() {
const currentEl = document.getElementById('current-thumb');
const grid = document.getElementById('upcoming-grid');
try {
const resp = await fetch(`/api/m/${TOKEN}/queue`);
if (!resp.ok) {
currentEl.innerHTML = '<p class="sub">This frame isn\'t set up yet.</p>';
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
currentEl.appendChild(img);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
grid.innerHTML = '';
for (const item of data.upcoming) {
const card = document.createElement('div');
card.className = 'photo-card';
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
img.draggable = false;
card.appendChild(img);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'show-next';
btn.textContent = 'Show next';
btn.addEventListener('click', async () => {
try {
await post('promote', { asset_id: item.id });
showStatus(true, 'Moved to the front.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
card.appendChild(btn);
grid.appendChild(card);
}
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
document.getElementById('btn-advance').addEventListener('click', async () => {
try { await post('advance'); showStatus(true, 'Advanced.'); loadQueue(); }
catch (e) { showStatus(false, e.message); }
});
document.getElementById('btn-back').addEventListener('click', async () => {
try { await post('back'); showStatus(true, 'Went back.'); loadQueue(); }
catch (e) { showStatus(false, e.message); }
});
loadQueue();
setInterval(loadQueue, 15000);
</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Reset your password</p>
{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Set a new password</h2>
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
{% if valid %}
<form method="post" action="/reset-password/{{ token }}">
<label>New password
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password">
</label>
<button type="submit">Set password</button>
</form>
{% else %}
<p class="sub">This reset link is invalid or has expired -- links are
only good for an hour.</p>
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Request a new one</a></p>
{% endif %}
</section>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "app_base.html" %}
{% block title %}Settings{% endblock %}
{% block page_title %}Your account{% endblock %}
{% block content %}
{% if saved %}<div class="status ok">Saved.</div>{% endif %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<section class="card">
<h2 class="card-title">Profile &amp; photo library</h2>
<form method="post" action="/settings">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Display name
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
</label>
<label>Email
<input type="email" name="email" value="{{ user.email }}" placeholder="[email protected]">
</label>
<p class="sub" style="margin-top: 8px;">Used for password-reset links
and, for frames you own, battery-low alerts (set a threshold in a
frame's Configuration tab).</p>
<label>Immich URL
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
value="{{ user.immich_url }}">
</label>
<label>Immich API key
<input type="password" name="immich_api_key" autocomplete="off"
placeholder="{% if user.immich_api_key %}(unchanged -- enter a new key to replace){% else %}your-immich-api-key{% endif %}">
</label>
<p class="sub" style="margin-top: 8px;">Frames you own pull photos from
this Immich library. The key needs read access to albums/assets/faces
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
<h2 class="card-title" style="margin-top: 24px;">Calendar</h2>
<label>Calendar URL (iCal/CalDAV .ics feed)
<input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics"
value="{{ user.calendar_ics_url }}">
</label>
<p class="sub" style="margin-top: 8px;">Your personal calendar
subscription link (no login needed -- e.g. Google Calendar's
Settings &rarr; "Secret address in iCal format", or Apple/Outlook/
Nextcloud's equivalent). Setting it here doesn't show it anywhere
by itself -- include it on any frame you're linked to from that
frame's Configuration &rarr; Calendar card, so a frame only shows
calendars people have actually chosen to share with it.</p>
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
<label>Current password
<input type="password" name="current_password" autocomplete="current-password">
</label>
<label>New password
<input type="password" name="new_password" minlength="8" autocomplete="new-password">
</label>
<p class="sub" style="margin-top: 8px;">Leave both blank to keep your
current password.</p>
<button type="submit">Save</button>
</form>
</section>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">First-run setup</p>
{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Create the admin account</h2>
<p class="sub">This server has no users yet. The account you create here
is the administrator: it can enroll other users and manage every
frame. Any frame this server already knows about is linked to it
automatically.</p>
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<form method="post" action="/setup">
<label>Username
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
</label>
<label>Display name (optional)
<input type="text" name="display_name" maxlength="64" autocomplete="name">
</label>
<label>Password
<input type="password" name="password" minlength="8" required autocomplete="new-password">
</label>
<button type="submit">Create admin account</button>
</form>
</section>
{% endblock %}
+4
View File
@@ -5,3 +5,7 @@ httpx==0.28.1
pillow==12.3.0
python-multipart==0.0.20
jinja2==3.1.5
sqlalchemy==2.0.51
qrcode==8.2
icalendar==7.2.2
recurring-ical-events==3.8.2