23 Commits
Author SHA1 Message Date
tfaour 7d34eca5d7 Bump firmware version to 1.4.0
Firmware build check / build-check (push) Successful in 2m53s
Build and release firmware / build-and-release (push) Successful in 2m51s
Release build for the per-widget button actions + hold-for-global-
action firmware changes (short/long press detection on next/back,
POST /frame/global-next|back). CI's firmware-build-check.yml already
confirmed both board variants compile clean at this commit.
2026-07-27 22:18:47 +00:00
tfaour fcf3aec4c0 Move button actions to per-widget config, add hold-for-global-action
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s
Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
2026-07-27 22:09:33 +00:00
tfaour 9911151d8d Add per-photo-widget lock (freezes current photo until unlocked)
Build and push server image / test (push) Successful in 33s
Build and push server image / build-and-push (push) Successful in 2m32s
Build and push server image / deploy (push) Successful in 57s
A "Lock this photo" button in the photos widget's dialog toggles
PhotoWidgetConfig.locked, which suppresses both the timer-elapsed
auto-advance and the advance/back button actions until unlocked. The
Layout tab canvas shows a lock badge on any locked photo widget's box.
2026-07-27 21:07:54 +00:00
tfaour 4e8c6e534b Install Node.js from Debian's own repo, drop NodeSource dependency
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m34s
Build and push server image / deploy (push) Successful in 53s
deb.nodesource.com started intermittently 403ing today on both its
setup_*.x scripts and its GPG key (confirmed directly, not just via CI --
some setup_NN.x paths 403, others 200, no consistent pattern), and the
curl-piped-into-bash install pattern silently swallowed that failure
instead of breaking the build loudly: curl -f exits non-zero on a 403,
but bash then runs on empty stdin and exits 0, so the RUN kept going
into a broken fallback (Debian's own split nodejs package with no
bundled npm) rather than stopping.

This base image now tracks Debian trixie, whose own nodejs package
(20.19.2) is inside jsdom 29's engines range and clears express/
resvg-js's much lower floors -- the version gap that originally required
routing through NodeSource is gone, so this drops that whole external
dependency (and the curl/gnupg install-then-purge dance) rather than
just swapping to a different NodeSource script.
2026-07-27 20:40:30 +00:00
tfaour b15747a604 Add per-widget border option (style, thickness, palette color)
Build and push server image / test (push) Has been cancelled
Build and push server image / build-and-push (push) Has been cancelled
Build and push server image / deploy (push) Has been cancelled
A Widget-level property (border_style/border_thickness/border_color_index),
not a per-type config field, since every widget type can have one -- drawn
once centrally in device.py's _render_widgets before compositing, using
an exact panel palette color so it never dithers. Styles: solid, dashed,
dotted, and a fancy double-line picture-frame-mat look. Configurable from
a shared "Border" card in every widget's gear-icon dialog.
2026-07-27 19:51:04 +00:00
tfaour eb7127718b Add battery widget (device's own last-reported level, no live upstream)
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 50s
Shows Frame.battery_percent/battery_as_of, already set by every device
wake-on-battery report, plus routers/common.py's existing
battery_estimate_s time-remaining estimate -- nothing new to fetch or
cache. Compact (icon + percent) or detailed (+ estimate, last report
age) display mode. No button actions.
2026-07-27 19:02:21 +00:00
tfaour 90a014d161 Revert to hand-drawn weather icons, styled after EC's set but exact panel colors
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 49s
The vendored EC bitmaps looked good but dither into a visible speckle
once quantized to the panel's 6-color palette (their colors are
anti-aliased/arbitrary RGB, essentially never an exact palette match).
Hand-drawn icons filled with the frame's actual ink colors quantize with
zero dithering error to diffuse -- confirmed by running both through the
real quantize pass: the bitmap version speckles, the hand-drawn one is
pixel-identical before and after.

Redrawn to look more like EC's style this time around: pointed
triangular sun rays (the earlier attempt's thin-line rays read as a
crosshair, not a sun) and dendrite snowflakes (tick marks near each tip,
not a bare asterisk), plus the same cloud/raindrop/lightning-bolt shapes
as before. Removed the vendored server/app/weather_icons/ directory
entirely -- no longer used, and removes the icon-image licensing
question along with it.
2026-07-27 17:32:51 +00:00
tfaour efb0f2e22d Swap hand-drawn weather icons for Environment Canada's real icon set
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m10s
Build and push server image / deploy (push) Successful in 51s
The hand-drawn glyphs (draw_cloud/draw_sun/draw_raindrop/draw_snowflake/
draw_lightning_bolt) are replaced by 7 vendored bitmaps, one per shared
weather category, sourced from weather.gc.ca's public icon set -- these
are small, flat-shaded images that dither cleanly onto the panel's
6-color palette and read as recognizable weather icons in a way the
hand-drawn attempt (a plain circle-with-ticks "sun") didn't. Used for
every provider's rendering (Open-Meteo, NWS, EC), not just when EC is
selected.

Vendored (not fetched live at render time), matching this project's
existing convention for the Noto Emoji fonts -- server/app/weather_icons/
SOURCE.md documents the source, attribution, and the licensing caveat
(this is a personal, non-commercial project; the icon images' own
copyright terms are less clearly permissive than the weather data's own
End-use Licence, since they're served from the public website rather
than ECCC's data servers).

draw_weather_icon's signature changes from (draw, cx, cy, r, category,
palette_rgb) to (img, cx, cy, r, category): pasting a bitmap needs the
Image object, not just an ImageDraw handle, and palette_rgb is no longer
needed since the shared _quantize step already maps whatever's on the
composited canvas to the frame's actual palette -- no per-icon color
resolution required anymore.
2026-07-27 17:22:59 +00:00
tfaour 270979949f Add Environment Canada as a third weather provider
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 4m32s
Build and push server image / deploy (push) Successful in 49s
app/weather/ec.py -- api.weather.gc.ca's MSC GeoMet OGC API
(citypageweather-realtime collection), the modern replacement for the
old dd.weatheroffice.gc.ca XML feed (that host no longer resolves).
Unlike Open-Meteo/NWS's simple lat/lon REST, this collection is only
queryable by bounding box, so _nearest_site widens the box
progressively and picks the closest of the ~844 sites by straight-line
distance -- capped at 300km, calibrated against a real bug caught in
development where an unconditional "nearest site, however far" matched
a Miami, FL query to a site in Ontario 1824km away once the box widened
to cover the whole country.

EC's own numeric icon codes get a small confirmed-against-live-data
mapping table plus the same keyword-on-condition-text fallback NWS
already uses for anything unmapped. Daily periods are named ("Today"/
"Tonight"/"Tuesday"/...) rather than dated, so dates are inferred by
walking them in issued order.

Verified end-to-end against the real live API (Toronto, rural
Saskatchewan, a US border city, and a rejected far-away match) and
through the browser (daily mode, composited panel preview). Test
fixtures mirror the actual response shapes captured live. docs/
widgets.md and CLAUDE.md's TODO updated -- EC is no longer a documented
gap.
2026-07-27 16:50:40 +00:00
tfaour 52ebafab78 Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
Build and push server image / test (push) Successful in 1m11s
Build and push server image / build-and-push (push) Successful in 2m3s
Build and push server image / deploy (push) Successful in 52s
New widget type with four display modes -- current conditions, an
hourly forecast strip, a multi-day forecast, and several cities' current
day side by side -- backed by a pluggable provider registry (app/weather/,
mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and
NWS (US-only) both wired up now, Environment Canada documented as the
next one to add given its more involved station/grid-lookup API.

The calendar widget's existing embedded weather strip is untouched and
still Open-Meteo-only; this lifts the same underlying icon-drawing
primitives (now shared via app/weather_render.py, calendar_render.py
still imports draw_weather_row unchanged) into a widget that can be
placed and sized on its own. Icons are redrawn in the panel's actual ink
colors (yellow sun/bolt, blue rain/snow) instead of flat black, and
build_multi_city's icon/font sizing now scales with how many cities need
to fit rather than the box's height alone -- both fixed after catching
them via live browser verification, along with a mode-switch cache-shape
crash and a mobile-width dialog overflow.

New WeatherWidgetConfig table (migration 24), grid footprint, widget
module, common.py fetch/cache helper, router endpoints (location set/
clear, city add/remove, preview), dialog template + JS, and full test
coverage (providers, widget render, HTTP endpoints, migration replay).
docs/widgets.md and CLAUDE.md's TODO updated accordingly.
2026-07-27 16:16:43 +00:00
tfaour 6118705c37 Fix corrupted text in CLAUDE.md
Two bullets had a stray "a \"coming up this week\" widget" phrase
overwriting their actual sentence ending (the AGPL bullet's "silently
accepting it)" and the mobile-breakpoint bullet's "below it"), likely
from an earlier bad edit. Restored both from git history; the phrase
still exists correctly once, as its own CURRENT TODO bullet.
2026-07-27 14:46:17 +00:00
tfaour 5247f5e512 Merge remote-tracking branch 'origin/main'
Build and push server image / test (push) Successful in 1m15s
Build and push server image / build-and-push (push) Successful in 5m1s
Build and push server image / deploy (push) Successful in 52s
Resolved CURRENT TODO conflict: kept Thomas's reformatted list and new
items, dropped the two scan-to-download lines this branch just finished.
2026-07-27 14:39:27 +00:00
tfaour 8bcc574f99 Update CLAUDE.md: mark scan-to-download TODOs done, add commit/push convention
Standing convention: commit and push once a task is verified working,
without waiting for a separate go-ahead each time -- see the new bullet
under Conventions specific to this repo.
2026-07-27 14:38:36 +00:00
tfaour c323402895 Fix scan-to-download auth and share every photo widget's current photo
The share QR's URL carried no auth params at all, so it silently fell
back through require_device's legacy-token resolution to whichever
frame happened to still be flagged legacy -- working only by accident
for a single frame, sharing the wrong frame's photos for any other, and
going fully dead once that frame's legacy flag was cleared.

Move the endpoint to manage.py, keyed on the frame's own manage_token
(same pattern /m/<manage_token> already uses) instead of device auth.
Since the server now resolves assets itself instead of trusting a
caller-supplied asset_id, it naturally generalizes to gather every
photo widget's current photo into one Immich share link, not just one
"primary" widget's.
2026-07-27 14:38:26 +00:00
tfaour 6fa3e2c2d2 Update CLAUDE.md
Updated todo
2026-07-26 23:39:33 -04:00
tfaour af513c1b5a Added a todo 2026-07-27 02:33:52 +00:00
tfaour c7b164e6cd Update CLAUDE.md 2026-07-26 18:40:40 -04:00
tfaour 5dfa6c8197 Update CLAUDE.md 2026-07-26 09:15:30 -04:00
tfaour e362519261 Update CLAUDE.md
Just adding some todos
2026-07-25 15:08:41 -04:00
Thomas Faour c1c657c0a9 Saved layouts feature
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
2026-07-25 18:18:48 +00:00
Thomas Faour edbd90745b Add font family choice to the text widget, move toolbar below the editor
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 51s
Six more vendored families alongside the existing Noto Sans (Inter,
Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/
serif/slab/mono variety), all OFL-licensed with their own per-family
license file in app/fonts/ since each has a different copyright holder.
Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only
families (Inter and Source Sans's current Google Fonts releases, plus
Playfair Display/Lora/Merriweather) were skipped in favor of static
builds from their own upstream repos, keeping every family's loading
code uniform with what was already there. Considered but deliberately
left out: Georgia -- a proprietary Microsoft core font, not freely
redistributable, unlike everything else vendored here.

Also moves the bold/italic/underline/color toolbar below the
contenteditable box per request, and reorders the dialog's Settings
card to a more natural family-then-size order.

Fixes a latent migration bug this surfaced: migration 20 (static image
widget) used Base.metadata.create_all, which creates every table
declared in Base.metadata that's missing, not just its own new one --
harmless when nothing else pending, but once TextWidgetConfig existed
it would silently pre-create text_widget_configs (in whatever shape
models.py currently declares) before migration 21 got a turn, so
migration 21's own CREATE TABLE (or a later ALTER TABLE adding
font_family) would collide with a table create_all had already leaked
into existence. Both migrations 20 and 21 now use raw, frozen CREATE
TABLE SQL instead, matching migration 17's existing precedent for
exactly this reason.
2026-07-25 16:05:22 +00:00
Thomas Faour 3735c5bfa7 Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s
A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
2026-07-25 14:21:52 +00:00
Thomas Faour f1fda9bdee Move make-widget/run-server skills to root .claude/skills/
Nested .claude/skills/ dirs (previously under server/) are only
auto-discovered on-demand once a file under that subdirectory is
touched, so /make-widget and /run-server weren't invocable from a
fresh session. Root .claude/skills/ is scanned at session start.

Fixes setup.sh/start-server.sh's relative cd-depth math (was hardcoded
for the old server/.claude/skills/run-server/ depth) to instead
resolve the repo root via git and cd into server/ explicitly, and
updates SKILL.md/driver.py's path references to match the new layout.
2026-07-25 12:09:39 +00:00
127 changed files with 8100 additions and 852 deletions
@@ -14,7 +14,12 @@ This container ships with **no Python, Node, Docker, or browser, and
no sudo**. `setup.sh` bootstraps everything non-root; it's the bulk of
what makes this skill non-obvious. Run it once per fresh container.
All paths below are relative to `server/`.
Commands below (`setup.sh`, `start-server.sh`, `stop-server.sh`,
`env.sh`, `driver.py`) are invoked from the **repo root** via their
`.claude/skills/run-server/` path -- the scripts `cd` into `server/`
themselves. Anything under `.venv/` (the venv itself, `pytest`,
`uvicorn`) lives inside `server/`, so those commands need `server/`
prefixed or `cd server` first.
## Prerequisites
@@ -65,7 +70,7 @@ the pane -- source `env.sh` before the first `tmux` call:
source .claude/skills/run-server/env.sh
tmux new-session -d -s runserver -x 200 -y 50
tmux send-keys -t runserver \
'source .claude/skills/run-server/env.sh && .venv/bin/python .claude/skills/run-server/driver.py' Enter
'source .claude/skills/run-server/env.sh && server/.venv/bin/python .claude/skills/run-server/driver.py' Enter
timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done'
# Every fresh scratch DB starts with no users -- bootstrap-admin
@@ -143,7 +148,7 @@ isn't needed for most UI testing.
## Test
```bash
.venv/bin/pytest
cd server && .venv/bin/pytest
```
Uses its own tempfile SQLite per run (`tests/conftest.py`) -- no setup
@@ -10,8 +10,8 @@ eval/console-errors).
Requires setup.sh to have run first (Python venv + Playwright Chromium
+ the non-root shared-lib/font extraction). Run via:
server/.claude/skills/run-server/env.sh sourced, then
.venv/bin/python server/.claude/skills/run-server/driver.py
.claude/skills/run-server/env.sh sourced, then
server/.venv/bin/python .claude/skills/run-server/driver.py
See SKILL.md for the full agent-path invocation (tmux wrapping etc).
"""
@@ -4,7 +4,7 @@
# container that ships with NO Python/Node/Docker/browser and NO sudo.
# Re-run any time; every step checks whether it already happened.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"/../../.. # -> server/
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
UV_BIN="$HOME/.local/bin/uv"
DEPS_ROOT="/tmp/run-server-chromium-deps"
@@ -3,7 +3,7 @@
# real deployment's data (see CLAUDE.md). Waits for readiness, prints
# the PID and log path. Usage: ./start-server.sh [scratch-dir] [port]
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"/../../.. # -> server/
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
SCRATCH="${1:-/tmp/run-server-scratch}"
PORT="${2:-8420}"
+21 -2
View File
@@ -4,7 +4,16 @@ A DIY e-ink photo frame: an ESP32-C6 (`firmware/`, ESP-IDF) driving a
Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
image processing (crop/dither/quantize/pack), and serves a placeable
photos/calendar/whiteboard widget system to the device.
photos/calendar/whiteboard/weather widget system to the device.
CURRENT TODO
-add more actions for buttons (i.e. change widget/layout)
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
-battery life widget
-sharing layouts with linked users
-a "coming up this week" widget
-scan to download for non-immich photos too?
-switch button reset action? and on reset dismiss the menu.
Start here, don't re-derive from scratch:
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
@@ -39,6 +48,16 @@ Start here, don't re-derive from scratch:
tempted to exclude and why. This repo shipped a token gate once that
covered `/api/*` but left `/frame/image` -- the actual photo bytes --
open; caught immediately in production.
- **Commit and push once a task is verified working, without waiting to
be asked separately.** Once tests pass (and, for UI changes, the
browser check has been done), stage the relevant files, write a normal
commit message, and push to the current branch -- the maintainer's
standing authorization for the commit/push step itself. This doesn't
relax anything else: still run `git status`/review the diff before
staging, still never force-push/amend a pushed commit/skip hooks, and
still surface anything that looks like it needs a real decision (e.g.
a change that would trigger `main`'s deploy workflow, see below)
instead of pushing through it silently.
## Working in this repo
@@ -54,7 +73,7 @@ Start here, don't re-derive from scratch:
live (mobile viewport CSS collapse, a dialog's status message landing
behind its own backdrop, a JSON/form-urlencoded body mismatch). Spin up
`uvicorn app.main:app` against a scratch `DATABASE_URL`/`CONFIG_PATH`
sqlite file, don't touch the real deployment's data. `server/.claude/skills/run-server/`
sqlite file, don't touch the real deployment's data. `.claude/skills/run-server/`
(`/run-server`) has a driver for exactly this.
- **New/changed UI must work at both desktop and mobile widths --
screenshot both, don't assume one implies the other.** The layout
+228 -32
View File
@@ -1,10 +1,10 @@
# Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image), like arranging
icons on an Android home screen. A frame can hold several widgets of the
same type (e.g. two photo widgets pointed at different Immich albums side
by side).
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
weather/battery), like arranging icons on an Android home screen. A frame
can hold several widgets of the same type (e.g. two photo widgets pointed
at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
@@ -20,23 +20,61 @@ a button press does.
## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"`), `x`/`y`/`w`/`h`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` |
`"text"` | `"weather"` | `"battery"`), `x`/`y`/`w`/`h`
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas.
Also carries an optional per-widget border (`border_style` -- `"none"`
| `"solid"` | `"dashed"` | `"dotted"` | `"fancy"`, `border_thickness`,
`border_color_index`, an index into the frame's palette so a border
always renders as one of the panel's exact 6 ink colors) directly on
`Widget` itself rather than a per-type config table, since every
widget type can have one regardless of `widget_type`. Drawn by
`image_pipeline.draw_widget_border` onto each widget's own region in
`routers/device.py`'s `_render_widgets`, before that region is pasted
onto the shared canvas -- one central integration point instead of
every `app/widgets/*.py` module needing to know about it. Set via the
gear-icon dialog's shared "Border" card (`_widget_border_fields.html`,
included by every `_widget_dialog_*.html` template) and
`POST .../widgets/{id}/border`, its own endpoint (not folded into
`api_widget_config_save`) since that endpoint's per-type dispatch is
keyed on a config row via `widget_locked`, and border fields live on
`Widget` itself, not any per-type config table.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
(plus `StaticWidgetConfig`) each keyed by `widget_id` with
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
`BatteryWidgetConfig`, each keyed by `widget_id` with
`ondelete="CASCADE"` -- rather than one wide table with every type's
mostly-irrelevant columns. `PhotoWidgetConfig`
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
text (paragraphs of styled runs), never raw HTML -- see
`server/app/text_content.py`'s module docstring for why that parse
step is the widget's actual stored-XSS sanitization boundary.
`PhotoWidgetConfig`
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
advance/back/queue logic ports across widget instances unchanged.
`PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id`
against both the timer-elapsed auto-advance
(`photo_queue.get_current`) and the advance/back button actions
(`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a
"Lock this photo" button in the widget's own dialog
(`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's
box on the Layout tab canvas.
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
into its own widget type (migration 17) so a task list can be placed
and sized independent of any calendar's view/footprint, then (migration
18) given the same multi-source shape a calendar widget already has.
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
weather strip (still present and unchanged, `weather_*` columns) out
into its own placeable widget type (migration 24) -- see "Weather
widget" below. `BatteryWidgetConfig` (migration 25) is the odd one out
-- its actual content (`Frame.battery_percent`/`battery_as_of`) isn't
in this table at all, already existing frame-level state set by
`routers/device.py`'s `frame_battery` regardless of whether a battery
widget is even placed; the config row only holds a display-mode
setting (`"compact"` | `"detailed"`).
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical
@@ -61,8 +99,13 @@ orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1.
Enforced both client-side
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
weather 2x2 (its hourly/daily strips need the room; current/multi_city
modes would tolerate smaller, but every mode shares one footprint value),
battery 1x1 (just an icon + a percent, legible even at a single cell,
like photos/static -- though see `MIN_FOOTPRINT`'s own comment in
`grid.py` on a mobile-width gear-icon click-target gap at that size,
already pre-existing for photos/static too). Enforced both client-side
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
and server-side (`routers/api_widgets.py`) -- the client is never trusted
alone.
@@ -71,7 +114,7 @@ alone.
`app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
`static_image.py`), each exposing:
`static_image.py`, `text.py`, `weather.py`, `battery.py`), each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's
@@ -81,8 +124,11 @@ alone.
bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard). Empty for tasks and static image -- nothing to
advance/back/force for a passive checklist or a fixed uploaded image.
for whiteboard and weather -- both throttled external fetches with a
forced-refetch action). Empty for tasks, static image, text, and
battery -- nothing to advance/back/force for a passive checklist, a
fixed uploaded image, a fixed block of authored text, or a number the
device itself pushes on every wake.
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI.
@@ -104,28 +150,49 @@ for month view to stay legible.
## Button actions
Each physical button (NEXT/BACK) maps to an **ordered list** of
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be
"photo widget A: advance" *and* "calendar widget B: advance" together, or
even a mismatched combination on purpose. On a press,
`routers/device.py`'s `_run_button_actions` runs every assigned action for
that button in order (each in its own `widget_locked` span -- never nested,
since the underlying per-frame lock isn't reentrant), catching and
logging any single action's failure without blocking the rest, then
re-renders and returns the whole composed panel once at the end regardless
of which actions succeeded.
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
every widget on the frame that has one -- **at most one binding per
widget per button** (a widget can't be bound to two different actions on
the same button). On a press, `routers/device.py`'s `_run_button_actions`
runs every widget's assigned action for that button (each in its own
`widget_locked` span -- never nested, since the underlying per-frame lock
isn't reentrant), catching and logging any single action's failure
without blocking the rest, then re-renders and returns the whole composed
panel once at the end regardless of which actions succeeded. Which
widget's action runs first never matters -- each only touches its own
state, and the shared re-render happens once, after all of them finish.
The web UI for this is the "Button assignments" card on a frame's
Configuration tab (`static/frame_config.js`, `GET`/`PUT
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of
the same type would otherwise both just say "Photos" in the assignment
dropdowns; the UI disambiguates using each widget's grid position (e.g.
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them
apart by eye on the Layout canvas.
The UI for this lives in each widget's own gear-icon config dialog (the
"Button actions" card, `templates/_widget_button_fields.html` +
`static/widget_dialog_button_actions.js`, `POST
/api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
tab, since assigning a widget's next/back behavior is naturally part of
configuring that widget. The card only renders for widget types with a
non-empty `ACTIONS` (photos, calendar, whiteboard, weather); tasks/
static/text/battery have nothing to bind so the card is omitted for
them. An empty selection ("(none)") clears that button's binding for the
widget.
A newly-created widget (including the one auto-migrated from a frame's old
`mode` on upgrade) gets a sensible default binding reproducing its old
button behavior -- see `migration.py`'s `_default_button_actions`.
A newly-created widget (including the one auto-migrated from a frame's
old `mode` on upgrade) gets a sensible default binding reproducing its
old button behavior -- see `widgets.default_button_actions` (called from
both `migration.py`'s backfill and `api_widgets.py`'s
`api_widget_create`), so a widget is never left with nothing bound until
someone deliberately reassigns it.
### Hold-for-global-action
Holding NEXT or BACK past a configurable duration (`Frame.hold_duration_ms`,
minimum 3000ms, set on the Configuration tab) triggers a **global**
action instead of the per-widget one -- not scoped to any widget, e.g.
cycling through the user's saved layouts. See `app/global_actions.py`'s
`GLOBAL_ACTIONS`/`GLOBAL_ACTION_LABELS` registry and
`routers/device.py`'s `/frame/global-next`/`/frame/global-back` (the
device calls these instead of `/frame/advance`/`/frame/back` once it
detects a long press -- see `firmware/main/next_button.c`/`back_button.c`).
`Frame.next_hold_action`/`back_hold_action` pick which registry entry (if
any) each button's hold triggers; unset is a silent no-op, same
convention as an unbound short-press button.
## Per-widget config UI
@@ -144,6 +211,135 @@ HTML can't carry executable `<script>` tags. While a dialog is open,
`window.FRAME_BASE_API` stays pointed at the frame-level base throughout
for the always-present header/status-bar JS.
## Saved layouts
A user can snapshot a frame's whole widget arrangement -- every widget's
type/placement/settings, calendar/task sources, and button-action
bindings -- under a name (`SavedLayout` + `SavedLayoutWidget` +
`SavedLayoutSource` + `SavedLayoutButtonAction`, `server/app/models.py`),
then switch back to it later, or apply it to a *different* frame. Saved
layouts are owned by the **user**, not any one frame -- the same set
shows up (with a per-frame `compatible` flag) on every frame that user
controls whose grid matches (`grid.grid_dims(orientation)`'s cols/rows,
landscape-class 8x5 vs. portrait-class 5x8), not just the frame it was
captured from.
Saving only captures an authored *setting*, never runtime/cache state --
a photo widget's current queue position, a calendar's fetch cache, a
whiteboard's rendered-image cache, etc. are deliberately left out (see
`routers/api_layouts.py`'s `LAYOUT_CONFIG_FIELDS` allowlist per
`widget_type`), so applying a layout feels like a fresh widget of that
type with its settings pre-filled, not a resurrection of stale state
from whenever it was saved. A static-image widget's uploaded bytes are
the one exception carried through verbatim (`SavedLayoutWidget.image`).
Saving again under a name the user already has overwrites that layout's
snapshot in place rather than erroring or creating a duplicate --
`SavedLayout`'s own docstring.
Applying a layout to a frame (`api_layout_apply`, `require_frame_control`)
deletes every widget currently on that frame and recreates the saved
arrangement from scratch, remapping calendar/task sources and button
bindings onto the newly-created widget ids -- same "act unconditionally
on the server, confirm on the client" posture as the Layout tab's own
"Clear all". A source whose owning user account no longer exists is
silently dropped rather than left dangling (config is JSON, not
FK-checked, so nothing else would catch that).
The web UI lives in the Layout tab's "Saved layouts" card
(`static/saved_layouts.js`, `GET`/`POST /api/frames/{id}/layouts`,
`PATCH`/`DELETE /api/layouts/{id}`, `POST
/api/frames/{id}/layouts/{id}/apply`) -- name + Save, then a list of
saved layouts each with Apply/rename/delete, incompatible ones shown
greyed-out with a "different orientation" badge rather than hidden.
## Weather widget
A standalone widget type (`models.WeatherWidgetConfig`, `app/widgets/
weather.py`) -- distinct from, and unrelated in code to,
`CalendarWidgetConfig`'s own embedded weather strip (still present,
still Open-Meteo-only, still working exactly as before). Four display
modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
`calendar_view`):
- `current` -- one city's current temp + a condition icon.
- `hourly` -- one city, a row of ticks across the day at a configurable
interval (`hourly_interval_hours`: 3/4/6/12).
- `daily` -- one city, a multi-day strip (`daily_days`, 1-14).
- `multi_city` -- several cities' current-day high/low/icon side by
side -- the calendar widget's embedded strip, as a standalone
widget's whole content instead of a strip above an agenda day.
`current`/`hourly`/`daily` share one configured location
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
has its own list (`cities`, add/remove via `POST .../weather-widget-
cities/add`|`remove` -- named to avoid colliding with the calendar
widget's own, differently-scoped `weather-cities/add`|`remove` routes,
which share the same `{widget_id}`-parameterized path shape).
**Providers** (`app/weather/`, a dispatch registry over pluggable
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
provider` selects which of `app/weather.PROVIDERS` actually fetches --
`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
only, no API key, approximates "current" with the first hourly forecast
period rather than a real station observation), or `"ec"` (Environment
Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
Every provider function returns already-normalized `{"category": ...}`
entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
know which provider supplied an entry. `geocode_city` (name -> lat/lon)
always goes through Open-Meteo's free geocoder regardless of which
provider is chosen to fetch with the result.
EC's `citypageweather-realtime` collection is only queryable by bounding
box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
`_nearest_site` widens the box progressively and picks the closest site
by straight-line distance, rejecting anything beyond 300 km (calibrated
against a real bug caught in development: an unconditional "nearest
site, however far" matched a Miami, FL query to a site in Ontario,
1824 km away, once the box widened enough to cover the whole country).
`app/weather_render.py` holds every weather-related drawing primitive:
`draw_weather_icon`/`draw_weather_row` (extracted out of
`calendar_render.py`, which still imports `draw_weather_row` for its own
embedded strip, unchanged) plus this widget's own `build_current`/
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
`render_tasks_preview_png` relationship. Icons are hand-drawn (no custom
font/icon asset), styled after Environment Canada's own icon set
(pointed sun rays, a puffy cloud, teardrop rain, dendrite snowflakes, a
zigzag bolt) but filled with the panel's *exact* ink RGB values rather
than an arbitrary bitmap's anti-aliased colors -- a flat fill that's
already a palette color quantizes with zero dithering error to diffuse,
where a fetched/vendored icon's colors (almost never an exact match)
dither into a visible speckle at these small on-panel sizes (confirmed
by actually running one through the real quantize pass during
development). Used for every provider's rendering, not just when EC is
selected as the provider.
## Battery widget
The simplest widget type (`models.BatteryWidgetConfig`, `app/widgets/
battery.py`): shows this frame's own last-reported battery level. Unlike
every other widget type, there's no live upstream to poll and nothing to
cache -- the content is `Frame.battery_percent`/`battery_as_of`, set by
`routers/device.py`'s `frame_battery` on every device wake-on-battery
report, which already existed for the Device panel's own history chart
regardless of whether a battery widget is placed anywhere. The widget's
own config is just a display mode: `"compact"` (icon + percent) or
`"detailed"` (default, adds `routers/common.py`'s existing
`battery_estimate_s` time-remaining estimate and the last report's age).
`render()` falls back to a "No reports yet" placeholder for a frame that
has never reported (never run on battery, or not yet claimed by a
device) rather than showing a stale or fabricated number. The battery
icon fill color (red/yellow/green by percent) uses the same exact-panel-
ink-RGB approach as the weather icons above and `manage_overlay.py`'s own
battery glyph on the "scan to manage" overlay -- a separate, unrelated
piece of code with its own fixed small size, not shared with this
widget, but drawing from the same thresholds/colors so a battery glyph
reads the same wherever one shows up on a panel.
## Known gaps (Phase 6, not yet done)
The original 8-phase rollout plan's last phase is still open:
+33 -1
View File
@@ -68,6 +68,7 @@ Under **ESPresso Frame Configuration**:
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
| `FRAME_HOLD_ACTION_MS` | 3000 | **Fallback only** -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below |
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
@@ -84,6 +85,34 @@ reflashing. The Kconfig value only applies before the device has ever
successfully reached a configured server, or if the response doesn't
include a valid interval.
### Holding NEXT/BACK for a global action
Past `FRAME_HOLD_ACTION_MS`, holding NEXT or BACK stops meaning "advance/
back this widget" and instead triggers whatever frame-wide action (if
any) is configured for that button's hold on the server's Configuration
tab -- e.g. cycling through saved layouts (see
`server/app/global_actions.py`). Fires immediately at the threshold,
without waiting for release -- same convention as the combo button's
factory-reset tier below.
Same "fallback only" caveat as `FRAME_SLEEP_INTERVAL_S` above, but with
one more wrinkle: the server's actual `hold_duration_ms` (set on the
Configuration tab, `GET /frame/config`'s response) can't be used for
*this* wake's button decision -- that decision happens in `main.c`
before WiFi even connects, but `/frame/config` isn't fetched until near
the end of the wake cycle (after the image fetch, deliberately -- see
`frame_client_run`'s own comment on why). So the device always acts on
whatever value the *previous* wake fetched (persisted in NVS via
`frame_config_set_hold_duration_ms`), falling back to
`FRAME_HOLD_ACTION_MS` only before it's ever successfully fetched one.
In practice this means changing the duration on the Configuration tab
takes effect starting with the wake *after* the next one, not
immediately.
Holding a button through the poll loop keeps the device awake and
connected longer than a normal short-press wake -- the same tradeoff
already accepted for the combo button's menu/reset holds below.
### WiFi fast-connect
After a successful home-WiFi connection, the device caches the AP's
@@ -204,6 +233,8 @@ device (if asleep) and tells the server to advance to the next photo
right away, regardless of the configured refresh interval -- no long hold
needed, since advancing is easily reversible by pressing again. See
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature.
Holding it past `FRAME_HOLD_ACTION_MS` instead means something else
entirely -- see "Holding NEXT/BACK for a global action" above.
Normal wakes and reboots never advance the photo on their own -- the
server decides when to advance based on its own clock (see
@@ -223,7 +254,8 @@ disable the feature.
If there's nothing to go back to yet (freshly provisioned, or you've
already gone back as far as there is history), it's a no-op -- the
current photo stays exactly as it was, no flash on the panel.
current photo stays exactly as it was, no flash on the panel. Same
long-hold caveat as the next-photo button above.
## Battery (XIAO ESP32-C6)
+17
View File
@@ -188,6 +188,23 @@ menu "ESPresso Frame Configuration"
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
confused for each other.
config FRAME_HOLD_ACTION_MS
int "Next/back hold-for-global-action duration (ms)"
default 3000
range 3000 10000
help
How long the NEXT or BACK button must be held before it
triggers a frame-wide action (see server/app/global_actions.py
-- e.g. cycling saved layouts) instead of that button's normal
short-press behavior. Only a first-boot/never-connected
fallback: once the device has fetched GET /frame/config at
least once, the server's own Frame.hold_duration_ms (set on
the Configuration tab) overrides this on every later boot --
see wifi_provisioning.h's frame_config_get_hold_duration_ms.
Floor matches the server's own minimum, so a long-held button
never means something different depending on which value
happened to apply.
config FRAME_BATTERY_ADC_GPIO
int "Battery voltage-divider ADC GPIO (-1 to disable)"
default -1
+40 -18
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning.h"
#include "back_button.h"
static const char *TAG = "back_button";
@@ -14,6 +18,7 @@ static const char *TAG = "back_button";
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
#define BACK_BUTTON_DEBOUNCE_MS 20
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
#define BACK_BUTTON_POLL_MS 100
void back_button_init(void)
{
@@ -36,7 +41,7 @@ void back_button_init(void)
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool back_button_check(void)
back_button_result_t back_button_check(void)
{
/* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -44,32 +49,49 @@ bool back_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
return true;
}
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
* power-on/reflash) -- fall back to a live, debounced level check so
* holding the button down while powering on also works. */
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* fresh power-on/reflash) -- fall back to a live, debounced level
* check so holding the button down while powering on also
* works. */
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
return BACK_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return BACK_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
}
}
ESP_LOGI(TAG, "Back-photo button held during power-on, going back");
return true;
/* Confirmed pressed -- measure how long, same reasoning/pattern as
* next_button_check(). */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(BACK_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Back button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return BACK_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_POLL_MS));
elapsed_ms += BACK_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Back-photo button short press (%ums), going back", (unsigned)elapsed_ms);
return BACK_BUTTON_SHORT_PRESS;
}
#else
void back_button_init(void) {}
bool back_button_check(void) { return false; }
back_button_result_t back_button_check(void) { return BACK_BUTTON_NOT_PRESSED; }
#endif
+18 -4
View File
@@ -12,9 +12,23 @@
*/
void back_button_init(void);
typedef enum {
BACK_BUTTON_NOT_PRESSED,
/** A short press -- same immediate-response reasoning as the
* next-photo button. */
BACK_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_BACK).
* Fires immediately at the threshold, without waiting for release. */
BACK_BUTTON_HOLD,
} back_button_result_t;
/**
* Returns whether the back-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. Same immediate-response
* reasoning as the next-photo button -- no long hold-to-confirm gate.
* Checks the back-photo button and, if it's pressed at all, blocks
* polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
* or the hold duration elapses (BACK_BUTTON_HOLD) -- same pattern as
* next_button_check(). Evaluated once per wake.
*/
bool back_button_check(void);
back_button_result_t back_button_check(void);
+29 -6
View File
@@ -276,6 +276,14 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
typedef struct {
bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
/* How long NEXT/BACK must be held to trigger a global action instead
* of a short press (see next_button.h/back_button.h) --
* CONFIG_FRAME_HOLD_ACTION_MS if absent/unparseable (older server) or
* unreachable. Persisted via frame_config_set_hold_duration_ms() for
* the *next* boot's button-hold decision -- this fetch happens too
* late in the cycle for its own boot's decision, see that function's
* own doc comment. */
uint32_t hold_duration_ms;
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
@@ -370,6 +378,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
frame_server_config_t result = {
.reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
};
result.firmware_version[0] = '\0';
result.device_token[0] = '\0';
@@ -419,6 +428,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
(int)result.refresh_interval_s);
}
uint32_t hold_ms;
if (json_extract_uint(body, "hold_duration_ms", &hold_ms)) {
result.hold_duration_ms = hold_ms;
}
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));
@@ -443,12 +456,13 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
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). 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
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
* /frame/global-next, or /frame/global-back to force a move/action
* (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
* FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
* 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
@@ -460,6 +474,10 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
path = "frame/advance";
} else if (action == FETCH_BACK) {
path = "frame/back";
} else if (action == FETCH_GLOBAL_NEXT) {
path = "frame/global-next";
} else if (action == FETCH_GLOBAL_BACK) {
path = "frame/global-back";
}
char url[256];
@@ -720,6 +738,11 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
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;
if (server_cfg.reachable) {
/* For next boot's button-hold decision, not this one -- see
* frame_config_get_hold_duration_ms()'s own doc comment. */
frame_config_set_hold_duration_ms(server_cfg.hold_duration_ms);
}
/* One-time identity handshake: the server pushes this frame's
* own token until we've authenticated with it once. Persist it
+8 -3
View File
@@ -9,14 +9,19 @@
* Which photo-fetch behavior this wake cycle should use -- normally the
* idempotent GET /frame/image (the server decides on its own whether to
* advance, based on its configured refresh interval, so a plain
* wake/reboot never skips a photo just by asking), or POST
* /frame/advance / POST /frame/back to force a move in either direction
* (the next-photo / back-photo buttons).
* wake/reboot never skips a photo just by asking), POST /frame/advance /
* POST /frame/back to force a move in either direction (a short press of
* the next-photo / back-photo buttons), or POST /frame/global-next /
* POST /frame/global-back to run whatever frame-wide action (if any) is
* configured for a held press (see next_button.h/back_button.h's
* *_HOLD result and app/global_actions.py server-side).
*/
typedef enum {
FETCH_NORMAL,
FETCH_ADVANCE,
FETCH_BACK,
FETCH_GLOBAL_NEXT,
FETCH_GLOBAL_BACK,
} fetch_action_t;
/**
+13 -4
View File
@@ -40,12 +40,21 @@ void app_main(void)
back_button_init();
combo_button_init();
bool next_pressed = next_button_check();
bool back_pressed = back_button_check();
next_button_result_t next_result = next_button_check();
back_button_result_t back_result = back_button_check();
/* Next takes priority over back if somehow both read pressed at once
* (e.g. both held through a power-on) -- an arbitrary but
* deterministic tie-break, not expected to matter in practice. */
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
* deterministic tie-break, not expected to matter in practice. Same
* priority applies whether the winning button resolved to a short
* press or a hold. */
fetch_action_t action;
if (next_result != NEXT_BUTTON_NOT_PRESSED) {
action = (next_result == NEXT_BUTTON_HOLD) ? FETCH_GLOBAL_NEXT : FETCH_ADVANCE;
} else if (back_result != BACK_BUTTON_NOT_PRESSED) {
action = (back_result == BACK_BUTTON_HOLD) ? FETCH_GLOBAL_BACK : FETCH_BACK;
} else {
action = FETCH_NORMAL;
}
/* Soft-resets or clears config + restarts internally for a medium/
* long hold and never returns in those cases -- only returns here
* for "not pressed" (false) or "quick press" (true, show the menu). */
+45 -18
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning.h"
#include "next_button.h"
static const char *TAG = "next_button";
@@ -14,6 +18,7 @@ static const char *TAG = "next_button";
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
#define NEXT_BUTTON_DEBOUNCE_MS 20
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
#define NEXT_BUTTON_POLL_MS 100
void next_button_init(void)
{
@@ -40,7 +45,7 @@ void next_button_init(void)
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool next_button_check(void)
next_button_result_t next_button_check(void)
{
/* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -48,32 +53,54 @@ bool next_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
return true;
}
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
* power-on/reflash) -- fall back to a live, debounced level check so
* holding the button down while powering on also works. */
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false;
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* fresh power-on/reflash) -- fall back to a live, debounced level
* check so holding the button down while powering on also
* works. */
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
return NEXT_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return NEXT_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
}
}
ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance");
return true;
/* Confirmed pressed (either the wake cause, or debounced during
* power-on) -- measure how long, same polling pattern as
* combo_button.c's own hold-tier detection. Reads the last hold
* duration the server reported (persisted from a previous cycle,
* see frame_config_get_hold_duration_ms's own doc comment), falling
* back to the Kconfig default before the device has ever fetched
* one. */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(NEXT_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Next button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return NEXT_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_POLL_MS));
elapsed_ms += NEXT_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Next-photo button short press (%ums), forcing advance", (unsigned)elapsed_ms);
return NEXT_BUTTON_SHORT_PRESS;
}
#else
void next_button_init(void) {}
bool next_button_check(void) { return false; }
next_button_result_t next_button_check(void) { return NEXT_BUTTON_NOT_PRESSED; }
#endif
+22 -5
View File
@@ -12,10 +12,27 @@
*/
void next_button_init(void);
typedef enum {
NEXT_BUTTON_NOT_PRESSED,
/** A short press -- advancing a photo is low-stakes and should feel
* immediate, so this fires the moment the button releases (or right
* away for a wake-triggered press, once it's confirmed not a hold). */
NEXT_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_NEXT).
* Fires immediately at the threshold, without waiting for release --
* same convention as combo_button.c's factory-reset tier. */
NEXT_BUTTON_HOLD,
} next_button_result_t;
/**
* Returns whether the next-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. No long hold-to-confirm
* gate -- advancing a photo is low-stakes and should feel immediate, so
* this returns right away either way.
* Checks the next-photo button and, if it's pressed at all (either what
* caused this wake, per the latched wakeup-status register, or held
* through a debounced power-on check), blocks polling its level until
* either it's released (NEXT_BUTTON_SHORT_PRESS) or the hold duration
* elapses (NEXT_BUTTON_HOLD, returned immediately, not waiting for
* release). Evaluated once per wake.
*/
bool next_button_check(void);
next_button_result_t next_button_check(void);
+23
View File
@@ -234,6 +234,29 @@ void frame_config_invalidate_last_display_crc32(void)
nvs_close(handle);
}
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out)
{
nvs_handle_t handle;
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle);
if (err != ESP_OK) {
return err;
}
err = nvs_get_u32(handle, "hold_ms", out);
nvs_close(handle);
return err;
}
void frame_config_set_hold_duration_ms(uint32_t ms)
{
nvs_handle_t handle;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
return;
}
nvs_set_u32(handle, "hold_ms", ms);
nvs_commit(handle);
nvs_close(handle);
}
/* ------------------------------------------------------------------------
* WiFi fast-connect cache
* ---------------------------------------------------------------------- */
+21
View File
@@ -94,6 +94,27 @@ void frame_config_set_last_display_crc32(uint32_t crc32);
*/
void frame_config_invalidate_last_display_crc32(void);
/**
* Returns the hold_duration_ms the server most recently reported via GET
* /frame/config (see frame_client.c's fetch_frame_config/frame_client_run)
* -- how long NEXT/BACK must be held before next_button_check()/
* back_button_check() treat it as a hold-for-global-action instead of a
* short press. Returns ESP_ERR_NVS_NOT_FOUND if the device has never
* fetched one yet (fresh install/factory reset); caller should fall back
* to CONFIG_FRAME_HOLD_ACTION_MS in that case.
*
* Deliberately a *previous* cycle's value: this cycle's own button
* decision happens in main.c before WiFi even connects, but
* /frame/config isn't fetched until near the end of frame_client_run
* (after the image fetch, for connection-warmth/timeout reasons -- see
* its own comment) -- so there's no same-cycle fresh value to use yet.
*/
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out);
/** Persists the hold duration reported by the server, for the *next*
* boot's button-hold decision to use. */
void frame_config_set_hold_duration_ms(uint32_t ms);
/**
* Returns this device's provisioning AP identity: a fixed SSID (from
* Kconfig) and a password that's generated once on first use and persisted
+1 -1
View File
@@ -1 +1 @@
1.3.0
1.4.0
+29 -25
View File
@@ -2,34 +2,38 @@ FROM python:3.12-slim
WORKDIR /app
# tzdata/fonts in their own layer, kept separate from the much larger
# Node.js/npm layers below -- see those layers' own comments for why
# they're split up the way they are. tzdata: python:3.12-slim doesn't
# include it by default, so the zoneinfo database backing the web UI's
# "Timezone" setting (used by "Quiet hours") would have no named zones
# to resolve without this -- ZoneInfo() would raise for anything other
# than "UTC". fontconfig/fonts-dejavu-core: whiteboard mode's
# render-service/ (own README there) needs something to render
# whiteboard text with.
RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata fontconfig fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# tzdata/fonts/Node.js all from Debian's own repo in one layer -- no
# external curl/gnupg dance needed (see below for why that changed).
# tzdata: python:3.12-slim doesn't include it by default, so the
# zoneinfo database backing the web UI's "Timezone" setting (used by
# "Quiet hours") would have no named zones to resolve without this --
# ZoneInfo() would raise for anything other than "UTC".
# fontconfig/fonts-dejavu-core: whiteboard mode's render-service/ (own
# README there) needs something to render whiteboard text with.
#
# Node.js: whiteboard frame mode's render-service/ runs as a second
# process in this same container rather than a separate compose service
# -- it's a lightweight, stateless, localhost-only sidecar with nothing
# worth independently scaling or restarting. NodeSource's setup script is
# used instead of Debian bookworm's own apt Node package, which is both
# older than jsdom's minimum (20.19+) and inconsistently available.
# curl/gnupg are only needed to add and fetch NodeSource's repo -- purged
# again in this same RUN (not a later one; Docker layers are immutable,
# so removing them in a *different* instruction wouldn't shrink this
# one's actual pushed size) so their bytes don't end up in the image at
# all, only nodejs's.
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get purge -y --auto-remove curl gnupg \
# worth independently scaling or restarting. Used to be installed via
# NodeSource's setup script (Debian's own nodejs package was too old for
# jsdom's minimum back when this base image tracked Debian bookworm) --
# switched to Debian's own `nodejs`/`npm` packages after NodeSource's
# deb.nodesource.com started intermittently 403ing on both its setup_*.x
# scripts *and* its GPG key (a live NodeSource-side S3/CDN issue,
# confirmed 2026-07-27 by hitting deb.nodesource.com directly -- some
# setup_NN.x paths 403, others 200, no consistent pattern, so no
# NodeSource-hosted install path could be trusted not to silently break
# again). This base image now tracks Debian trixie, whose own `nodejs`
# package is 20.19.2 -- inside jsdom 29's stated engines range
# (`^20.19.0 || ^22.13.0 || >=24.0.0`) and well above express/resvg-js's
# much lower floors -- so there's no longer a version gap to route
# around NodeSource for. One less external dependency, and no more
# curl-piped-into-bash (that pattern is also what let the NodeSource
# failure go undetected here in the first place: `curl -f ... | bash -`
# on a 403 hands bash an empty, "successful" script instead of failing
# the RUN outright).
RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata fontconfig fonts-dejavu-core nodejs npm \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
+11 -9
View File
@@ -121,9 +121,11 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
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/share/{manage_token}` -- creates a 30-minute public Immich
share link covering every photo widget's currently-showing photo on
*this* frame and 302s to it. Authenticated by the frame's own
`manage_token` (see the manage QR below), not device credentials -- a
phone scanning the QR has no way to supply `?id=`/`?token=`.
- `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.
@@ -200,12 +202,12 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
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.
- 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.
per-frame tokens (`?id=` + `?token=`), the manage QR and the
scan-to-download QR both use the frame's own `manage_token` (device
tokens don't work for either -- neither is ever called by firmware,
both are opened by a phone that has no way to supply `?id=`/`?token=`),
and `MANAGEMENT_TOKEN` survives only as the migration credential for
pre-multi-frame firmware.
- 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
+11 -99
View File
@@ -35,6 +35,7 @@ from .image_pipeline import (
logical_render_size,
)
from .weather import weather_category
from .weather_render import draw_weather_row
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
@@ -389,98 +390,6 @@ def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]
return entries
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
with a clean outline -- drawn as one black pass slightly larger than
the shapes, then the same shapes again in white on top. Overlapping
ellipses each drawn with their own `outline=` would leave visible
seams where they cross; this double-draw trick sidesteps that
entirely regardless of how the lobes overlap."""
stroke = 2
lobes = [
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
]
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0, y0, x1, y1], fill=BG)
draw.rectangle(base, fill=BG)
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
"""A small hand-drawn glyph for one weather category -- no custom
font/icon asset, same hand-primitives-only approach the rest of this
module uses (colored rectangles for owner indicators, density dots
for month view)."""
if category == "clear":
# Kept within a ~1.1r visual radius overall (rays included) to
# match _draw_cloud's own footprint -- _draw_weather_row lays
# icons out assuming each one stays roughly within icon_r of its
# center, and the first entry in a row sits flush against the
# region's own left margin, so any icon that draws wider than
# that pokes out past it with nothing to visually connect to.
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
fill=FG, width=3)
return
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
if category == "partly_cloudy":
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
_draw_cloud(draw, cx, cloud_cy, r)
if category == "fog":
for i in range(3):
y = cy + r * 0.5 + i * (r * 0.45)
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
elif category == "rain":
for dx in (-0.6, 0, 0.6):
x = cx + dx * r
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
elif category == "snow":
for dx in (-0.6, 0, 0.6):
x, y = cx + dx * r, cloud_cy + r * 0.9
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
elif category == "thunderstorm":
x, y = cx, cloud_cy + r * 0.5
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
fill=FG, width=2)
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
show_labels: bool = True) -> int:
"""Draws one or more cities' weather side by side starting at
(x0, y0), stopping once another entry wouldn't fit within max_w
(narrow views like week columns just end up showing fewer cities --
same graceful-degradation approach month view takes with density
dots). Returns the row height consumed (0 if there was nothing to
draw, so callers can skip reserving space entirely)."""
if not entries:
return 0
unit_suffix = "F" if units == "fahrenheit" else "C"
row_h = icon_r * 2 + 8
x = x0
drew_any = False
for entry in entries:
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
label = f"{entry['label']} {temps}" if show_labels else temps
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
if drew_any and x + entry_w > x0 + max_w:
break
cx, cy = x + icon_r, y0 + icon_r
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
x += entry_w
drew_any = True
return row_h + 6
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
@@ -504,8 +413,9 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
palette_rgb=palette_rgb)
day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14
@@ -720,13 +630,15 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
y = MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see
# _draw_weather_row) -- typically one city, no label (the column
# itself makes which day it's for obvious; a city name wouldn't fit
# anyway). Never more than that -- this is already the tight view.
# weather_render.draw_weather_row) -- typically one city, no label
# (the column itself makes which day it's for obvious; a city name
# wouldn't fit anyway). Never more than that -- this is already
# the tight view.
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb)
row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2010-2013, Anton Koovit ([email protected]), with Reserved Font Name 'Arvo'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010 The Crimson Text Project Authors (https://github.com/googlefonts/Crimson)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+115
View File
@@ -0,0 +1,115 @@
"""Frame-wide actions triggered by holding NEXT/BACK past
Frame.hold_duration_ms, instead of the per-widget action a short press
runs (see models.FrameButtonAction, app/widgets/*.py's ACTIONS). Not
scoped to any one widget -- e.g. cycling through the owner's saved
layouts -- so this is its own registry rather than living in a widget
module.
Each function's signature is (db, frame) -> None, the frame-level
analogue of a widget ACTIONS entry's (db, frame, widget) -> None, and
each is responsible for its own locking/commit internally (frame_locked/
widget_locked), same convention as app/widgets/*.py. routers/device.py's
/frame/global-next and /frame/global-back look up which (if any) of
these Frame.next_hold_action/back_hold_action points to and call it,
same "unset/unknown -> silent no-op" posture as an unbound short-press
button."""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import grid
from .db import frame_locked
from .models import Frame, PhotoWidgetConfig, SavedLayout, Widget
from .routers.api_layouts import apply_layout_to_frame
from .widgets import WIDGET_TYPES
logger = logging.getLogger(__name__)
def cycle_layout(db: Session, frame: Frame) -> None:
"""Applies the owner's next saved layout compatible with this
frame's current grid size, in a stable order (by id), wrapping back
to the first past the last one. A silent no-op if the frame is
unclaimed or its owner has no compatible saved layouts -- same
posture as every other action here when there's nothing to do."""
if frame.owner_user_id is None:
return
cols, rows = grid.grid_dims(frame.orientation)
candidates = db.scalars(
select(SavedLayout)
.where(SavedLayout.user_id == frame.owner_user_id, SavedLayout.cols == cols, SavedLayout.rows == rows)
.order_by(SavedLayout.id)
).all()
if not candidates:
return
next_layout = candidates[0]
if frame.last_cycled_layout_id is not None:
for i, layout in enumerate(candidates):
if layout.id == frame.last_cycled_layout_id:
next_layout = candidates[(i + 1) % len(candidates)]
break
apply_layout_to_frame(db, frame, next_layout)
with frame_locked(db, frame.id) as locked:
locked.last_cycled_layout_id = next_layout.id
def refresh_all_widgets(db: Session, frame: Frame) -> None:
"""Runs every widget's own check_now (calendar/weather/whiteboard),
regardless of which button it's normally bound to -- a manual "sync
everything now" global action. One widget's failure doesn't block
the rest, same posture as routers/device.py's _run_button_actions."""
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
for widget in widgets:
module = WIDGET_TYPES.get(widget.widget_type)
check_now = module.ACTIONS.get("check_now") if module else None
if check_now is None:
continue
try:
check_now(db, frame, widget)
except Exception:
logger.exception(
"refresh_all_widgets failed for widget %d (frame %d)", widget.id, frame.id
)
def toggle_all_photo_locks(db: Session, frame: Frame) -> None:
"""Flips PhotoWidgetConfig.locked for every photo widget on the frame
at once. Target state is the opposite of "everything's already
locked" -- one hold freezes every photo widget unless they're all
already frozen, in which case it unfreezes all of them. A no-op if
the frame has no photo widgets."""
widget_ids = [
w.id for w in db.scalars(
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
)
]
if not widget_ids:
return
configs = db.scalars(
select(PhotoWidgetConfig).where(PhotoWidgetConfig.widget_id.in_(widget_ids))
).all()
if not configs:
return
target = not all(c.locked for c in configs)
with frame_locked(db, frame.id):
for config in configs:
config.locked = target
GLOBAL_ACTIONS = {
"cycle_layout": cycle_layout,
"refresh_all_widgets": refresh_all_widgets,
"toggle_all_photo_locks": toggle_all_photo_locks,
}
GLOBAL_ACTION_LABELS = {
"cycle_layout": "Cycle saved layouts",
"refresh_all_widgets": "Refresh all widgets now",
"toggle_all_photo_locks": "Freeze/unfreeze all photo widgets",
}
+13 -1
View File
@@ -24,13 +24,25 @@ GRID_SHORT = 5
# scaling (see calendar_render.py); whiteboard needs enough room to be
# worth looking at; photos can go as small as a single cell; tasks needs
# enough width for a due-date prefix plus a couple words of summary
# without truncating on every row.
# without truncating on every row; weather needs enough room for its
# hourly/daily strips to stay legible (its current/multi_city modes
# would tolerate smaller, but every mode shares one footprint value).
# battery is just an icon + a percent (+ two optional small lines in
# "detailed" mode) -- legible even at a single cell, like photos/static.
# NOTE: a 1x1 widget-box on a narrow mobile canvas can clip its own
# gear/remove buttons behind theme.css's overflow: hidden (their fixed
# pixel offsets overflow the box's clipped width) -- a pre-existing
# layout gap that already affects photos/static at 1x1 too, not fixed
# here; see the finding called out where this was discovered.
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1),
"calendar": (3, 2),
"whiteboard": (2, 2),
"tasks": (2, 2),
"static": (1, 1),
"text": (2, 1),
"weather": (2, 2),
"battery": (1, 1),
}
Rect = tuple[int, int, int, int] # (x, y, w, h)
+108
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import io
import math
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
@@ -32,6 +33,83 @@ def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
def _dashed_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
width: int, color: tuple[int, int, int], dash: float, gap: float) -> None:
length = math.hypot(x1 - x0, y1 - y0)
if length <= 0:
return
ux, uy = (x1 - x0) / length, (y1 - y0) / length
pos = 0.0
while pos < length:
end = min(pos + dash, length)
draw.line([(x0 + ux * pos, y0 + uy * pos), (x0 + ux * end, y0 + uy * end)], fill=color, width=width)
pos += dash + gap
def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
width: int, color: tuple[int, int, int], spacing: float) -> None:
length = math.hypot(x1 - x0, y1 - y0)
if length <= 0:
return
ux, uy = (x1 - x0) / length, (y1 - y0) / length
r = max(1, width / 2)
pos = 0.0
while pos <= length:
cx, cy = x0 + ux * pos, y0 + uy * pos
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
pos += spacing
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int]) -> None:
"""Draws a border inset within img's own bounds, mutating it in
place -- called once per widget's own region (routers/device.py's
_render_widgets, and each widget type's own dialog preview) before
that region's image is pasted onto the shared canvas, so a border
never straddles the boundary between two adjacent widgets. `color`
should already be an exact palette RGB (see resolve_border_color) so
the stroke quantizes with zero dithering error, same reasoning as
the weather/battery icons' exact-panel-ink-RGB fills.
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
just inside the image's edge; "fancy" is two thinner concentric
strokes with a gap between them, picture-frame-mat style. "none" (or
a non-positive thickness) draws nothing."""
if style == "none" or thickness <= 0:
return
w, h = img.size
t = max(1, min(int(thickness), min(w, h) // 2))
draw = ImageDraw.Draw(img)
if style == "fancy":
line_t = max(1, t // 3)
gap = max(2, t - 2 * line_t)
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
inset = line_t + gap
if w - 2 * inset > 1 and h - 2 * inset > 1:
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
return
if style == "solid":
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
return
# dashed/dotted trace the same centered-on-the-edge path solid/
# fancy's rectangle outline draws, so all four styles sit at the
# same inset regardless of which is chosen.
half = t / 2
x0, y0, x1, y1 = half, half, w - 1 - half, h - 1 - half
edges = [(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)]
if style == "dashed":
dash, gap = t * 3, t * 2
for ex0, ey0, ex1, ey1 in edges:
_dashed_edge(draw, ex0, ey0, ex1, ey1, t, color, dash, gap)
elif style == "dotted":
spacing = max(t * 2, t + 4)
for ex0, ey0, ex1, ey1 in edges:
_dotted_edge(draw, ex0, ey0, ex1, ey1, t, color, spacing)
# How each orientation maps the logically-composed image onto the native
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
# crop ratio matches how the frame actually hangs) and rotate into native
@@ -94,6 +172,21 @@ PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
# upstream.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
# Per-widget optional border (models.Widget.border_style, see
# draw_widget_border below). "none" is the default/no-op; the rest are
# thickness-px strokes inset within the widget's own region.
BORDER_STYLES = ["none", "solid", "dashed", "dotted", "fancy"]
BORDER_STYLE_LABELS = {
"none": "None",
"solid": "Solid",
"dashed": "Dashed",
"dotted": "Dotted",
"fancy": "Fancy (double line)",
}
MIN_BORDER_THICKNESS = 1
MAX_BORDER_THICKNESS = 8
DEFAULT_BORDER_THICKNESS = 3
def palette_to_hex(palette_rgb: list) -> list[str]:
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
@@ -101,6 +194,21 @@ def palette_to_hex(palette_rgb: list) -> list[str]:
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
def resolve_border_color(color_index: int, palette_rgb: list | None) -> tuple[int, int, int]:
"""Widget.border_color_index -> an actual RGB tuple, against this
frame's tuned palette if it has one (falls back to
DEFAULT_PALETTE_RGB) -- so a border always renders as one of the
panel's real 6 ink colors and never needs to be dithered, same
reasoning as the weather/battery icons' exact-panel-ink-RGB fills
(see docs/widgets.md). Out-of-range indexes (a stale value from a
frame that used to have more colors, though that never happens
today) fall back to Black rather than raising."""
palette = palette_rgb or DEFAULT_PALETTE_RGB
if 0 <= color_index < len(palette):
return tuple(palette[color_index])
return tuple(palette[0])
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
+5 -5
View File
@@ -80,10 +80,10 @@ class ImmichClient:
resp.raise_for_status()
return resp.json()
def create_share_link(self, asset_id: str, expires_in_s: int) -> str:
"""Creates a public, view-only Immich share link for a single
asset, expiring expires_in_s seconds from now, and returns its
public URL. Used by the manage-button overlay's share QR --
def create_share_link(self, asset_ids: list[str], expires_in_s: int) -> str:
"""Creates a public, view-only Immich share link covering one or
more assets, expiring expires_in_s seconds from now, and returns
its public URL. Used by the manage-button overlay's share QR --
created lazily (only when someone actually scans it), not when
the button's pressed, so the expiry clock starts when it's
actually used."""
@@ -93,7 +93,7 @@ class ImmichClient:
headers=self._headers,
json={
"type": "INDIVIDUAL",
"assetIds": [asset_id],
"assetIds": list(asset_ids),
"expiresAt": expires_at,
"allowUpload": False,
"allowDownload": True,
+4 -1
View File
@@ -5,6 +5,8 @@ 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}/...
api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets
api_layouts.py named, user-owned saved layouts, /api/layouts,
/api/frames/{id}/layouts
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
pages.py setup/login/claim/settings/admin
manage.py the limited manage-QR surface (/m/, /api/m/)
@@ -31,7 +33,7 @@ from .auth import (
)
from .db import SessionLocal
from .models import Frame
from .routers import api_frames, api_widgets, device, frame_pages, manage, pages
from .routers import api_frames, api_layouts, api_widgets, device, frame_pages, manage, pages
from .routers.common import shell_context
logger = logging.getLogger(__name__)
@@ -47,6 +49,7 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(device.router)
app.include_router(api_frames.router)
app.include_router(api_widgets.router)
app.include_router(api_layouts.router)
app.include_router(frame_pages.router)
app.include_router(pages.router)
app.include_router(manage.router)
+281 -29
View File
@@ -24,7 +24,6 @@ from .models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
@@ -32,6 +31,7 @@ from .models import (
WhiteboardWidgetConfig,
Widget,
)
from .widgets import default_button_actions
logger = logging.getLogger(__name__)
@@ -385,8 +385,9 @@ def _migration_17(conn) -> None:
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
), {"frame_id": row["frame_id"]}).scalar()
result = conn.execute(text(
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
"border_style, border_thickness, border_color_index) "
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at, 'none', 3, 0)"
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
"sort_order": max_sort + 1, "created_at": now})
new_widget_id = result.lastrowid
@@ -505,10 +506,272 @@ def _migration_20(conn) -> None:
image (or a PDF's first page) the user last uploaded (see
app/image_upload.py, routers/api_widgets.py's api_widget_static_
upload) -- no live upstream to poll, unlike every other widget type.
Brand new table with no existing data to carry forward, so this is
just create_all's usual "creates the one new table; existing ones
untouched" shape (see _migration_2)."""
Base.metadata.create_all(bind=conn)
Raw CREATE TABLE, not Base.metadata.create_all (this migration
originally used create_all -- switched retroactively once it turned
out to matter): create_all creates every table declared in Base.
metadata that's missing, not just this migration's own new one, so
it would just as happily create text_widget_configs (a LATER
migration's model, once TextWidgetConfig existed in models.py) years
before migration 21 gets a turn -- and then migration 21's own
CREATE TABLE collides with the one create_all already snuck in. Same
fix, same reasoning as migration 21's own comment about migration
22's ALTER TABLE -- see that one for the fuller explanation."""
conn.execute(text(
"CREATE TABLE static_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"image BLOB, "
"original_filename TEXT NOT NULL DEFAULT '', "
"uploaded_at REAL NOT NULL DEFAULT 0.0, "
"display_mode TEXT NOT NULL DEFAULT 'crop_fill')"
))
def _migration_21(conn) -> None:
"""New widget type: a text widget shows user-authored rich text (see
app/text_content.py, app/widgets/text.py, models.TextWidgetConfig)
-- another no-live-upstream type like migration 20's static image.
Raw CREATE TABLE (not Base.metadata.create_all, unlike migration 20's
static_widget_configs) because migration 22 adds a column to this
same table right after -- create_all always reflects models.py's
CURRENT shape, so replaying the full migration chain on an old
database would have it already include that later column and
collide with migration 22's ALTER TABLE. Same reason migration 17's
task_widget_configs CREATE TABLE is raw SQL rather than create_all,
ahead of migration 19's ALTER TABLE ADD COLUMN name."""
conn.execute(text(
"CREATE TABLE text_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"content TEXT, "
"font_size INTEGER NOT NULL DEFAULT 28, "
"align TEXT NOT NULL DEFAULT 'left', "
"background_color TEXT NOT NULL DEFAULT '#ffffff')"
))
def _migration_22(conn) -> None:
"""Adds a font family choice to the text widget (app/widgets/text.py's
FONT_FAMILIES) alongside its existing font_size -- both whole-widget
settings, not per-run. "sans" (Noto Sans) matches the column default
so existing text widgets keep rendering in the same font they always
have."""
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN font_family TEXT NOT NULL DEFAULT 'sans'"))
def _migration_23(conn) -> None:
"""New feature: named, user-owned saved layouts (see models.
SavedLayout/SavedLayoutWidget/SavedLayoutSource/
SavedLayoutButtonAction, routers/api_layouts.py) -- a snapshot of a
frame's widget arrangement a user can capture and later apply to any
frame they control whose grid matches, instead of manually rebuilding
it widget by widget.
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
migration 20/21's own comments: create_all always reflects models.py's
CURRENT shape, so replaying the full chain on an old database could
collide with a later migration's ALTER TABLE on one of these same
tables."""
conn.execute(text(
"CREATE TABLE saved_layouts ("
"id INTEGER PRIMARY KEY, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"name TEXT NOT NULL, "
"cols INTEGER NOT NULL, "
"rows INTEGER NOT NULL, "
"created_at REAL NOT NULL DEFAULT 0.0, "
"updated_at REAL NOT NULL DEFAULT 0.0)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_saved_layouts_user_name ON saved_layouts (user_id, name)"
))
conn.execute(text(
"CREATE TABLE saved_layout_widgets ("
"id INTEGER PRIMARY KEY, "
"saved_layout_id INTEGER NOT NULL REFERENCES saved_layouts(id) ON DELETE CASCADE, "
"widget_type TEXT NOT NULL, "
"x INTEGER NOT NULL, y INTEGER NOT NULL, w INTEGER NOT NULL, h INTEGER NOT NULL, "
"sort_order INTEGER NOT NULL DEFAULT 0, "
"config TEXT NOT NULL DEFAULT '{}', "
"image BLOB)"
))
conn.execute(text(
"CREATE INDEX ix_saved_layout_widgets_layout ON saved_layout_widgets (saved_layout_id)"
))
conn.execute(text(
"CREATE TABLE saved_layout_sources ("
"id INTEGER PRIMARY KEY, "
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
"kind TEXT NOT NULL, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"CREATE INDEX ix_saved_layout_sources_widget ON saved_layout_sources (saved_layout_widget_id)"
))
conn.execute(text(
"CREATE TABLE saved_layout_button_actions ("
"id INTEGER PRIMARY KEY, "
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
"button TEXT NOT NULL, "
"action TEXT NOT NULL, "
"sort_order INTEGER NOT NULL DEFAULT 0)"
))
conn.execute(text(
"CREATE INDEX ix_saved_layout_button_actions_widget ON saved_layout_button_actions (saved_layout_widget_id)"
))
def _migration_24(conn) -> None:
"""New widget type: standalone weather (current/hourly/daily/
multi_city display modes, pluggable Open-Meteo/NWS providers -- see
models.WeatherWidgetConfig, app/weather/, app/widgets/weather.py).
Lifts the calendar widget's embedded weather strip's underlying
fetch/render building blocks (app/weather/open_meteo.py, the icon-
drawing primitives now in app/weather_render.py) out into a widget
that can be placed/sized on its own -- CalendarWidgetConfig's own
weather_* columns are untouched, still working exactly as before.
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
migration 20/21/23's own comments: create_all always reflects
models.py's CURRENT shape, so replaying the full chain on an old
database could collide with a later migration's ALTER TABLE on this
same table."""
conn.execute(text(
"CREATE TABLE weather_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"mode TEXT NOT NULL DEFAULT 'current', "
"provider TEXT NOT NULL DEFAULT 'open_meteo', "
"units TEXT NOT NULL DEFAULT 'fahrenheit', "
"city_label TEXT, "
"city_latitude REAL, "
"city_longitude REAL, "
"hourly_interval_hours INTEGER NOT NULL DEFAULT 4, "
"daily_days INTEGER NOT NULL DEFAULT 5, "
"cities TEXT, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached TEXT)"
))
def _migration_25(conn) -> None:
"""New widget type: battery (see models.BatteryWidgetConfig,
app/widgets/battery.py) -- shows the frame's own last-reported
battery level. No live upstream to poll and nothing to cache: unlike
every other widget type added since migration 20, the content is
frame-level state (Frame.battery_percent/battery_as_of) that already
existed before this widget did, so the only new column is a display
mode.
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
migration 20/21/23/24's own comments: create_all always reflects
models.py's CURRENT shape, so replaying the full chain on an old
database could collide with a later migration's ALTER TABLE on this
same table."""
conn.execute(text(
"CREATE TABLE battery_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"mode TEXT NOT NULL DEFAULT 'detailed')"
))
def _migration_26(conn) -> None:
"""Per-widget border (see models.Widget.border_style/border_thickness/
border_color_index, image_pipeline.draw_widget_border) -- a shared
property on the widgets table itself, not a per-type config table,
since every widget type can have one regardless of widget_type.
border_style defaults to 'none' so existing widgets keep rendering
exactly as before until someone opts in via a widget's dialog.
Guarded per-column (unlike every earlier ALTER TABLE ADD COLUMN
migration in this file) because widgets is the one table
test_migrations.py's upgrade-path tests deliberately leave un-dropped
across a simulated old-schema_version replay (see those tests' own
comments: it hasn't changed shape since migration 16 created it, so
reusing the fresh-install create_all() copy -- which, unlike this
ALTER, already reflects models.py's current border_* columns -- was
safe up to now). Without the guard, replaying this migration in that
scenario re-adds a column that's already there and SQLite raises
"duplicate column name"."""
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
if "border_style" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_style TEXT NOT NULL DEFAULT 'none'"))
if "border_thickness" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_thickness INTEGER NOT NULL DEFAULT 3"))
if "border_color_index" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0"))
def _migration_27(conn) -> None:
"""Per-photo-widget lock (models.PhotoWidgetConfig.locked) -- freezes
current_asset_id against both the timer-elapsed auto-advance
(photo_queue.get_current) and the advance/back button actions
(app/widgets/photos.py's ACTIONS) until unlocked. Defaults to
unlocked so existing widgets keep rotating exactly as before.
Guarded per-column, same reasoning as migration 26's own comment:
photo_widget_configs isn't touched by test_migrations.py's simulated
pre-widget-system replays (unlike calendar/task/widgets tables those
tests DROP and recreate in an old shape), so it keeps the fresh-
install create_all() copy -- which already has this column -- when
those tests replay migrations 17+ from schema_version 16. Without
the guard, replaying this migration there re-adds a column that's
already there and SQLite raises "duplicate column name"."""
existing = {c["name"] for c in inspect(conn).get_columns("photo_widget_configs")}
if "locked" not in existing:
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
def _migration_28(conn) -> None:
"""One action per (widget, button) instead of an ordered per-button
list -- button-action editing moved from the frame-level "Button
assignments" card into each widget's own config dialog (see
models.FrameButtonAction's updated docstring, routers/api_widgets.py's
api_widget_config_save). Cross-widget execution order never actually
mattered (each widget's action only touches its own state), so this
only needs to de-dupe down to one row before the new unique index can
be created -- MIN(id) per (widget_id, button) survives, arbitrarily
but deterministically, since which specific extra binding a user's
old list happened to have doesn't matter anymore."""
conn.execute(text(
"DELETE FROM frame_button_actions WHERE id NOT IN "
"(SELECT MIN(id) FROM frame_button_actions GROUP BY widget_id, button)"
))
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_frame_button_actions_widget_button "
"ON frame_button_actions (widget_id, button)"
))
def _migration_29(conn) -> None:
"""Hold-for-global-action (see app/global_actions.py): holding NEXT/
BACK past hold_duration_ms triggers a frame-wide action instead of
the per-widget one a short press runs. next_hold_action/
back_hold_action are NULL (disabled) by default -- existing frames
get no new button behavior until someone opts in on the
Configuration tab. last_cycled_layout_id tracks where a repeated
"cycle saved layouts" hold should resume from.
Guarded per-column, same reasoning as migration 26/27's own
comments: frames is a table test_migrations.py's pre-widget-system
replay tests leave un-dropped (unlike calendar/task/widget tables
those tests DROP and recreate in an old shape), so it keeps the
fresh-install create_all() copy -- which already has these columns
-- when those tests replay migrations 17+ from schema_version 16.
Without the guard, replaying this migration there re-adds a column
that's already there and SQLite raises "duplicate column name"."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "hold_duration_ms" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN hold_duration_ms INTEGER NOT NULL DEFAULT 3000"))
if "next_hold_action" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN next_hold_action TEXT"))
if "back_hold_action" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN back_hold_action TEXT"))
if "last_cycled_layout_id" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
MIGRATIONS = [
@@ -532,6 +795,15 @@ MIGRATIONS = [
(18, _migration_18),
(19, _migration_19),
(20, _migration_20),
(21, _migration_21),
(22, _migration_22),
(23, _migration_23),
(24, _migration_24),
(25, _migration_25),
(26, _migration_26),
(27, _migration_27),
(28, _migration_28),
(29, _migration_29),
]
@@ -742,26 +1014,6 @@ def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWid
)
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
"""NEXT/BACK -> whatever this widget's own advance/back concept is
(see app/widgets/ for the actual action registry, built in a later
phase) -- reproduces each mode's exact old button behavior for the
one auto-migrated widget, so upgrading changes nothing about what the
physical buttons do until someone deliberately reassigns them."""
if widget_type == "whiteboard":
# No real "next"/"back" concept for a static board -- both
# buttons already meant "check now" before this migration (see
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
]
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type
@@ -811,7 +1063,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
)
@@ -827,7 +1079,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(_default_button_actions(frame.id, widget.id, mode))
db.add_all(default_button_actions(frame.id, widget.id, mode))
if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
+234 -9
View File
@@ -311,6 +311,23 @@ class Frame(Base):
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
# -- hold-for-global-action (see app/global_actions.py) -- holding
# NEXT/BACK past hold_duration_ms triggers a global action instead of
# the per-widget one that a short press runs (models.FrameButtonAction).
# Not scoped to any widget, e.g. cycling saved layouts -- hence its
# own pair of frame-level columns rather than living in that table.
hold_duration_ms: Mapped[int] = mapped_column(Integer, default=3000)
next_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
back_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
# Where "cycle saved layouts" resumes from -- the last SavedLayout id
# it applied, so repeated holds advance through the list instead of
# re-applying the same one every time. Deliberately not a real FK:
# this is just a resume cursor, not a relationship needing cascade/
# referential integrity -- if that layout's since been deleted or
# renamed away, global_actions.cycle_layout just doesn't find it and
# starts over from the first one, same as an unset value.
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- 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)
@@ -433,7 +450,7 @@ class Widget(Base):
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather" | "battery"
x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer)
@@ -445,6 +462,20 @@ class Widget(Base):
# table needs to match a specific attribute name here.
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Optional decorative border, drawn once around this widget's own
# region (routers/device.py's _render_widgets) regardless of
# widget_type -- a Widget-level property, not a per-type config
# column, since every widget type can have one. See
# image_pipeline.BORDER_STYLES/draw_widget_border. "none" (the
# default) draws nothing, so existing widgets don't suddenly grow a
# border. border_color_index indexes into the frame's palette_rgb
# (0-5, Black/White/Yellow/Red/Blue/Green) rather than storing an
# arbitrary hex -- an exact palette color quantizes with zero
# dithering error, same reasoning as the weather/battery icons'
# exact-panel-ink-RGB fills (see docs/widgets.md).
border_style: Mapped[str] = mapped_column(String, default="none")
border_thickness: Mapped[int] = mapped_column(Integer, default=3)
border_color_index: Mapped[int] = mapped_column(Integer, default=0)
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
@@ -469,6 +500,7 @@ class PhotoWidgetConfig(Base):
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)
locked: Mapped[bool] = mapped_column(Boolean, default=False)
class CalendarWidgetConfig(Base):
@@ -553,6 +585,75 @@ class WhiteboardWidgetConfig(Base):
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
class WeatherWidgetConfig(Base):
"""One weather widget's settings + cached-fetch state. Four display
modes (see app/widgets/weather.py): "current" (one city, current
temp + icon), "hourly" (one city, a row of ticks across the day),
"daily" (one city, a multi-day strip), "multi_city" (several cities'
current-day high/low/icon side by side -- the calendar widget's
embedded weather strip, lifted out into its own widget type).
`provider` selects which of app/weather/'s PROVIDERS actually fetches
("open_meteo" | "nws" -- see that package's own module docstring).
`cached`'s shape depends on `mode`: {"temp","category"} for current,
a list of {"time","temp","category"} for hourly, a
{"YYYY-MM-DD": {...}} dict for daily, or a list of
{"label","high","low","category"} for multi_city."""
__tablename__ = "weather_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
# Single-location modes only (current/hourly/daily) -- geocoded once
# via weather.geocode_city() when set, same idiom as
# CalendarWidgetConfig.weather_cities' per-entry shape.
city_label: Mapped[str | None] = mapped_column(String, nullable=True)
city_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
city_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
hourly_interval_hours: Mapped[int] = mapped_column(Integer, default=4)
daily_days: Mapped[int] = mapped_column(Integer, default=5)
# multi_city mode only -- [{"label", "latitude", "longitude"}, ...],
# same shape as CalendarWidgetConfig.weather_cities.
cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached: Mapped[dict | list | None] = mapped_column(JSON, nullable=True, default=None)
class TextWidgetConfig(Base):
"""One text widget's authored content + display settings -- another
no-live-upstream type like StaticWidgetConfig, just parsed rich text
instead of an uploaded image. content is never raw HTML: the
dialog's contenteditable innerHTML is parsed server-side (see
app/text_content.py, the sanitization boundary) into this plain
run structure at save time, so render() (app/widgets/text.py) never
re-parses/sanitizes HTML on every panel refresh, and the dialog never
re-injects stored HTML via innerHTML when reopened.
[[{"text","bold","italic","underline","color","bg"}, ...], ...] --
outer list is paragraphs (line breaks), inner list is styled runs
within that paragraph. color/bg are "#rrggbb" or null (falls back to
black text / no highlight). NULL (not just []) means never
configured, matching StaticWidgetConfig.image's None-vs-empty
convention for "not configured yet"."""
__tablename__ = "text_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
content: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Base point size for the whole block -- render() shrinks this down
# (never up) to fit the widget's actual box; per-run font size isn't
# supported, only the bold/italic/underline/color/bg style flags are
# per-run (see app/text_content.py) -- keeps the wrap/shrink-to-fit
# layout in app/widgets/text.py to one size per render pass.
font_size: Mapped[int] = mapped_column(Integer, default=28)
# A key into app/widgets/text.py's FONT_FAMILIES, also whole-widget
# not per-run (see font_size above for why).
font_family: Mapped[str] = mapped_column(String, default="sans")
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
class StaticWidgetConfig(Base):
"""One static-image widget's uploaded content + display settings --
unlike every other widget type, this one has no live upstream to
@@ -574,6 +675,22 @@ class StaticWidgetConfig(Base):
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
class BatteryWidgetConfig(Base):
"""One battery widget's display settings -- another no-live-upstream
type like StaticWidgetConfig/TextWidgetConfig, just showing existing
frame-level state (Frame.battery_percent/battery_as_of, already set
by routers/device.py's frame_battery on every device report) instead
of anything the widget itself fetches or the user authors. `mode`
"compact" is icon + percent only; "detailed" (default) adds the
routers.common.battery_estimate_s time-remaining estimate and the
last report's age."""
__tablename__ = "battery_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed
# widget_type -> its per-type extension table, keyed by widget_id. Used
# by db.widget_locked() to resolve the right config row without importing
# app/widgets/'s heavier render/action registry just for this lookup.
@@ -583,20 +700,24 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig,
"static": StaticWidgetConfig,
"battery": BatteryWidgetConfig,
"text": TextWidgetConfig,
"weather": WeatherWidgetConfig,
}
class FrameButtonAction(Base):
"""One (widget, action) binding for one of a frame's two physical
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
"advance"}. A button can have several of these (sort_order gives
execution order); on a press, every row for that (frame, button) runs
-- see routers/device.py's frame_advance/frame_back. Deliberately
unconstrained about which widget/action pairs with which button (the
user's own idea for resolving "what does NEXT even mean with several
widgets on screen": let them assign literally anything to either
button, including mismatched combinations, rather than the server
guessing a sensible default)."""
"advance"}. At most one binding per (widget, button) -- edited from
that widget's own config dialog (routers/api_widgets.py's
api_widget_config_save), prefilled with a sane default at widget
creation (app/widgets/default_button_actions). On a press, every
widget's row for that (frame, button) runs -- see routers/device.py's
frame_advance/frame_back. sort_order is unused (which widget's action
runs first never matters: each only touches its own state, and one
shared re-render happens after all of them finish) but kept around so
dispatch has a stable, deterministic query order."""
__tablename__ = "frame_button_actions"
@@ -610,9 +731,113 @@ class FrameButtonAction(Base):
__table_args__ = (
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
Index("ix_frame_button_actions_widget_button", "widget_id", "button", unique=True),
)
class SavedLayout(Base):
"""A named snapshot of one frame's widget arrangement (types,
placement, per-widget settings, button assignments) -- owned by a
*user*, not a frame, so it can be applied to any frame that user
controls whose grid matches (see docs/widgets.md's "Saved layouts").
cols/rows is the grid.grid_dims(orientation) the snapshot was taken
at -- an 8x5 (landscape-class) layout isn't meaningful on a 5x8
(portrait-class) frame, same reasoning as grid.py's own orientation-
change note.
Saving again with a name that already exists for this user
overwrites that layout's snapshot in place (see routers/
api_layouts.py's api_layout_save) rather than erroring or quietly
creating a second layout with the same name -- the "named save slot"
behavior people expect."""
__tablename__ = "saved_layouts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String)
cols: Mapped[int] = mapped_column(Integer)
rows: Mapped[int] = mapped_column(Integer)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
updated_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
Index("ix_saved_layouts_user_name", "user_id", "name", unique=True),
)
class SavedLayoutWidget(Base):
"""One captured widget's type/placement/settings within a
SavedLayout -- the snapshot analogue of Widget plus its per-type
config row, minus anything that's runtime/cache state rather than an
authored setting (a photo widget's current queue position, a
calendar's fetch cache, a whiteboard's rendered-image cache, etc.)
-- see api_layouts.LAYOUT_CONFIG_FIELDS for the exact per-type field
allowlist. `config` holds every JSON-safe captured setting; `image`
is only ever populated for a static-image widget's uploaded bytes
(its own BLOB column rather than folding base64 into the JSON, same
reasoning as StaticWidgetConfig.image itself)."""
__tablename__ = "saved_layout_widgets"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_id: Mapped[int] = mapped_column(ForeignKey("saved_layouts.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String)
x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer)
h: Mapped[int] = mapped_column(Integer)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
config: Mapped[dict] = mapped_column(JSON, default=dict)
image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
__table_args__ = (Index("ix_saved_layout_widgets_layout", "saved_layout_id"),)
class SavedLayoutSource(Base):
"""One included calendar/task-list source captured on a calendar or
tasks SavedLayoutWidget -- the snapshot analogue of FrameCalendar/
FrameTaskList. `kind` ("calendar" | "task") distinguishes which,
since both shapes are otherwise identical and sharing one table
avoids a near-duplicate SavedLayoutTaskSource table."""
__tablename__ = "saved_layout_sources"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_widget_id: Mapped[int] = mapped_column(
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
)
kind: Mapped[str] = mapped_column(String)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (Index("ix_saved_layout_sources_widget", "saved_layout_widget_id"),)
class SavedLayoutButtonAction(Base):
"""One (button, action) binding captured for one SavedLayoutWidget --
the snapshot analogue of FrameButtonAction. References the captured
widget directly rather than a frame_id/widget_id pair (neither
exists until the layout is applied) so applying can remap it onto
whichever new Widget row that captured widget becomes -- see
routers/api_layouts.py's api_layout_apply."""
__tablename__ = "saved_layout_button_actions"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_widget_id: Mapped[int] = mapped_column(
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
)
button: Mapped[str] = mapped_column(String)
action: Mapped[str] = mapped_column(String)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
__table_args__ = (Index("ix_saved_layout_button_actions_widget", "saved_layout_widget_id"),)
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
+9 -2
View File
@@ -211,11 +211,18 @@ def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bo
/api/queue), so without this an open browser tab polling overnight
would silently advance the current photo on raw elapsed time alone,
even though the device itself is correctly asleep through the
window (see main.py's _effective_refresh_interval_s)."""
window (see main.py's _effective_refresh_interval_s).
cfg.locked suppresses the elapsed-time trigger the same way
in_quiet_hours does -- a locked widget still needs an initial pick
if it somehow has none (an unconfigured widget just locked, or a
changed album), but once it has a current photo the whole point of
locking is that it stops moving on its own until explicitly
unlocked."""
valid_ids = {a["id"] for a in assets}
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
stale = needs_pick or (time_elapsed and not in_quiet_hours)
stale = needs_pick or (time_elapsed and not in_quiet_hours and not cfg.locked)
if not stale:
return False
advance_forced(cfg, assets, frame)
+25 -94
View File
@@ -22,17 +22,16 @@ 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 delete, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, grid, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
from ..models import BatteryLog, Frame, Widget
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
from .device import render_frame_preview_png
@@ -45,6 +44,11 @@ MAX_REFRESH_INTERVAL_S = 86400
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
# See app/global_actions.py -- how long NEXT/BACK must be held before the
# device treats it as a hold instead of a short press.
MIN_HOLD_DURATION_MS = 3000
MAX_HOLD_DURATION_MS = 10000
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
@@ -101,6 +105,9 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None),
back_hold_action: str | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
@@ -120,7 +127,14 @@ def api_config_save(
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid."""
visually wrong but literally out of bounds on the new grid.
hold_duration_ms/next_hold_action/back_hold_action configure hold-
for-global-action (see app/global_actions.py) -- a frame-wide
setting, not per-widget, hence living here rather than on
api_widgets.py's per-widget button-actions endpoint. An unrecognized
action value clears the binding rather than erroring, same posture
as this endpoint's other enum-ish fields (orientation, timezone)."""
with frame_locked(db, frame.id) as cfg:
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
@@ -168,6 +182,12 @@ def api_config_save(
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 hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None:
cfg.next_hold_action = next_hold_action if next_hold_action in GLOBAL_ACTIONS else None
if back_hold_action is not None:
cfg.back_hold_action = back_hold_action if back_hold_action in GLOBAL_ACTIONS else None
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -251,95 +271,6 @@ def api_frame_preview(
return Response(content=png, media_type="image/png")
BUTTONS = ("next", "back")
@router.get("/api/frames/{frame_id}/buttons")
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""Everything the button-assignment UI needs in one call: every
widget on the frame with the actions its type supports (see
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
ordered list of (widget, action) bindings.
Includes each widget's placement (x/y/w/h) and the frame's grid
dimensions -- two widgets of the same type otherwise look identical
in the assignment UI's dropdowns (both just say "Photos"); the
client derives a position label ("top-left" etc.) from this to tell
them apart, the same way you'd tell them apart by eye on the Layout
canvas."""
cols, rows = grid.grid_dims(frame.orientation)
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
widget_options = [
{
"id": w.id,
"widget_type": w.widget_type,
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
"actions": [
{"action": action, "label": label}
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
],
}
for w in widgets
]
result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
for button in BUTTONS:
rows = db.scalars(
select(FrameButtonAction)
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
.order_by(FrameButtonAction.sort_order)
).all()
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
return result
class ButtonActionItem(BaseModel):
widget_id: int
action: str
class ButtonActionsRequest(BaseModel):
actions: list[ButtonActionItem]
@router.put("/api/frames/{frame_id}/buttons/{button}")
def api_buttons_save(
button: str, body: ButtonActionsRequest,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces the whole ordered action list for one button in a single
call -- simpler and more atomic than separate add/remove/reorder
endpoints for what's normally a list of one to a handful of entries,
and the UI always has the full list in hand anyway (see
static/frame_config.js)."""
if button not in BUTTONS:
raise HTTPException(404, "No such button")
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
for item in body.actions:
widget = widgets_by_id.get(item.widget_id)
if widget is None:
raise HTTPException(400, f"No such widget: {item.widget_id}")
module = WIDGET_TYPES.get(widget.widget_type)
if module is None or item.action not in module.ACTIONS:
raise HTTPException(
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
)
with frame_locked(db, frame.id):
db.execute(
delete(FrameButtonAction).where(
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
)
)
for i, item in enumerate(body.actions):
db.add(FrameButtonAction(
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
sort_order=i, created_at=time.time(),
))
db.commit()
return {"status": "saved"}
@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(
+333
View File
@@ -0,0 +1,333 @@
"""Saved layouts: a named snapshot of one frame's widget arrangement
(types, placement, per-widget settings, button assignments) that a user
can capture and later apply to any frame they control whose grid matches
-- see models.SavedLayout and docs/widgets.md's "Saved layouts" section.
Layouts are owned by a *user*, not a frame (SavedLayout.user_id), so
every endpoint here except save/apply/list (which need a frame_id to act
on) is frame-agnostic -- /api/layouts/{id} rather than /api/frames/{fid}/
layouts/{id}, gated purely on "is this your own saved layout" rather than
frame view/control.
Save/apply DO live under /api/frames/{frame_id}/... (require_frame_
control) since they read/replace one specific frame's actual widgets.
"""
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import grid
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..models import (
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
SavedLayout,
SavedLayoutButtonAction,
SavedLayoutSource,
SavedLayoutWidget,
User,
WIDGET_CONFIG_MODELS,
Widget,
)
router = APIRouter()
MAX_LAYOUT_NAME_LEN = 60
# Per-widget-type allowlist of config columns that are an actual authored
# *setting* (captured/restored by a saved layout) as opposed to runtime/
# cache state (a photo widget's current queue position, a calendar's
# fetch cache, a whiteboard's rendered-image cache, ...) which a layout
# deliberately leaves out -- applying a layout should feel like a fresh
# widget of that type with these settings pre-filled, not a resurrection
# of stale queue/cache state from whenever it was saved. "static"
# excludes `image` on purpose -- that BLOB lives on SavedLayoutWidget.image
# instead (see its own model docstring).
LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
"calendar": (
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
"week_days", "week_layout", "week_start_offset",
),
"tasks": ("name", "show_completed"),
"static": ("display_mode", "original_filename"),
"text": ("content", "font_size", "font_family", "align", "background_color"),
"whiteboard": ("user_id", "url"),
"battery": ("mode",),
}
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
SOURCE_MODELS: dict[str, tuple[type, str]] = {
"calendar": (FrameCalendar, "calendar"),
"tasks": (FrameTaskList, "task"),
}
def _layout_summary(db: Session, layout: SavedLayout, frame: Frame | None = None) -> dict:
widget_count = db.scalar(
select(func.count()).select_from(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id)
)
result = {
"id": layout.id,
"name": layout.name,
"cols": layout.cols,
"rows": layout.rows,
"widget_count": widget_count or 0,
"created_at": layout.created_at,
"updated_at": layout.updated_at,
}
if frame is not None:
cols, rows = grid.grid_dims(frame.orientation)
result["compatible"] = (layout.cols, layout.rows) == (cols, rows)
return result
def _user_owned_layout(db: Session, layout_id: int, user: User) -> SavedLayout:
layout = db.get(SavedLayout, layout_id)
if layout is None or layout.user_id != user.id:
raise HTTPException(404, "No such saved layout")
return layout
@router.get("/api/frames/{frame_id}/layouts")
def api_layouts_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""Every saved layout owned by the calling user (saved layouts are
global to a user, not scoped to this or any other frame) -- includes
a `compatible` flag per layout for whether it can actually be applied
to *this* frame's current grid, so the UI can offer incompatible ones
greyed-out with a reason rather than hiding them outright."""
user = require_user_api(request, db)
layouts = db.scalars(
select(SavedLayout).where(SavedLayout.user_id == user.id).order_by(SavedLayout.name)
).all()
return {"layouts": [_layout_summary(db, layout, frame) for layout in layouts]}
class LayoutSaveRequest(BaseModel):
name: str
def _snapshot_widget_config(widget: Widget, config) -> dict:
fields = LAYOUT_CONFIG_FIELDS.get(widget.widget_type, ())
return {field: getattr(config, field) for field in fields}
def _snapshot_sources(db: Session, widget_id: int, model: type) -> list[dict]:
rows = db.scalars(select(model).where(model.widget_id == widget_id)).all()
return [
{
"user_id": row.user_id, "calendar_key": row.calendar_key, "calendar_label": row.calendar_label,
"included": row.included, "color_index": row.color_index,
}
for row in rows
]
@router.post("/api/frames/{frame_id}/layouts")
def api_layout_save(
body: LayoutSaveRequest, request: Request,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Snapshots this frame's current widgets (placement + settings,
see LAYOUT_CONFIG_FIELDS), their calendar/task sources, and their
button-action bindings into a named layout owned by the calling
user. Saving again with a name this user already has overwrites that
layout's snapshot in place (see models.SavedLayout's docstring)
rather than erroring or creating a duplicate."""
user = require_user_api(request, db)
name = body.name.strip()
if not name:
raise HTTPException(400, "Name is required")
if len(name) > MAX_LAYOUT_NAME_LEN:
raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer")
cols, rows = grid.grid_dims(frame.orientation)
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
now = time.time()
existing = db.execute(
select(SavedLayout).where(SavedLayout.user_id == user.id, SavedLayout.name == name)
).scalar_one_or_none()
if existing is not None:
layout = existing
layout.cols, layout.rows, layout.updated_at = cols, rows, now
db.execute(delete(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id))
db.flush()
else:
layout = SavedLayout(user_id=user.id, name=name, cols=cols, rows=rows, created_at=now, updated_at=now)
db.add(layout)
db.flush()
snapshot_id_by_widget_id: dict[int, int] = {}
for widget in widgets:
config = db.get(WIDGET_CONFIG_MODELS[widget.widget_type], widget.id)
snapshot = SavedLayoutWidget(
saved_layout_id=layout.id, widget_type=widget.widget_type,
x=widget.x, y=widget.y, w=widget.w, h=widget.h, sort_order=widget.sort_order,
config=_snapshot_widget_config(widget, config),
image=config.image if widget.widget_type == "static" else None,
)
db.add(snapshot)
db.flush()
snapshot_id_by_widget_id[widget.id] = snapshot.id
source_model = SOURCE_MODELS.get(widget.widget_type)
if source_model is not None:
model, kind = source_model
for source in _snapshot_sources(db, widget.id, model):
db.add(SavedLayoutSource(saved_layout_widget_id=snapshot.id, kind=kind, **source))
actions = db.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
for action in actions:
snapshot_id = snapshot_id_by_widget_id.get(action.widget_id)
if snapshot_id is None:
continue
db.add(SavedLayoutButtonAction(
saved_layout_widget_id=snapshot_id, button=action.button, action=action.action,
sort_order=action.sort_order,
))
db.commit()
return _layout_summary(db, layout, frame)
class LayoutRenameRequest(BaseModel):
name: str
@router.patch("/api/layouts/{layout_id}")
def api_layout_rename(layout_id: int, body: LayoutRenameRequest, request: Request, db: Session = Depends(get_db)):
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
name = body.name.strip()
if not name:
raise HTTPException(400, "Name is required")
if len(name) > MAX_LAYOUT_NAME_LEN:
raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer")
conflict = db.execute(
select(SavedLayout).where(
SavedLayout.user_id == user.id, SavedLayout.name == name, SavedLayout.id != layout.id
)
).scalar_one_or_none()
if conflict is not None:
raise HTTPException(400, "You already have a saved layout with that name")
layout.name = name
layout.updated_at = time.time()
db.commit()
return _layout_summary(db, layout)
@router.delete("/api/layouts/{layout_id}")
def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(get_db)):
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
db.delete(layout)
db.commit()
return {"status": "deleted"}
def apply_layout_to_frame(db: Session, frame: Frame, layout: SavedLayout) -> int:
"""Replaces frame's entire widget arrangement with layout's snapshot
-- every current widget (and its own config/sources/button actions,
all ondelete="CASCADE") is deleted first, same "act unconditionally
on the server, confirm on the client" posture as
api_widgets.api_widgets_clear. A source whose owning user account
(or a whiteboard's user_id) no longer exists is silently dropped
rather than left dangling -- config is JSON, not FK-checked, so
nothing enforces that at the storage layer. Shared by api_layout_apply
(explicit user action) and global_actions.cycle_layout (a hold-
triggered global action, see app/global_actions.py) -- caller is
responsible for checking the grid-size match first. Returns the
number of widgets applied."""
snapshots = db.scalars(
select(SavedLayoutWidget)
.where(SavedLayoutWidget.saved_layout_id == layout.id)
.order_by(SavedLayoutWidget.sort_order)
).all()
with frame_locked(db, frame.id):
for widget in db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all():
db.delete(widget)
db.flush()
new_widget_id_by_snapshot_id: dict[int, int] = {}
for snapshot in snapshots:
widget = Widget(
frame_id=frame.id, widget_type=snapshot.widget_type,
x=snapshot.x, y=snapshot.y, w=snapshot.w, h=snapshot.h,
sort_order=snapshot.sort_order, created_at=time.time(),
)
db.add(widget)
db.flush()
new_widget_id_by_snapshot_id[snapshot.id] = widget.id
config = WIDGET_CONFIG_MODELS[snapshot.widget_type](widget_id=widget.id)
for field, value in snapshot.config.items():
if field == "user_id" and value is not None and db.get(User, value) is None:
value = None # that WebDAV account no longer exists
setattr(config, field, value)
if snapshot.widget_type == "static" and snapshot.image is not None:
config.image = snapshot.image
config.uploaded_at = time.time()
db.add(config)
source_model = SOURCE_MODELS.get(snapshot.widget_type)
if source_model is not None:
model, kind = source_model
sources = db.scalars(
select(SavedLayoutSource).where(
SavedLayoutSource.saved_layout_widget_id == snapshot.id, SavedLayoutSource.kind == kind
)
).all()
for source in sources:
if db.get(User, source.user_id) is None:
continue # that account no longer exists
db.add(model(
widget_id=widget.id, user_id=source.user_id, calendar_key=source.calendar_key,
calendar_label=source.calendar_label, included=source.included,
color_index=source.color_index,
))
for snapshot in snapshots:
actions = db.scalars(
select(SavedLayoutButtonAction)
.where(SavedLayoutButtonAction.saved_layout_widget_id == snapshot.id)
.order_by(SavedLayoutButtonAction.sort_order)
).all()
for action in actions:
db.add(FrameButtonAction(
frame_id=frame.id, button=action.button,
widget_id=new_widget_id_by_snapshot_id[snapshot.id], action=action.action,
sort_order=action.sort_order, created_at=time.time(),
))
db.commit()
return len(snapshots)
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
def api_layout_apply(
layout_id: int, request: Request,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces this frame's entire widget arrangement with a saved
layout's -- see apply_layout_to_frame above for what that actually
does."""
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
cols, rows = grid.grid_dims(frame.orientation)
if (layout.cols, layout.rows) != (cols, rows):
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
widget_count = apply_layout_to_frame(db, frame, layout)
return {"status": "applied", "widget_count": widget_count}
+359 -7
View File
@@ -26,13 +26,18 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
BORDER_STYLES,
DEFAULT_DISPLAY_MODE,
DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES,
hex_to_rgb,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES,
render_preview_png,
)
@@ -40,22 +45,29 @@ from ..image_upload import decode_upload
from ..models import (
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS,
Widget,
)
from ..widgets import WIDGET_TYPES
from ..text_content import has_text, parse_rich_text
from ..widgets import WIDGET_TYPES, default_button_actions
from ..widgets import battery as battery_widget
from ..widgets import text as text_widget
from .common import (
calendar_sources_for_widget,
fetch_source_and_faces,
get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget,
get_or_refresh_weather_widget_data,
get_or_refresh_whiteboard_for_widget,
immich_client_for,
immich_creds,
@@ -69,13 +81,17 @@ router = APIRouter()
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
MIN_TEXT_FONT_SIZE = 10
MAX_TEXT_FONT_SIZE = 96
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
def _widget_dict(w: Widget) -> dict:
def _widget_dict(w: Widget, locked: bool = False) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order}
"sort_order": w.sort_order, "border_style": w.border_style,
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
"locked": locked}
def require_widget_view(
@@ -127,12 +143,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
# Layout canvas needs to know which photo widgets are locked (to draw
# the lock badge) -- a per-type config field, not on Widget itself,
# so it's a separate lookup rather than something _widget_dict can
# read straight off the row it's given.
photo_widget_ids = [w.id for w in widgets if w.widget_type == "photos"]
locked_by_widget_id = dict(db.execute(
select(PhotoWidgetConfig.widget_id, PhotoWidgetConfig.locked)
.where(PhotoWidgetConfig.widget_id.in_(photo_widget_ids))
).all()) if photo_widget_ids else {}
return {
"orientation": frame.orientation,
"grid": {"cols": cols, "rows": rows},
"widget_types": list(WIDGET_TYPES.keys()),
"min_footprint": grid.MIN_FOOTPRINT,
"widgets": [_widget_dict(w) for w in widgets],
"widgets": [_widget_dict(w, locked_by_widget_id.get(w.id, False)) for w in widgets],
"control": {
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
"you": frame.controlled_by_user_id == user.id,
@@ -190,6 +215,7 @@ def api_widget_create(
db.add(widget)
db.flush()
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
db.add_all(default_button_actions(frame.id, widget.id, body.widget_type))
db.commit()
return _widget_dict(widget)
@@ -217,6 +243,38 @@ def api_widget_move(
return _widget_dict(widget)
class WidgetBorderRequest(BaseModel):
border_style: str
border_thickness: int
border_color_index: int
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/border")
def api_widget_border(
body: WidgetBorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets this widget's optional border -- a shared Widget-level
property (see models.Widget), not a per-type config field, since
every widget type can have one regardless of widget_type. Its own
endpoint (not folded into api_widget_config_save) for the same
reason: that endpoint's per-type dispatch is keyed on a config row
via widget_locked, and border fields live on Widget itself, not any
per-type config table."""
frame, widget = frame_widget
if body.border_style not in BORDER_STYLES:
raise HTTPException(400, f"border_style must be one of {BORDER_STYLES}")
if not (0 <= body.border_color_index < len(PALETTE_LABELS)):
raise HTTPException(400, "border_color_index must be 0-5 (a panel palette color)")
thickness = max(MIN_BORDER_THICKNESS, min(MAX_BORDER_THICKNESS, body.border_thickness))
with frame_locked(db, frame.id):
widget.border_style = body.border_style
widget.border_thickness = thickness
widget.border_color_index = body.border_color_index
db.commit()
return _widget_dict(widget)
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_delete(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
@@ -271,6 +329,20 @@ def api_widget_config_save(
# tasks
tasks_name: str | None = Form(None),
tasks_show_completed: bool | None = Form(None),
# text
text_html: str | None = Form(None),
text_font_size: int | None = Form(None),
text_font_family: str | None = Form(None),
text_align: str | None = Form(None),
text_background_color: str | None = Form(None),
# weather
weather_mode: str | None = Form(None),
weather_provider: str | None = Form(None),
weather_units: str | None = Form(None),
weather_hourly_interval_hours: int | None = Form(None),
weather_daily_days: int | None = Form(None),
# battery
battery_mode: str | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
@@ -347,11 +419,113 @@ def api_widget_config_save(
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
if display_mode is not None:
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
elif widget.widget_type == "text":
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
if text_html is not None:
# The one save path that touches app/text_content.py --
# see its module docstring for why parsing (not storing
# raw HTML) is the actual sanitization boundary here.
xcfg.content = parse_rich_text(text_html)
if text_font_size is not None:
xcfg.font_size = max(MIN_TEXT_FONT_SIZE, min(MAX_TEXT_FONT_SIZE, text_font_size))
if text_font_family is not None:
xcfg.font_family = (
text_font_family if text_font_family in text_widget.FONT_FAMILIES
else text_widget.DEFAULT_FONT_FAMILY
)
if text_align is not None:
xcfg.align = text_align if text_align in ("left", "center", "right") else "left"
if text_background_color is not None:
xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
)
elif widget.widget_type == "weather":
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
if weather_mode != wcfg.mode:
# A stale cache is a different shape under a
# different mode (a single-temp dict vs. an hourly
# list vs. a daily dict vs. a city list) -- clear it
# outright (not just force a refetch attempt) so a
# get_or_refresh_weather_widget_data call that happens
# to fail on the very first fetch under the new mode
# doesn't fall back to the old mode's incompatible
# cached shape.
wcfg.checked_at = 0.0
wcfg.cached = None
wcfg.mode = weather_mode
if weather_provider is not None and weather_provider in weather.PROVIDERS:
if weather_provider != wcfg.provider:
wcfg.checked_at = 0.0
wcfg.provider = weather_provider
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
if weather_units != wcfg.units:
# Cached temps are in the old unit -- force a refetch
# rather than showing stale numbers under a new unit
# label (same idiom as calendar_weather_units above).
wcfg.checked_at = 0.0
wcfg.units = weather_units
if weather_hourly_interval_hours is not None:
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
if weather_daily_days is not None:
wcfg.daily_days = max(1, min(14, weather_daily_days))
elif widget.widget_type == "battery":
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
if battery_mode is not None:
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
class WidgetButtonActionsRequest(BaseModel):
next_button_action: str
back_button_action: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/button-actions")
def api_widget_button_actions(
body: WidgetButtonActionsRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets this widget's NEXT/BACK button bindings (models.FrameButtonAction)
-- its own endpoint, not folded into api_widget_config_save, same
reasoning as api_widget_border above: these rows live in their own
table, not this widget's per-type config table. Replaces the old
frame-level "Button assignments" card (routers/api_frames.py's
api_buttons_get/api_buttons_save, now removed) -- each widget's own
dialog edits its own binding directly, prefilled at widget-creation
time with a sane default (see widgets.default_button_actions).
An empty string clears the binding for that button. Unlike
api_widget_config_save's silent-ignore-unrecognized-value posture,
a value outside this widget type's own ACTIONS is a 400 -- this
request body is specifically about button actions, so a bad value
here is a real client bug worth surfacing, not a stray field to
shrug off."""
frame, widget = frame_widget
valid_actions = set(WIDGET_TYPES[widget.widget_type].ACTIONS)
for value in (body.next_button_action, body.back_button_action):
if value != "" and value not in valid_actions:
raise HTTPException(400, f"{widget.widget_type} widgets don't support the {value!r} action")
with frame_locked(db, frame.id):
for button, value in (("next", body.next_button_action), ("back", body.back_button_action)):
existing = db.scalars(
select(FrameButtonAction).where(
FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == button
)
).first()
if value == "":
if existing is not None:
db.delete(existing)
elif existing is not None:
existing.action = value
else:
db.add(FrameButtonAction(frame_id=frame.id, button=button, widget_id=widget.id, action=value))
db.commit()
return {"status": "saved"}
# --- Photos: queue/thumbnail/preview ------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
@@ -372,6 +546,7 @@ def api_widget_queue(
photo_queue.sync_queue_length(locked_pcfg, assets)
current_asset_id = locked_pcfg.current_asset_id
queue = list(locked_pcfg.queue)
locked = locked_pcfg.locked
controller_id = locked_frame.controlled_by_user_id
controller = (
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
@@ -384,6 +559,7 @@ def api_widget_queue(
return {
"current": entry(current_asset_id) if current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in queue],
"locked": locked,
"control": {"controller": controller, "you": controller_id == user.id},
}
@@ -453,6 +629,26 @@ def api_widget_queue_remove(
return {"status": "removed"}
class QueueLockRequest(BaseModel):
locked: bool
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/lock")
def api_widget_queue_lock(
body: QueueLockRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Freezes/unfreezes current_asset_id (models.PhotoWidgetConfig.locked)
-- while locked, neither the timer-elapsed auto-advance
(photo_queue.get_current) nor the advance/back button actions
(app/widgets/photos.py) change which photo is showing."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.locked = body.locked
return {"status": "saved", "locked": body.locked}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
def api_widget_thumbnail(
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
@@ -461,8 +657,8 @@ def api_widget_thumbnail(
"""Scoped to what this widget 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 this
widget's own curated album. Same rule device.frame_share and
manage.manage_thumbnail already enforce."""
widget's own curated album. Same rule manage.manage_thumbnail
already enforces."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
pcfg = db.get(PhotoWidgetConfig, widget.id)
@@ -801,6 +997,124 @@ def api_widget_weather_city_remove(
return {"status": "saved"}
# --- Weather widget: location/cities/preview -------------------------------
#
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
# "weather-cities") specifically to avoid colliding with the calendar
# widget's own /weather-cities/add|remove route *patterns* above -- both
# are registered against the same {widget_id}-parameterized path shape,
# so a literal name clash there would silently shadow one of them
# regardless of each handler's own _require_widget_type check.
class WeatherLocationRequest(BaseModel):
name: str | None # None clears the location; else a free-text city name to geocode
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
def api_widget_weather_location(
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets (or clears) this weather widget's single configured location
-- the current/hourly/daily modes' one city. A widget-wide display
setting like calendar_view/weather_units, not personal data, hence
require_widget_control rather than the calendar/tasks owner-adds/
anyone-mutes split (there's only ever one location and no per-person
ownership of it)."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
if body.name is None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = None
cfg.city_latitude = None
cfg.city_longitude = None
cfg.cached = None
cfg.checked_at = 0.0
return {"status": "saved", "city": None}
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = city["label"]
cfg.city_latitude = city["latitude"]
cfg.city_longitude = city["longitude"]
cfg.cached = None
cfg.checked_at = 0.0 # pick up the new location promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
def api_widget_weather_widget_city_add(
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""multi_city mode's city list -- same shape/gating as the calendar
widget's own weather-cities/add above, just scoped to this widget's
own WeatherWidgetConfig.cities."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = list(cfg.cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this widget's list")
cities.append(city)
cfg.cities = cities
cfg.checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
def api_widget_weather_widget_city_remove(
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "weather")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
cfg.cities = cities
if cfg.cached:
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
return {"status": "saved"}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
def api_widget_preview_weather(
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""The same throttled fetch cache a live device render would use, run
through the panel composition/quantization pipeline -- "how it will
look on the frame", same convention as the other preview endpoints.
force=True (the "Refresh now" button) bypasses the fetch throttle."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
wcfg = db.get(WeatherWidgetConfig, widget.id)
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
if data is None:
if wcfg.mode == "multi_city":
raise HTTPException(400, "No cities added to this widget yet")
raise HTTPException(400, "No location set on this widget yet")
png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
)
return Response(content=png, media_type="image/png")
# --- Static image: upload/preview -----------------------------------------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
@@ -852,6 +1166,44 @@ def api_widget_preview_static(
return Response(content=png, media_type="image/png")
# --- Text: preview ----------------------------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/text")
def api_widget_preview_text(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The saved rich text run through the same word-wrap/shrink-to-fit
layout and quantize pass a live device render would use -- same
"reflects what's currently saved" convention as the other preview
endpoints."""
frame, widget = frame_widget
_require_widget_type(widget, "text")
xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content):
raise HTTPException(400, "No text authored on this widget yet")
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
return Response(content=png, media_type="image/png")
# --- Battery: preview --------------------------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/battery")
def api_widget_preview_battery(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""Unlike every other preview endpoint, there's no "not configured
yet" 400 case -- the content is frame-level state (battery_percent)
that either exists or doesn't, and render() already degrades to a
"No reports yet" placeholder either way, same as a live device
render would."""
frame, widget = frame_widget
_require_widget_type(widget, "battery")
png = battery_widget.render_preview_png(
db, frame, widget, orientation=frame.orientation, palette_rgb=frame.palette_rgb
)
return Response(content=png, media_type="image/png")
# --- Whiteboard: source/preview ------------------------------------------
class WhiteboardSourceRequest(BaseModel):
+73 -1
View File
@@ -31,6 +31,7 @@ from ..models import (
TaskWidgetConfig,
User,
Widget,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
)
@@ -449,7 +450,15 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
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/{primary_cfg.current_asset_id}"
# Unlike location/date-taken (a single fixed corner, so tied to the
# one "primary" widget above), the share link covers every photo
# widget's current photo (see manage.manage_share) -- so it only
# needs *some* photo widget to have a current photo, not specifically
# the primary one, and doesn't depend on the EXIF fetch above
# succeeding.
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
panel_w, panel_h = logical_render_size(frame.orientation)
face_labels: list[dict] = []
@@ -566,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
return result
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
weather widget (see app/widgets/weather.py) -- reads/writes
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
hourly/daily need a single configured location (city_latitude/
city_longitude); multi_city needs cfg.cities. None if not configured
yet, so render() falls back to a placeholder -- same convention as
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
button) bypasses the throttle entirely.
A single-location mode's fetch failure keeps the last-known cached
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
multi_city fetch fails per-city (like get_or_refresh_weather_for_
widget's calendar-strip counterpart) so one broken city doesn't blank
the others."""
cfg = db.get(WeatherWidgetConfig, widget.id)
if cfg.mode == "multi_city":
if not cfg.cities:
return None
elif cfg.city_latitude is None or cfg.city_longitude is None:
return None
now = time.time()
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
return cfg.cached
if cfg.mode == "multi_city":
previous = {c["label"]: c for c in (cfg.cached or [])}
result = []
for city in cfg.cities:
try:
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
d = next(iter(today.values())) if today else previous.get(city["label"], {})
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
d = previous.get(city["label"], {})
result.append({
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
"category": d.get("category", "cloudy"),
})
else:
try:
if cfg.mode == "current":
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
elif cfg.mode == "hourly":
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
hours=HOURLY_FETCH_HOURS)
else: # "daily"
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
cfg.daily_days)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
return cfg.cached
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached = result
locked_cfg.checked_at = now
return result
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
+61 -43
View File
@@ -15,9 +15,8 @@ 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 fastapi.responses import FileResponse, Response
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
@@ -26,8 +25,9 @@ from .. import grid, mail, 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 logical_render_size, render_panel, render_placeholder
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget
from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
from .common import (
BATTERY_HISTORY_MAX,
@@ -35,9 +35,6 @@ from .common import (
RECHARGE_JUMP_PCT,
RECHARGE_LOOKBACK,
build_manage_content,
immich_client_for,
immich_creds,
photo_widgets_for_frame,
)
logger = logging.getLogger(__name__)
@@ -85,11 +82,14 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
as_png: bool = False) -> bytes:
"""The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math) and
hands the results to image_pipeline.render_panel for the single
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
per-mode RENDERERS dict -- a frame can now show several widgets at
once instead of exactly one mode owning the whole panel."""
into its own region (see app/grid.py for grid-cell -> pixel math),
draws that widget's own optional border directly onto its region
(models.Widget.border_style, a shared per-widget property no
widget_type module needs to know about) and hands the results to
image_pipeline.render_panel for the single shared paste/enhance/
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
dict -- a frame can now show several widgets at once instead of
exactly one mode owning the whole panel."""
all_widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
@@ -103,6 +103,10 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
)
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
draw_widget_border(
img, widget.border_style, widget.border_thickness,
resolve_border_color(widget.border_color_index, frame.palette_rgb),
)
regions.append(((px, py, pw, ph), img))
return render_panel(
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
@@ -181,6 +185,22 @@ def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
)
def _run_global_action(db: Session, frame: Frame, button: str) -> None:
"""The hold-triggered counterpart to _run_button_actions -- runs
whichever entry in app/global_actions.GLOBAL_ACTIONS this button's
Frame.next_hold_action/back_hold_action points to, if any (unset or
unrecognized is a silent no-op, same posture as an unbound short-
press button). See routers/device.py's frame_global_next/back."""
action = frame.next_hold_action if button == "next" else frame.back_hold_action
action_fn = GLOBAL_ACTIONS.get(action) if action else None
if action_fn is None:
return
try:
action_fn(db, frame)
except Exception:
logger.exception("Global hold action %r failed for frame %d", action, frame.id)
@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
@@ -206,6 +226,11 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None,
# Additive key -- old firmware's hand-rolled parser only ever
# extracts the keys it knows about, so this is safe for
# firmware that predates hold-for-global-action (see
# firmware/main/next_button.c, app/global_actions.py).
"hold_duration_ms": locked.hold_duration_ms,
}
# Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte-
@@ -267,6 +292,30 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/global-next")
def frame_global_next(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Fires when the device detects NEXT held past Frame.hold_duration_ms
instead of a short press -- runs Frame.next_hold_action (see
app/global_actions.GLOBAL_ACTIONS) if one is set, then re-renders and
returns the whole panel same as /frame/advance. A separate endpoint
from /frame/advance (not a query flag on it) so the frozen short-press
path's behavior never has to account for the long-press case -- see
firmware/main/next_button.c for the short/long split."""
_run_global_action(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/global-back")
def frame_global_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/global-next, for a held BACK button."""
_run_global_action(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
@@ -356,34 +405,3 @@ def frame_firmware(frame: Frame = Depends(require_device)):
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), db: Session = Depends(get_db)):
"""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 one of THIS
frame's own photo widgets -- not any arbitrary Immich asset id -- as
a second layer even a leaked token wouldn't bypass."""
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
photo_widgets = photo_widgets_for_frame(db, frame)
showing_or_queued = any(
asset_id == cfg.current_asset_id or asset_id in cfg.queue
for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets)
)
if not showing_or_queued:
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)
+71 -6
View File
@@ -17,30 +17,42 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import weather
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
from ..global_actions import GLOBAL_ACTION_LABELS
from ..image_pipeline import (
BORDER_STYLES,
BORDER_STYLE_LABELS,
DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES,
palette_to_hex,
)
from ..models import (
BatteryWidgetConfig,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
User,
UserFrame,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
from ..quiet_hours import ALL_TIMEZONES
from ..widgets import WIDGET_TYPES
from ..widgets import text as text_widget
from .common import shell_context, widget_of_type
router = APIRouter()
@@ -79,6 +91,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id,
global_action_labels=GLOBAL_ACTION_LABELS,
)
@@ -209,11 +222,44 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
# Every dialog includes the shared "Border" card (_widget_border_fields.html,
# models.Widget.border_style/border_thickness/border_color_index) --
# a Widget-level property, not a per-type config field, so this
# context is the same regardless of widget_type.
border_ctx = {
"border_styles": BORDER_STYLES,
"border_style_labels": BORDER_STYLE_LABELS,
"border_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
"min_border_thickness": MIN_BORDER_THICKNESS,
"max_border_thickness": MAX_BORDER_THICKNESS,
}
# Every dialog also includes the shared "Button actions" card
# (_widget_button_fields.html) if this widget type supports any --
# empty for tasks/static/text/battery, so the card renders nothing
# for those. Bindings, not the type's own config, so this lives in
# FrameButtonAction (see models.py), same reasoning as border_ctx
# above for why it's a separate card/endpoint from the type-specific
# form.
bindings = {
row.button: row.action
for row in db.scalars(
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id)
)
}
button_ctx = {
"button_action_labels": WIDGET_TYPES[widget.widget_type].ACTION_LABELS,
"next_button_action": bindings.get("next", ""),
"back_button_action": bindings.get("back", ""),
}
if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", {
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
"display_mode_labels": DISPLAY_MODE_LABELS,
"display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx, **button_ctx,
})
if widget.widget_type == "calendar":
@@ -224,8 +270,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
**border_ctx, **button_ctx,
})
if widget.widget_type == "tasks":
@@ -234,8 +279,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
**border_ctx, **button_ctx,
})
if widget.widget_type == "static":
@@ -243,6 +287,14 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
return templates.TemplateResponse("_widget_dialog_static.html", {
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
**border_ctx, **button_ctx,
})
if widget.widget_type == "text":
text_cfg = db.get(TextWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_text.html", {
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
"text_font_families": text_widget.FONT_FAMILIES, **border_ctx, **button_ctx,
})
if widget.widget_type == "whiteboard":
@@ -253,7 +305,20 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds,
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
})
if widget.widget_type == "weather":
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_weather.html", {
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
"weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx, **button_ctx,
})
if widget.widget_type == "battery":
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_battery.html", {
"request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx, **button_ctx,
})
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
+43 -3
View File
@@ -11,7 +11,7 @@ import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import select
@@ -19,8 +19,14 @@ from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
from ..db import get_db, widget_locked
from ..models import Frame
from .common import immich_client_for, list_assets, photo_widget_config_or_404
from ..models import Frame, PhotoWidgetConfig
from .common import (
immich_client_for,
immich_creds,
list_assets,
photo_widget_config_or_404,
photo_widgets_for_frame,
)
logger = logging.getLogger(__name__)
@@ -120,3 +126,37 @@ def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db:
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)
@router.get("/frame/share/{manage_token}")
def manage_share(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Creates a 30-minute public Immich share link covering every photo
widget's currently-displayed asset on this frame, and redirects to it
-- what the manage overlay's bottom-left QR code points to. Lazily
created (only when someone actually scans it, not when the manage
button was pressed), so the 30-minute window starts at actual use.
Keyed on this frame's own manage_token, like the rest of this router,
rather than device credentials -- a phone scanning a QR code has no
way to supply the device's ?id=/?token=, which is why this used to
silently fall back to whichever frame happened to still carry the
legacy migration token instead of the frame that was actually
scanned."""
photo_widgets = photo_widgets_for_frame(db, frame)
asset_ids: list[str] = []
for widget in photo_widgets:
cfg = db.get(PhotoWidgetConfig, widget.id)
if cfg.current_asset_id and cfg.current_asset_id not in asset_ids:
asset_ids.append(cfg.current_asset_id)
if not asset_ids:
raise HTTPException(404, "No photos currently showing on this frame")
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_ids, 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)
+1 -1
View File
@@ -62,7 +62,7 @@
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = {
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
static: 'Static image',
static: 'Static image', text: 'Text', weather: 'Weather', battery: 'Battery',
};
function showStatus(ok, message) {
+25 -200
View File
@@ -59,6 +59,31 @@ document.getElementById('config-form').addEventListener('submit', async (e) => {
}
});
// Hold-for-global-action (see app/global_actions.py) -- a frame-wide
// setting, not per-widget, so it shares api_config_save/the /config
// endpoint rather than getting its own -- just a separate card/form on
// this page for a distinct-enough concern.
document.getElementById('hold-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3;
const body = new URLSearchParams({
hold_duration_ms: String(seconds * 1000),
next_hold_action: document.getElementById('next_hold_action').value,
back_hold_action: document.getElementById('back_hold_action').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.');
} catch (err) {
showStatus(false, err.message);
}
});
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
@@ -363,203 +388,3 @@ loadFirmwareCheck();
// cheap either way.
setInterval(loadFirmwareCheck, 60000);
// --- Button assignments -----------------------------------------------
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
// api_buttons_get. Each button's list is edited client-side (add/
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
// add/remove endpoints for what's normally a handful of entries, and
// this file already has the full list in hand after any edit.
let buttonsData = null;
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
const BUTTONS = ['next', 'back'];
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
// frame's grid dims -- the same rough position you'd read off the
// Layout canvas by eye, used to tell apart two widgets of the same type
// that would otherwise both just say "Photos".
function widgetPositionLabel(w, grid) {
const cx = w.x + w.w / 2;
const cy = w.y + w.h / 2;
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
if (!horiz && !vert) return 'center';
if (!vert) return horiz;
if (!horiz) return vert;
return `${vert}-${horiz}`;
}
// A single widget of a given type keeps the plain type name ("Photos")
// -- the common case, no need to clutter it. Only widgets sharing a
// type with another widget on the same frame get a number + position
// suffix, numbered in reading order (top-to-bottom, left-to-right).
function buildWidgetNames(widgets, grid) {
const byType = {};
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
const names = {};
Object.values(byType).forEach((group) => {
if (group.length === 1) {
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
return;
}
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
ordered.forEach((w, i) => {
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
});
});
return names;
}
function widgetActionLabel(widgetId, action) {
const w = buttonsData.widgets.find((w) => w.id === widgetId);
if (!w) return `(deleted widget): ${action}`;
const found = w.actions.find((a) => a.action === action);
const actionLabel = found ? found.label : action;
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
}
function renderButtonList(button) {
const list = document.getElementById(`button-actions-${button}`);
const rows = buttonsData[button];
list.innerHTML = '';
if (!rows.length) {
list.innerHTML = '<li class="sub">Nothing assigned -- this button wont do anything.</li>';
return;
}
rows.forEach((row, i) => {
const li = document.createElement('li');
li.className = 'button-action-row';
const span = document.createElement('span');
span.textContent = widgetActionLabel(row.widget_id, row.action);
const controls = document.createElement('span');
controls.className = 'button-action-controls';
const up = document.createElement('button');
up.type = 'button';
up.className = 'icon-btn';
up.textContent = '↑';
up.title = 'Move up';
up.disabled = i === 0;
up.addEventListener('click', () => moveButtonAction(button, i, -1));
const down = document.createElement('button');
down.type = 'button';
down.className = 'icon-btn';
down.textContent = '↓';
down.title = 'Move down';
down.disabled = i === rows.length - 1;
down.addEventListener('click', () => moveButtonAction(button, i, 1));
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'icon-btn';
remove.textContent = '×';
remove.title = 'Remove';
remove.addEventListener('click', () => removeButtonAction(button, i));
controls.appendChild(up);
controls.appendChild(down);
controls.appendChild(remove);
li.appendChild(span);
li.appendChild(controls);
list.appendChild(li);
});
}
function populateActionSelect(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
const actionSel = document.getElementById(`button-add-action-${button}`);
actionSel.innerHTML = '';
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
if (!w) return;
w.actions.forEach((a) => {
const opt = document.createElement('option');
opt.value = a.action;
opt.textContent = a.label;
actionSel.appendChild(opt);
});
}
function populateWidgetSelect(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
widgetSel.innerHTML = '';
buttonsData.widgets.forEach((w) => {
const opt = document.createElement('option');
opt.value = w.id;
opt.textContent = widgetNames[w.id] || WIDGET_LABELS[w.widget_type] || w.widget_type;
widgetSel.appendChild(opt);
});
populateActionSelect(button);
}
async function saveButtonActions(button) {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Button assignments saved.');
} catch (e) {
showStatus(false, e.message);
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
}
}
function moveButtonAction(button, index, delta) {
const rows = buttonsData[button];
const target = index + delta;
if (target < 0 || target >= rows.length) return;
[rows[index], rows[target]] = [rows[target], rows[index]];
renderButtonList(button);
saveButtonActions(button);
}
function removeButtonAction(button, index) {
buttonsData[button].splice(index, 1);
renderButtonList(button);
saveButtonActions(button);
}
function addButtonAction(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
const actionSel = document.getElementById(`button-add-action-${button}`);
if (!widgetSel.value || !actionSel.value) return;
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
renderButtonList(button);
saveButtonActions(button);
}
async function loadButtons() {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
if (!resp.ok) throw new Error(await apiError(resp));
buttonsData = await resp.json();
widgetNames = buildWidgetNames(buttonsData.widgets, buttonsData.grid);
document.getElementById('button-assign-groups').style.display =
buttonsData.widgets.length ? '' : 'none';
document.getElementById('button-assign-empty-hint').style.display =
buttonsData.widgets.length ? 'none' : '';
BUTTONS.forEach((button) => {
renderButtonList(button);
populateWidgetSelect(button);
});
} catch (e) {
showStatus(false, e.message);
}
}
BUTTONS.forEach((button) => {
document.getElementById(`button-add-widget-${button}`)
.addEventListener('change', () => populateActionSelect(button));
document.getElementById(`button-add-${button}`)
.addEventListener('click', () => addButtonAction(button));
});
loadButtons();
+13 -2
View File
@@ -210,6 +210,14 @@ function renderCanvas() {
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
box.appendChild(label);
if (widget.locked) {
const lockBadge = document.createElement('span');
lockBadge.className = 'widget-box-lock-badge';
lockBadge.textContent = '\u{1F512}'; // lock emoji -- open the gear icon to unlock
lockBadge.title = 'Locked -- won\'t change until unlocked in this widget\'s settings';
box.appendChild(lockBadge);
}
const settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.className = 'widget-box-settings';
@@ -286,11 +294,13 @@ window.addEventListener('resize', () => {
// icon was clicked).
const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog, static: initStaticDialog,
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
battery: initBatteryDialog,
};
const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
battery: closeBatteryDialog,
};
let openDialogWidgetType = null;
@@ -341,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => {
openDialogWidgetType = null;
window.FRAME_API = window.FRAME_BASE_API;
document.getElementById('widget-dialog-body').innerHTML = '';
loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge)
});
loadWidgets();
+196
View File
@@ -0,0 +1,196 @@
// Layout tab's "Saved layouts" card: save the current widget arrangement
// (placement, settings, button assignments -- see routers/api_layouts.py)
// under a name, then switch back to it later. Layouts are owned by the
// logged-in user, not this frame, so this always hits window.FRAME_BASE_API
// (the frame-level base) rather than window.FRAME_API, which the widget
// dialog machinery in frame_layout.js temporarily repoints at a specific
// widget while its gear-icon dialog is open.
let savedLayouts = [];
let editingLayoutId = null; // inline rename in progress, same pattern as frame_header.js's name pencil-edit
function renderSavedLayouts() {
const list = document.getElementById('saved-layout-list');
const emptyHint = document.getElementById('saved-layout-empty-hint');
list.innerHTML = '';
emptyHint.style.display = savedLayouts.length ? 'none' : '';
savedLayouts.forEach((layout) => {
const li = document.createElement('li');
li.className = 'saved-layout-row';
if (editingLayoutId === layout.id) {
const input = document.createElement('input');
input.type = 'text';
input.maxLength = 60;
input.value = layout.name;
input.className = 'saved-layout-rename-input';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'btn-inline';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', () => renameSavedLayout(layout, input.value));
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn-inline secondary';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => { editingLayoutId = null; renderSavedLayouts(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') renameSavedLayout(layout, input.value);
if (e.key === 'Escape') { editingLayoutId = null; renderSavedLayouts(); }
});
li.appendChild(input);
li.appendChild(saveBtn);
li.appendChild(cancelBtn);
list.appendChild(li);
input.focus();
input.select();
return;
}
const nameWrap = document.createElement('span');
nameWrap.className = 'saved-layout-name';
const nameText = document.createElement('span');
nameText.textContent = layout.name;
nameWrap.appendChild(nameText);
if (!layout.compatible) {
const badge = document.createElement('span');
badge.className = 'saved-layout-badge';
badge.textContent = 'different orientation';
nameWrap.appendChild(badge);
}
li.appendChild(nameWrap);
const controls = document.createElement('span');
controls.className = 'saved-layout-controls';
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'btn-inline';
applyBtn.textContent = 'Apply';
applyBtn.disabled = !layout.compatible;
applyBtn.title = layout.compatible
? `Replace the current arrangement with "${layout.name}"`
: "This layout was saved for a different orientation's grid";
applyBtn.addEventListener('click', () => applySavedLayout(layout));
controls.appendChild(applyBtn);
const renameBtn = document.createElement('button');
renameBtn.type = 'button';
renameBtn.className = 'icon-btn';
renameBtn.textContent = '✎'; // pencil
renameBtn.title = 'Rename';
renameBtn.addEventListener('click', () => { editingLayoutId = layout.id; renderSavedLayouts(); });
controls.appendChild(renameBtn);
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'icon-btn';
deleteBtn.textContent = '×';
deleteBtn.title = 'Delete';
deleteBtn.addEventListener('click', () => deleteSavedLayout(layout));
controls.appendChild(deleteBtn);
li.appendChild(controls);
list.appendChild(li);
});
}
async function loadSavedLayouts() {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/layouts`);
if (!resp.ok) throw new Error(await apiError(resp));
savedLayouts = (await resp.json()).layouts;
renderSavedLayouts();
} catch (e) {
showStatus(false, e.message);
}
}
async function saveCurrentLayout() {
const input = document.getElementById('saved-layout-name');
const name = input.value.trim();
if (!name) {
showStatus(false, 'Give this layout a name first.');
return;
}
const existing = savedLayouts.find((l) => l.name === name);
if (existing && !confirm(`You already have a saved layout named "${name}" -- overwrite it with the current arrangement?`)) {
return;
}
try {
const resp = await fetch(`${window.FRAME_BASE_API}/layouts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
input.value = '';
showStatus(true, `Saved layout "${name}".`);
} catch (e) {
showStatus(false, e.message);
} finally {
loadSavedLayouts();
}
}
document.getElementById('save-layout-btn').addEventListener('click', saveCurrentLayout);
document.getElementById('saved-layout-name').addEventListener('keydown', (e) => {
if (e.key === 'Enter') saveCurrentLayout();
});
async function applySavedLayout(layout) {
if (!confirm(`Replace the current arrangement with "${layout.name}"? Widgets not in that layout will be removed.`)) {
return;
}
try {
const resp = await fetch(`${window.FRAME_BASE_API}/layouts/${layout.id}/apply`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, `Applied "${layout.name}".`);
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets(); // see frame_layout.js -- reloads the placement canvas
}
}
async function renameSavedLayout(layout, rawName) {
const name = rawName.trim();
if (!name || name === layout.name) {
editingLayoutId = null;
renderSavedLayouts();
return;
}
try {
const resp = await fetch(`/api/layouts/${layout.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Renamed.');
editingLayoutId = null;
} catch (e) {
showStatus(false, e.message);
} finally {
loadSavedLayouts();
}
}
async function deleteSavedLayout(layout) {
if (!confirm(`Delete the saved layout "${layout.name}"? This can't be undone.`)) return;
try {
const resp = await fetch(`/api/layouts/${layout.id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Deleted.');
} catch (e) {
showStatus(false, e.message);
} finally {
loadSavedLayouts();
}
}
loadSavedLayouts();
+82 -8
View File
@@ -205,6 +205,12 @@ details.card .sub { margin-top: 8px; }
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
input[type="color"] {
width: 44px;
height: 34px;
padding: 2px;
cursor: pointer;
}
input[type="range"] {
width: 100%;
margin-top: 8px;
@@ -259,9 +265,10 @@ input:focus, select:focus {
box-shadow: 0 0 0 3px var(--focus-ring);
}
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.button-action-row {
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.saved-layout-row {
display: flex;
align-items: center;
justify-content: space-between;
@@ -270,23 +277,76 @@ input:focus, select:focus {
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
flex-wrap: wrap;
}
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.button-action-add select { width: auto; margin-top: 0; }
.saved-layout-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
.saved-layout-name > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.saved-layout-badge {
font-size: 11px;
padding: 2px 7px;
border-radius: 999px;
background: var(--warn-bg);
color: var(--warn-text);
white-space: nowrap;
}
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.saved-layout-controls .btn-inline { margin: 0; }
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
.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; }
.richtext-toolbar { display: flex; align-items: center; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
.richtext-btn {
width: auto;
min-width: 32px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.richtext-btn:hover { background: var(--surface); }
.richtext-toolbar-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 4px; }
.richtext-color-label {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: normal;
font-size: 14px;
color: var(--text-muted);
cursor: pointer;
}
.richtext-color-label input[type="color"] {
width: 22px;
height: 22px;
padding: 0;
margin-top: 0;
}
.richtext-editor {
margin-top: 8px;
min-height: 90px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-alt);
color: var(--text);
font-size: 15px;
line-height: 1.5;
}
.richtext-editor:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
.calendar-user-list > li { margin-top: 14px; }
.calendar-user-list > li:first-child { margin-top: 0; }
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
.calendar-row { flex-wrap: wrap; }
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
.border-color-picker { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
.color-swatch {
width: 20px; height: 20px; padding: 0; margin: 0;
border: 2px solid var(--border); border-radius: 5px;
@@ -399,6 +459,20 @@ button.secondary:hover { background: var(--surface-alt); }
.widget-box-remove { right: 4px; }
.widget-box-settings { right: 28px; }
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
.widget-box-lock-badge {
position: absolute;
bottom: 4px;
left: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--overlay);
color: #fff;
font-size: 11px;
line-height: 20px;
text-align: center;
pointer-events: none; /* passive indicator, not a control -- toggled from the widget's own dialog */
}
.widget-box-resize-handle {
position: absolute;
bottom: 0;
@@ -0,0 +1,39 @@
// Battery widget dialog: display-mode setting and the rendered preview.
// Not a page-load script -- frame_layout.js fetches this widget's dialog
// HTML fragment, injects it into the shared <dialog>, points
// window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initBatteryDialog().
function loadBatteryPreview() {
document.getElementById('battery-preview').src = `${window.FRAME_API}/preview/battery?_=${Date.now()}`;
}
function initBatteryDialog() {
document.getElementById('battery-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
battery_mode: document.getElementById('battery_mode').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.');
loadBatteryPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
loadBatteryPreview();
initBorderFields();
initButtonActionFields();
}
function closeBatteryDialog() {
// Nothing to tear down -- no poll interval, no upload state.
}
+48
View File
@@ -0,0 +1,48 @@
// Shared "Border" card (models.Widget.border_style/border_thickness/
// border_color_index, _widget_border_fields.html) -- present on every
// widget type's dialog regardless of widget_type, so this is one shared
// init function each widget_dialog_<type>.js's init<Type>Dialog() calls,
// rather than 8 copies of the same slider/swatch/save wiring. Not a
// page-load script by itself -- frame_layout.js loads it unconditionally
// (like every other widget_dialog_*.js) since which dialog is open, and
// therefore which init<Type>Dialog() calls initBorderFields(), varies.
function initBorderFields() {
const styleSelect = document.getElementById('border_style');
if (!styleSelect) return; // dialog fragment didn't render the border card -- shouldn't happen
const thickness = document.getElementById('border_thickness');
const thicknessValue = document.getElementById('border_thickness_value');
thickness.addEventListener('input', (e) => {
thicknessValue.textContent = `${e.target.value}px`;
});
const colorIndexInput = document.getElementById('border_color_index');
document.querySelectorAll('#border-color-picker .color-swatch').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('#border-color-picker .color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
colorIndexInput.value = btn.dataset.index;
});
});
document.getElementById('border-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = JSON.stringify({
border_style: styleSelect.value,
border_thickness: Number(thickness.value),
border_color_index: Number(colorIndexInput.value),
});
try {
const resp = await fetch(`${window.FRAME_API}/border`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Border saved.');
} catch (err) {
showStatus(false, err.message);
}
});
}
@@ -0,0 +1,33 @@
// Shared "Button actions" card (models.FrameButtonAction,
// _widget_button_fields.html) -- present on every widget type's dialog
// that supports any actions at all (the card renders nothing for
// tasks/static/text/battery, whose ACTIONS is empty), so this is one
// shared init function each widget_dialog_<type>.js's init<Type>Dialog()
// calls, rather than N copies of the same save wiring -- same pattern as
// widget_dialog_border.js. Not a page-load script by itself --
// frame_layout.js loads it unconditionally (like every other
// widget_dialog_*.js) since which dialog is open varies.
function initButtonActionFields() {
const form = document.getElementById('button-actions-form');
if (!form) return; // this widget type has no actions -- card didn't render
form.addEventListener('submit', async (e) => {
e.preventDefault();
const body = JSON.stringify({
next_button_action: document.getElementById('next_button_action').value,
back_button_action: document.getElementById('back_button_action').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/button-actions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Button actions saved.');
} catch (err) {
showStatus(false, err.message);
}
});
}
@@ -197,6 +197,8 @@ function initCalendarDialog() {
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
initBorderFields();
initButtonActionFields();
}
function closeCalendarDialog() {
+36
View File
@@ -8,6 +8,33 @@
// FRAME_API + a global loadQueue()" contract queue.js has always had.
let photosPollTimer = null;
let photoLocked = false;
let photoHasCurrent = false;
function renderLockButton() {
const btn = document.getElementById('lock-photo-btn');
if (!btn) return;
btn.textContent = photoLocked ? 'Unlock this photo' : 'Lock this photo';
btn.classList.toggle('active', photoLocked);
btn.disabled = !photoHasCurrent && !photoLocked; // nothing displayed yet to lock
}
async function toggleLock() {
const next = !photoLocked;
try {
const resp = await fetch(`${window.FRAME_API}/lock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locked: next }),
});
if (!resp.ok) throw new Error(await apiError(resp));
photoLocked = next;
renderLockButton();
showStatus(true, photoLocked ? 'Locked -- this photo will stay put.' : 'Unlocked.');
} catch (e) {
showStatus(false, e.message);
}
}
async function loadQueue() {
if (dragState) {
@@ -21,9 +48,14 @@ async function loadQueue() {
currentEl.innerHTML =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
renderUpcoming([]);
photoHasCurrent = false;
renderLockButton();
return;
}
const data = await resp.json();
photoLocked = !!data.locked;
photoHasCurrent = !!data.current;
renderLockButton();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
@@ -109,10 +141,14 @@ function initPhotosDialog() {
}
});
document.getElementById('lock-photo-btn').addEventListener('click', toggleLock);
loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag.
photosPollTimer = setInterval(loadQueue, 10000);
initBorderFields();
initButtonActionFields();
}
function closePhotosDialog() {
@@ -55,6 +55,8 @@ function initStaticDialog() {
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
loadStaticPreview();
initBorderFields();
initButtonActionFields();
}
function closeStaticDialog() {
+2
View File
@@ -88,6 +88,8 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
initBorderFields();
initButtonActionFields();
}
function closeTasksDialog() {
+143
View File
@@ -0,0 +1,143 @@
// Text widget dialog: a small rich-text editor (bold/italic/underline,
// text/highlight color), font size/alignment/background settings, and
// the rendered preview. Not a page-load script -- frame_layout.js
// fetches this widget's dialog HTML fragment, injects it into the
// shared <dialog>, points window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initTextDialog().
//
// The editor is never seeded via innerHTML string interpolation --
// #text-editor's data-content attribute (server-rendered from
// app/text_content.py's already-sanitized run structure, not raw HTML)
// is JSON-parsed and rebuilt with createElement/textContent below. The
// same <div><span style="..."> shape this produces is exactly what
// app/text_content.py's parser expects back on save, so round-tripping
// (load -> edit -> save -> reload) is stable.
function loadTextPreview() {
document.getElementById('text-preview').src = `${window.FRAME_API}/preview/text?_=${Date.now()}`;
}
function buildTextEditorContent(editor, paragraphs) {
editor.textContent = '';
if (!paragraphs || !paragraphs.length) return;
paragraphs.forEach((para) => {
const div = document.createElement('div');
if (!para.length) {
div.appendChild(document.createElement('br'));
} else {
para.forEach((run) => {
const span = document.createElement('span');
span.textContent = run.text;
if (run.bold) span.style.fontWeight = 'bold';
if (run.italic) span.style.fontStyle = 'italic';
if (run.underline) span.style.textDecoration = 'underline';
if (run.color) span.style.color = run.color;
if (run.bg) span.style.backgroundColor = run.bg;
div.appendChild(span);
});
}
editor.appendChild(div);
});
}
let _textSavedRange = null;
let _textSelectionHandler = null;
function initTextDialog() {
const editor = document.getElementById('text-editor');
let initialContent = null;
try {
initialContent = JSON.parse(editor.dataset.content || 'null');
} catch (e) { /* leave empty */ }
buildTextEditorContent(editor, initialContent);
// Native <input type="color"> steals focus (and with it, the
// editor's text selection) the moment it's interacted with -- track
// the most recent in-editor selection continuously so a color pick
// can be reapplied to the text the user actually had selected,
// instead of applying to nothing.
_textSelectionHandler = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
_textSavedRange = sel.getRangeAt(0).cloneRange();
}
};
document.addEventListener('selectionchange', _textSelectionHandler);
function restoreSelection() {
if (!_textSavedRange) return;
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(_textSavedRange);
}
['text-bold', 'text-italic', 'text-underline'].forEach((id) => {
const btn = document.getElementById(id);
// preventDefault on mousedown keeps focus (and the selection) in
// the editor, so the click's execCommand has something to act on.
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', () => {
editor.focus();
document.execCommand(btn.dataset.cmd, false, null);
});
});
document.getElementById('text-color').addEventListener('input', (e) => {
editor.focus();
restoreSelection();
document.execCommand('foreColor', false, e.target.value);
});
document.getElementById('text-highlight').addEventListener('input', (e) => {
editor.focus();
restoreSelection();
document.execCommand('hiliteColor', false, e.target.value);
});
document.getElementById('text-highlight-clear').addEventListener('mousedown', (e) => e.preventDefault());
document.getElementById('text-highlight-clear').addEventListener('click', () => {
editor.focus();
restoreSelection();
document.execCommand('hiliteColor', false, 'transparent');
});
document.getElementById('text_font_size').addEventListener('input', (e) => {
document.getElementById('text_font_size_value').textContent = `${e.target.value}px`;
});
document.getElementById('text-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
text_html: editor.innerHTML,
text_font_family: document.getElementById('text_font_family').value,
text_font_size: document.getElementById('text_font_size').value,
text_align: document.getElementById('text_align').value,
text_background_color: document.getElementById('text_background_color').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.');
loadTextPreview();
} catch (e2) {
showStatus(false, e2.message);
}
});
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
loadTextPreview();
initBorderFields();
initButtonActionFields();
}
function closeTextDialog() {
if (_textSelectionHandler) {
document.removeEventListener('selectionchange', _textSelectionHandler);
_textSelectionHandler = null;
}
_textSavedRange = null;
}
+160
View File
@@ -0,0 +1,160 @@
// Weather widget dialog: mode/provider/units settings, location (single-
// city modes) or a city list (multi_city mode), and the rendered
// preview. Not a page-load script -- frame_layout.js fetches this
// widget's dialog HTML fragment, injects it into the shared <dialog>,
// points window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initWeatherDialog().
// Only one of "Location" (current/hourly/daily -- one city) or "Cities"
// (multi_city -- a list) is ever relevant at a time; the interval/days
// rows are each specific to one mode too.
function updateWeatherFieldVisibility() {
const mode = document.getElementById('weather_mode').value;
document.getElementById('weather-hourly-interval-row').style.display = mode === 'hourly' ? '' : 'none';
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
}
function addWeatherWidgetCityRow(label) {
const list = document.getElementById('weather-widget-city-list');
const empty = document.getElementById('weather-widget-city-empty');
if (empty) empty.remove();
const li = document.createElement('li');
li.className = 'checkbox-row';
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
const span = document.createElement('span');
span.textContent = label;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary weather-widget-city-remove';
btn.dataset.label = label;
btn.textContent = 'Remove';
btn.addEventListener('click', removeWeatherWidgetCity);
li.appendChild(span);
li.appendChild(btn);
list.appendChild(li);
}
async function removeWeatherWidgetCity(e) {
const label = e.target.dataset.label;
try {
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
if (!resp.ok) throw new Error(await apiError(resp));
e.target.closest('li').remove();
const list = document.getElementById('weather-widget-city-list');
if (!list.querySelector('li')) {
list.innerHTML = '<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>';
}
showStatus(true, `${label} removed.`);
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
}
function loadWeatherPreview(force) {
const suffix = force ? '&force=1' : '';
document.getElementById('weather-preview').src = `${window.FRAME_API}/preview/weather?_=${Date.now()}${suffix}`;
}
function initWeatherDialog() {
document.getElementById('weather_mode').addEventListener('change', updateWeatherFieldVisibility);
updateWeatherFieldVisibility();
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
weather_mode: document.getElementById('weather_mode').value,
weather_provider: document.getElementById('weather_provider').value,
weather_units: document.getElementById('weather_units').value,
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
weather_daily_days: document.getElementById('weather_daily_days').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.');
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('weather-location-set').addEventListener('click', async () => {
const input = document.getElementById('weather-location-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
document.getElementById('weather-location-current').textContent = `Currently: ${data.city.label}`;
input.value = '';
showStatus(true, `Location set to ${data.city.label}.`);
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('weather-location-clear').addEventListener('click', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
document.getElementById('weather-location-current').textContent = 'No location set yet.';
showStatus(true, 'Location cleared.');
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
document.querySelectorAll('.weather-widget-city-remove').forEach((el) => el.addEventListener('click', removeWeatherWidgetCity));
document.getElementById('weather-widget-city-add').addEventListener('click', async () => {
const input = document.getElementById('weather-widget-city-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
addWeatherWidgetCityRow(data.city.label);
input.value = '';
showStatus(true, `Added ${data.city.label}.`);
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
loadWeatherPreview(false);
initBorderFields();
initButtonActionFields();
}
function closeWeatherDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
@@ -96,6 +96,8 @@ function initWhiteboardDialog() {
// whiteboard's `force` param).
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
loadWhiteboardPreview(false);
initBorderFields();
initButtonActionFields();
// --- file picker (Browse...) ---
const browseToggle = document.getElementById('whiteboard-browse-toggle');
@@ -0,0 +1,32 @@
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Border</h2>
<p class="sub">An optional border drawn around this widget's own box --
"None" (the default) draws nothing. Shows on the frame's actual live
view, not in the standalone preview below.</p>
<form id="border-config-form">
<label>Style
<select id="border_style">
{% for style in border_styles %}
<option value="{{ style }}" {% if widget.border_style == style %}selected{% endif %}>{{ border_style_labels[style] }}</option>
{% endfor %}
</select>
</label>
<label>Thickness
<input type="range" id="border_thickness" min="{{ min_border_thickness }}" max="{{ max_border_thickness }}" step="1"
value="{{ widget.border_thickness }}">
<span class="slider-value" id="border_thickness_value">{{ widget.border_thickness }}px</span>
</label>
<label>Color</label>
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
<div class="border-color-picker" id="border-color-picker">
{% for label in border_color_labels %}
<button type="button" class="color-swatch {% if widget.border_color_index == loop.index0 %}selected{% endif %}"
data-index="{{ loop.index0 }}" title="{{ label }}"
style="background-color: {{ current_hex[loop.index0] }};"></button>
{% endfor %}
</div>
<input type="hidden" id="border_color_index" value="{{ widget.border_color_index }}">
<button type="submit">Save</button>
</form>
</section>
@@ -0,0 +1,27 @@
{% if button_action_labels %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Button actions</h2>
<p class="sub">What the frame's physical NEXT/BACK buttons do to this
widget. Every widget on the frame runs its own binding when a
button is pressed -- this only affects this one.</p>
<form id="button-actions-form">
<label>Next button
<select id="next_button_action">
<option value="" {% if not next_button_action %}selected{% endif %}>(none)</option>
{% for action, label in button_action_labels.items() %}
<option value="{{ action }}" {% if next_button_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Back button
<select id="back_button_action">
<option value="" {% if not back_button_action %}selected{% endif %}>(none)</option>
{% for action, label in button_action_labels.items() %}
<option value="{{ action }}" {% if back_button_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
{% endif %}
@@ -0,0 +1,27 @@
<h2 class="dialog-title">Battery widget</h2>
<section class="card">
<h2 class="card-title">Settings</h2>
<p class="sub">Shows this frame's own last-reported battery level --
nothing to configure beyond how much detail to show.</p>
<form id="battery-config-form">
<label>Display mode
<select id="battery_mode">
<option value="compact" {% if battery_cfg and battery_cfg.mode == 'compact' %}selected{% endif %}>Compact (icon + percent only)</option>
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="battery-preview" alt="Battery widget preview">
<button type="button" class="secondary" id="battery-preview-refresh">Refresh now</button>
</section>
@@ -127,6 +127,10 @@
</div>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -41,9 +41,17 @@
</form>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<button type="button" class="secondary" id="lock-photo-btn" style="margin-top: 8px;">Lock this photo</button>
<p class="sub" style="margin-top: 4px;">While locked, this photo stays
on screen -- the refresh timer and the next/back buttons won't
change it until you unlock it.</p>
</section>
<section class="card" style="margin-top: 20px;">
@@ -36,6 +36,10 @@
</form>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -59,6 +59,10 @@
</form>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -0,0 +1,59 @@
<h2 class="dialog-title">Text widget</h2>
<section class="card">
<h2 class="card-title">Text</h2>
<p class="sub">A short block of styled text -- select some and use the
toolbar, or just type. Saved as plain text plus style flags, not raw
HTML.</p>
<div class="richtext-editor" id="text-editor" contenteditable="true"
data-content='{{ (text_cfg.content if text_cfg else none) | tojson }}'></div>
<div class="richtext-toolbar" id="text-toolbar">
<button type="button" class="richtext-btn" id="text-bold" title="Bold" data-cmd="bold"><b>B</b></button>
<button type="button" class="richtext-btn" id="text-italic" title="Italic" data-cmd="italic"><i>I</i></button>
<button type="button" class="richtext-btn" id="text-underline" title="Underline" data-cmd="underline"><u>U</u></button>
<span class="richtext-toolbar-sep"></span>
<label class="richtext-color-label" title="Text color">A<input type="color" id="text-color" value="#000000"></label>
<label class="richtext-color-label" title="Highlight color">&#9635;<input type="color" id="text-highlight" value="#ffff00"></label>
<button type="button" class="richtext-btn" id="text-highlight-clear" title="Remove highlight">&times;</button>
</div>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Settings</h2>
<form id="text-config-form">
<label>Font family
<select id="text_font_family">
{% for value, label in text_font_families.items() %}
<option value="{{ value }}" {% if text_cfg and text_cfg.font_family == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Font size
<input type="range" id="text_font_size" min="10" max="96" step="2"
value="{{ text_cfg.font_size if text_cfg else 28 }}">
<span class="slider-value" id="text_font_size_value">{{ text_cfg.font_size if text_cfg else 28 }}px</span>
</label>
<label>Alignment
<select id="text_align">
{% for value, label in [("left", "Left"), ("center", "Center"), ("right", "Right")] %}
<option value="{{ value }}" {% if text_cfg and text_cfg.align == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Background color
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
</label>
<button type="submit">Save</button>
</form>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="text-preview" alt="Text widget preview">
<button type="button" class="secondary" id="text-preview-refresh">Refresh now</button>
</section>
@@ -0,0 +1,85 @@
<h2 class="dialog-title">Weather widget</h2>
<section class="card">
<h2 class="card-title">Display</h2>
<form id="weather-config-form">
<label>Mode
<select id="weather_mode">
<option value="current" {% if weather_cfg.mode == "current" %}selected{% endif %}>Current conditions</option>
<option value="hourly" {% if weather_cfg.mode == "hourly" %}selected{% endif %}>Hourly forecast</option>
<option value="daily" {% if weather_cfg.mode == "daily" %}selected{% endif %}>Multi-day forecast</option>
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
</select>
</label>
<label>Weather source
<select id="weather_provider">
{% for value, label in weather_provider_labels.items() %}
<option value="{{ value }}" {% if weather_cfg.provider == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations; Environment Canada only covers Canadian locations.</p>
<label>Units
<select id="weather_units">
<option value="fahrenheit" {% if weather_cfg.units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
<option value="celsius" {% if weather_cfg.units == "celsius" %}selected{% endif %}>Celsius</option>
</select>
</label>
<div id="weather-hourly-interval-row">
<label>Hours between ticks
<select id="weather_hourly_interval_hours">
{% for hours in (3, 4, 6, 12) %}
<option value="{{ hours }}" {% if weather_cfg.hourly_interval_hours == hours %}selected{% endif %}>{{ hours }}</option>
{% endfor %}
</select>
</label>
</div>
<div id="weather-daily-days-row">
<label>Days to show
<input type="number" id="weather_daily_days" min="1" max="14" value="{{ weather_cfg.daily_days }}">
</label>
</div>
<button type="submit">Save</button>
</form>
</section>
<section class="card" id="weather-location-section" style="margin-top: 20px;">
<h2 class="card-title">Location</h2>
<p class="sub" id="weather-location-current">
{% if weather_cfg.city_label %}Currently: {{ weather_cfg.city_label }}{% else %}No location set yet.{% endif %}
</p>
<div class="checkbox-row" style="flex-wrap: wrap;">
<input type="text" id="weather-location-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1 1 160px;">
<button type="button" class="btn-inline" id="weather-location-set">Set</button>
<button type="button" class="btn-inline secondary" id="weather-location-clear">Clear</button>
</div>
</section>
<section class="card" id="weather-cities-section" style="margin-top: 20px;">
<h2 class="card-title">Cities</h2>
<ul class="calendar-user-list" id="weather-widget-city-list">
{% for c in weather_cfg.cities or [] %}
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
<span>{{ c.label }}</span>
<button type="button" class="btn-inline secondary weather-widget-city-remove" data-label="{{ c.label }}">Remove</button>
</li>
{% else %}
<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>
{% endfor %}
</ul>
<div class="checkbox-row" style="margin-top: 10px;">
<input type="text" id="weather-widget-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
<button type="button" class="btn-inline" id="weather-widget-city-add">Add</button>
</div>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="weather-preview" alt="Weather preview">
<button type="button" class="secondary" id="weather-preview-refresh">Refresh now</button>
</section>
@@ -50,6 +50,10 @@
{% endif %}
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
+30 -30
View File
@@ -55,37 +55,37 @@
</section>
<section class="card">
<h2 class="card-title">Button assignments</h2>
<p class="sub">What the frame's physical NEXT and BACK buttons do --
assign one or more widget actions to each, in the order they
should run. A button with several actions runs all of them, in
order, then the panel redraws once.</p>
<p class="sub" id="button-assign-empty-hint" style="display: none;">
Add a widget on the Layout tab first -- there's nothing to assign
a button to yet.</p>
<div id="button-assign-groups">
<div class="button-assign-group">
<h3 class="button-assign-label">NEXT button</h3>
<ul class="button-action-list" id="button-actions-next"></ul>
<div class="button-action-add">
<select id="button-add-widget-next"></select>
<select id="button-add-action-next"></select>
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
</div>
</div>
<div class="button-assign-group" style="margin-top: 20px;">
<h3 class="button-assign-label">BACK button</h3>
<ul class="button-action-list" id="button-actions-back"></ul>
<div class="button-action-add">
<select id="button-add-widget-back"></select>
<select id="button-add-action-back"></select>
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
</div>
</div>
</div>
<h2 class="card-title">Hold actions</h2>
<p class="sub">Holding NEXT or BACK past this duration runs a
frame-wide action instead of each widget's normal short-press
binding (set per-widget in that widget's own config dialog).
Not scoped to any widget -- e.g. cycling through your saved
layouts.</p>
<form id="hold-config-form">
<label>Hold duration (seconds)
<input type="number" id="hold_duration_s" min="3" max="10"
value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
</label>
<label>NEXT held action
<select id="next_hold_action">
<option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
{% for action, label in global_action_labels.items() %}
<option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>BACK held action
<select id="back_hold_action">
<option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
{% for action, label in global_action_labels.items() %}
<option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
</div>
<div class="side-col">
+19
View File
@@ -35,6 +35,19 @@
<div id="add-widget-buttons" class="checkbox-row" style="gap: 10px; flex-wrap: wrap;"></div>
<p class="sub" id="add-widget-hint" style="margin-top: 8px;"></p>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Saved layouts</h2>
<p class="sub">Save this whole arrangement -- widgets, their settings, and button
assignments -- under a name, then switch back to it any time. Saved layouts are
yours across every frame you control with a matching orientation, not just this one.</p>
<div class="saved-layout-add">
<input type="text" id="saved-layout-name" placeholder="Layout name" maxlength="60">
<button type="button" id="save-layout-btn" class="secondary">Save current as...</button>
</div>
<ul class="saved-layout-list" id="saved-layout-list" style="margin-top: 14px;"></ul>
<p class="sub" id="saved-layout-empty-hint" style="margin-top: 10px;">No saved layouts yet.</p>
</section>
</div>
</div>
@@ -60,5 +73,11 @@
<script src="/static/widget_dialog_whiteboard.js"></script>
<script src="/static/widget_dialog_tasks.js"></script>
<script src="/static/widget_dialog_static.js"></script>
<script src="/static/widget_dialog_text.js"></script>
<script src="/static/widget_dialog_weather.js"></script>
<script src="/static/widget_dialog_battery.js"></script>
<script src="/static/widget_dialog_border.js"></script>
<script src="/static/widget_dialog_button_actions.js"></script>
<script src="/static/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script>
{% endblock %}
+169
View File
@@ -0,0 +1,169 @@
"""Parses a contenteditable div's serialized innerHTML (widget_dialog_
text.js's POSTed content_html) into a plain, storage-safe run structure
-- list of paragraphs, each a list of {"text", "bold", "italic",
"underline", "color", "bg"} runs -- for the text widget (see
models.TextWidgetConfig, app/widgets/text.py).
This is the sanitization boundary the checklist's stored-XSS note
(CLAUDE.md, another linked user could have set this) is about: raw HTML
never round-trips back into any browser DOM. Only text content and a
small fixed set of style flags survive parsing; every tag, attribute,
and CSS property not explicitly recognized below is simply discarded --
there's no allowlist-of-tags-to-keep-as-HTML step where something could
slip through unescaped, because nothing is ever re-emitted as HTML at
all. The dialog reconstructs its editor from this same run structure via
safe DOM calls (createElement/textContent), never innerHTML."""
from __future__ import annotations
import re
from html.parser import HTMLParser
# Generous ceilings, not exact UX limits -- just stop a direct API call
# (bypassing the dialog's own textarea-ish size) from storing something
# pathologically large. MAX_INPUT_CHARS bounds parse work; MAX_TOTAL_CHARS
# bounds what's actually kept (a widget's on-panel region is a few
# hundred pixels -- there is no legible use for more than a few thousand
# characters of body text there).
MAX_INPUT_CHARS = 200_000
MAX_TOTAL_CHARS = 4_000
_BASE_STYLE = {"bold": False, "italic": False, "underline": False, "color": None, "bg": None}
_BLOCK_TAGS = {"div", "p", "li"}
_VOID_TAGS = {"br"}
_HEX6 = re.compile(r"^#([0-9a-fA-F]{6})$")
_HEX3 = re.compile(r"^#([0-9a-fA-F]{3})$")
_RGB = re.compile(r"^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$")
_STYLE_PROP = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+)")
_BOLD_WEIGHTS = {"bold", "bolder", "600", "700", "800", "900"}
def _normalize_color(value: str) -> str | None:
""""#1a2b3c" / "#abc" / "rgb(26, 43, 60)" -> "#1a2b3c". Anything else
(a CSS named color, "transparent", garbage) -> None, i.e. dropped --
this is the one place an arbitrary style-attribute string could try
to smuggle something through, so it's a strict allowlist match, not
a best-effort parse."""
value = value.strip()
m = _HEX6.match(value)
if m:
return "#" + m.group(1).lower()
m = _HEX3.match(value)
if m:
return "#" + "".join(c * 2 for c in m.group(1)).lower()
m = _RGB.match(value)
if m:
r, g, b = (max(0, min(255, int(x))) for x in m.groups())
return f"#{r:02x}{g:02x}{b:02x}"
return None
class _RichTextParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.paragraphs: list[list[dict]] = [[]]
self._style_stack: list[dict] = [_BASE_STYLE]
self._at_line_start = True
def _break(self, tag: str) -> None:
# Coalesces contenteditable's per-line block wrapping (Chrome
# wraps every line in its own <div> even without a deliberate
# blank line) down to one paragraph break per actual line gap,
# while still letting an explicit <br> when already at a fresh
# line start (Chrome's "<div><br></div>" idiom for a blank line,
# or a genuine double Shift+Enter) add a real blank paragraph.
if not self._at_line_start:
self.paragraphs.append([])
self._at_line_start = True
elif tag == "br":
self.paragraphs.append([])
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in _BLOCK_TAGS or tag in _VOID_TAGS:
self._break(tag)
if tag in _VOID_TAGS:
return
style = dict(self._style_stack[-1])
attrs_dict = {k: v for k, v in attrs if v is not None}
if tag in ("b", "strong"):
style["bold"] = True
elif tag in ("i", "em"):
style["italic"] = True
elif tag == "u":
style["underline"] = True
elif tag == "font":
color = _normalize_color(attrs_dict.get("color", ""))
if color:
style["color"] = color
elif tag == "span":
for prop, val in _STYLE_PROP.findall(attrs_dict.get("style", "")):
prop = prop.strip().lower()
val = val.strip()
if prop == "color":
color = _normalize_color(val)
if color:
style["color"] = color
elif prop == "background-color":
color = _normalize_color(val)
if color:
style["bg"] = color
elif prop == "font-weight" and val.lower() in _BOLD_WEIGHTS:
style["bold"] = True
elif prop == "font-style" and val.lower() == "italic":
style["italic"] = True
elif prop == "text-decoration" and "underline" in val.lower():
style["underline"] = True
# Pushed for every non-void tag, including ones with no
# recognized style effect (script/a/img/...) -- keeps push/pop
# balanced against handle_endtag regardless of tag, without
# needing to track which tags actually pushed something.
self._style_stack.append(style)
def handle_endtag(self, tag: str) -> None:
if tag in _VOID_TAGS:
return
if len(self._style_stack) > 1:
self._style_stack.pop()
def handle_data(self, data: str) -> None:
if not data:
return
style = self._style_stack[-1]
self.paragraphs[-1].append({"text": data, **style})
if data.strip():
self._at_line_start = False
def parse_rich_text(html: str) -> list[list[dict]]:
"""The sanitization entry point -- see module docstring. Always
returns a valid (possibly all-empty) paragraphs structure, never
raises for malformed markup (html.parser tolerates unclosed/
mismatched tags; handle_endtag's length guard tolerates an
over-popped stack)."""
parser = _RichTextParser()
parser.feed(html[:MAX_INPUT_CHARS])
parser.close()
paragraphs = parser.paragraphs
total = 0
truncated: list[list[dict]] = []
for para in paragraphs:
new_para: list[dict] = []
for run in para:
remaining = MAX_TOTAL_CHARS - total
if remaining <= 0:
break
text = run["text"][:remaining]
total += len(text)
new_para.append({**run, "text": text})
truncated.append(new_para)
if total >= MAX_TOTAL_CHARS:
break
return truncated
def has_text(paragraphs: list[list[dict]] | None) -> bool:
if not paragraphs:
return False
return any(run["text"].strip() for para in paragraphs for run in para)

Some files were not shown because too many files have changed in this diff Show More