14 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
80 changed files with 5317 additions and 893 deletions
+13 -9
View File
@@ -4,17 +4,13 @@ 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 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 self-hosted FastAPI server (`server/`) that pulls from Immich, does all
image processing (crop/dither/quantize/pack), and serves a placeable 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 CURRENT TODO
-add more actions for buttons (i.e. change widget/layout) -add more actions for buttons (i.e. change widget/layout)
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults) -Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
-FIX Scan to download
-make a weather widget
-widget border option
-battery life widget -battery life widget
-sharing layouts with linked users -sharing layouts with linked users
-when multiple photo widgets on layout, the "scan to download" should create a share with all the photos on
-a "coming up this week" widget -a "coming up this week" widget
-scan to download for non-immich photos too? -scan to download for non-immich photos too?
-switch button reset action? and on reset dismiss the menu. -switch button reset action? and on reset dismiss the menu.
@@ -41,8 +37,7 @@ Start here, don't re-derive from scratch:
license via `pip show`/package metadata -- including transitive deps, license via `pip show`/package metadata -- including transitive deps,
not just the top-level package -- and present the finding and tradeoff not just the top-level package -- and present the finding and tradeoff
in plain text rather than picking an approach unilaterally (hand-rolling in plain text rather than picking an approach unilaterally (hand-rolling
an alternative, swapping packages, silently accepting an alternative, swapping packages, silently accepting it). This project
a "coming up this week" widget). This project
has knowingly accepted AGPL-3.0-or-later exposure once already has knowingly accepted AGPL-3.0-or-later exposure once already
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate, (`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
explicit call -- not a precedent for skipping the check next time. explicit call -- not a precedent for skipping the check next time.
@@ -53,6 +48,16 @@ Start here, don't re-derive from scratch:
tempted to exclude and why. This repo shipped a token gate once that tempted to exclude and why. This repo shipped a token gate once that
covered `/api/*` but left `/frame/image` -- the actual photo bytes -- covered `/api/*` but left `/frame/image` -- the actual photo bytes --
open; caught immediately in production. 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 ## Working in this repo
@@ -73,8 +78,7 @@ Start here, don't re-derive from scratch:
- **New/changed UI must work at both desktop and mobile widths -- - **New/changed UI must work at both desktop and mobile widths --
screenshot both, don't assume one implies the other.** The layout screenshot both, don't assume one implies the other.** The layout
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
goes off-canvas behind a hamburger below goes off-canvas behind a hamburger below it. A dialog, header
a "coming up this week" widget. A dialog, header
control, or new widget that looks right at a wide viewport can control, or new widget that looks right at a wide viewport can
overflow, overlap the mobile bar, or mis-center at phone widths. overflow, overlap the mobile bar, or mis-center at phone widths.
`run-server`'s driver has a `viewport` command for exactly this `run-server`'s driver has a `viewport` command for exactly this
+182 -32
View File
@@ -1,10 +1,10 @@
# Widget system # Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
arranging icons on an Android home screen. A frame can hold several widgets of the weather/battery), like arranging icons on an Android home screen. A frame
same type (e.g. two photo widgets pointed at different Immich albums side can hold several widgets of the same type (e.g. two photo widgets pointed
by side). at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame` full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
@@ -20,14 +20,32 @@ a button press does.
## Data model ## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type` - `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `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 (grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client `routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order, already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas. 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`, - Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, 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 `ondelete="CASCADE"` -- rather than one wide table with every type's
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
text (paragraphs of styled runs), never raw HTML -- see text (paragraphs of styled runs), never raw HTML -- see
@@ -36,11 +54,27 @@ a button press does.
`PhotoWidgetConfig` `PhotoWidgetConfig`
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
advance/back/queue logic ports across widget instances unchanged. 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 `TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
`CalendarWidgetConfig` (a week-view-only, single-list task list); split `CalendarWidgetConfig` (a week-view-only, single-list task list); split
into its own widget type (migration 17) so a task list can be placed 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 and sized independent of any calendar's view/footprint, then (migration
18) given the same multi-source shape a calendar widget already has. 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 - `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`frame_id`) since a frame can now have more than one independent `frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical calendar/tasks widget, each with its own included set. Identical
@@ -65,8 +99,13 @@ orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`): Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1. size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
Enforced both client-side 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`) (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 and server-side (`routers/api_widgets.py`) -- the client is never trusted
alone. alone.
@@ -75,7 +114,7 @@ alone.
`app/widgets/` is the render/action registry -- one module per `app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`, `widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
`static_image.py`, `text.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`: - `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 an unquantized RGB image exactly `target_w x target_h`, the widget's
@@ -85,9 +124,11 @@ alone.
bad moment doesn't blank the whole panel. bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type - `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard). Empty for tasks, static image, and text -- nothing to for whiteboard and weather -- both throttled external fetches with a
advance/back/force for a passive checklist, a fixed uploaded image, or forced-refetch action). Empty for tasks, static image, text, and
a fixed block of authored text. 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- - `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI. assignment UI.
@@ -109,28 +150,49 @@ for month view to stay legible.
## Button actions ## Button actions
Each physical button (NEXT/BACK) maps to an **ordered list** of Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be every widget on the frame that has one -- **at most one binding per
"photo widget A: advance" *and* "calendar widget B: advance" together, or widget per button** (a widget can't be bound to two different actions on
even a mismatched combination on purpose. On a press, the same button). On a press, `routers/device.py`'s `_run_button_actions`
`routers/device.py`'s `_run_button_actions` runs every assigned action for runs every widget's assigned action for that button (each in its own
that button in order (each in its own `widget_locked` span -- never nested, `widget_locked` span -- never nested, since the underlying per-frame lock
since the underlying per-frame lock isn't reentrant), catching and isn't reentrant), catching and logging any single action's failure
logging any single action's failure without blocking the rest, then without blocking the rest, then re-renders and returns the whole composed
re-renders and returns the whole composed panel once at the end regardless panel once at the end regardless of which actions succeeded. Which
of which actions succeeded. 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 The UI for this lives in each widget's own gear-icon config dialog (the
Configuration tab (`static/frame_config.js`, `GET`/`PUT "Button actions" card, `templates/_widget_button_fields.html` +
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of `static/widget_dialog_button_actions.js`, `POST
the same type would otherwise both just say "Photos" in the assignment /api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
dropdowns; the UI disambiguates using each widget's grid position (e.g. tab, since assigning a widget's next/back behavior is naturally part of
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them configuring that widget. The card only renders for widget types with a
apart by eye on the Layout canvas. 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 A newly-created widget (including the one auto-migrated from a frame's
`mode` on upgrade) gets a sensible default binding reproducing its old old `mode` on upgrade) gets a sensible default binding reproducing its
button behavior -- see `migration.py`'s `_default_button_actions`. 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 ## Per-widget config UI
@@ -190,6 +252,94 @@ The web UI lives in the Layout tab's "Saved layouts" card
saved layouts each with Apply/rename/delete, incompatible ones shown saved layouts each with Apply/rename/delete, incompatible ones shown
greyed-out with a "different orientation" badge rather than hidden. 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) ## Known gaps (Phase 6, not yet done)
The original 8-phase rollout plan's last phase is still open: 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_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_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_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_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 | | `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 successfully reached a configured server, or if the response doesn't
include a valid interval. 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 ### WiFi fast-connect
After a successful home-WiFi connection, the device caches the AP's 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 right away, regardless of the configured refresh interval -- no long hold
needed, since advancing is easily reversible by pressing again. See needed, since advancing is easily reversible by pressing again. See
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature. `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 Normal wakes and reboots never advance the photo on their own -- the
server decides when to advance based on its own clock (see 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 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 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) ## 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 FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
confused for each other. 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 config FRAME_BATTERY_ADC_GPIO
int "Battery voltage-divider ADC GPIO (-1 to disable)" int "Battery voltage-divider ADC GPIO (-1 to disable)"
default -1 default -1
+40 -18
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h" #include "driver/gpio.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h" #include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "wifi_provisioning.h"
#include "back_button.h" #include "back_button.h"
static const char *TAG = "back_button"; 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_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
#define BACK_BUTTON_DEBOUNCE_MS 20 #define BACK_BUTTON_DEBOUNCE_MS 20
#define BACK_BUTTON_DEBOUNCE_CHECKS 3 #define BACK_BUTTON_DEBOUNCE_CHECKS 3
#define BACK_BUTTON_POLL_MS 100
void back_button_init(void) 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); 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 /* 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 * 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 * status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap * cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */ * regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) { bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
return true;
}
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh if (!caused_wake) {
* power-on/reflash) -- fall back to a live, debounced level check so /* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* holding the button down while powering on also works. */ * fresh power-on/reflash) -- fall back to a live, debounced level
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) { * check so holding the button down while powering on also
return false; * works. */
}
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) { 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"); /* Confirmed pressed -- measure how long, same reasoning/pattern as
return true; * 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 #else
void back_button_init(void) {} 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 #endif
+18 -4
View File
@@ -12,9 +12,23 @@
*/ */
void back_button_init(void); 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 * Checks the back-photo button and, if it's pressed at all, blocks
* a couple of short re-checks to reject noise. Same immediate-response * polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
* reasoning as the next-photo button -- no long hold-to-confirm gate. * 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 { typedef struct {
bool reachable; bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */ 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 */ char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
/* Per-frame token the server pushes until this device has /* Per-frame token the server pushes until this device has
* authenticated with it once; empty when absent. Persisted via * 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 = { frame_server_config_t result = {
.reachable = false, .reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S, .refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
}; };
result.firmware_version[0] = '\0'; result.firmware_version[0] = '\0';
result.device_token[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, ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
(int)result.refresh_interval_s); (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, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token)); 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; return n > 0 ? (size_t)n : 0;
} }
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or /* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
* /frame/back to force a move in either direction (FETCH_ADVANCE / * /frame/global-next, or /frame/global-back to force a move/action
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the * (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
* manage button) appends &manage=1, telling the server to bake its * FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
* overlay into this same response instead of returning the bare * manage=true (the manage button) appends &manage=1, telling the server
* content -- see server/app/routers/device.py. Returning non-ESP_OK * 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() * means the panel was never actually refreshed -- epd_display_stream()
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/ * (see epd7in3e.c) refuses to trigger a physical refresh on a short/
* wrong-size stream, so a failure here always leaves the visible screen * 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"; path = "frame/advance";
} else if (action == FETCH_BACK) { } else if (action == FETCH_BACK) {
path = "frame/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]; 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); report_battery(cfg, battery_percent);
frame_server_config_t server_cfg = fetch_frame_config(cfg); 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; 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 /* One-time identity handshake: the server pushes this frame's
* own token until we've authenticated with it once. Persist it * 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 * Which photo-fetch behavior this wake cycle should use -- normally the
* idempotent GET /frame/image (the server decides on its own whether to * idempotent GET /frame/image (the server decides on its own whether to
* advance, based on its configured refresh interval, so a plain * advance, based on its configured refresh interval, so a plain
* wake/reboot never skips a photo just by asking), or POST * wake/reboot never skips a photo just by asking), POST /frame/advance /
* /frame/advance / POST /frame/back to force a move in either direction * POST /frame/back to force a move in either direction (a short press of
* (the next-photo / back-photo buttons). * 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 { typedef enum {
FETCH_NORMAL, FETCH_NORMAL,
FETCH_ADVANCE, FETCH_ADVANCE,
FETCH_BACK, FETCH_BACK,
FETCH_GLOBAL_NEXT,
FETCH_GLOBAL_BACK,
} fetch_action_t; } fetch_action_t;
/** /**
+13 -4
View File
@@ -40,12 +40,21 @@ void app_main(void)
back_button_init(); back_button_init();
combo_button_init(); combo_button_init();
bool next_pressed = next_button_check(); next_button_result_t next_result = next_button_check();
bool back_pressed = back_button_check(); back_button_result_t back_result = back_button_check();
/* Next takes priority over back if somehow both read pressed at once /* Next takes priority over back if somehow both read pressed at once
* (e.g. both held through a power-on) -- an arbitrary but * (e.g. both held through a power-on) -- an arbitrary but
* deterministic tie-break, not expected to matter in practice. */ * deterministic tie-break, not expected to matter in practice. Same
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL; * 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/ /* Soft-resets or clears config + restarts internally for a medium/
* long hold and never returns in those cases -- only returns here * long hold and never returns in those cases -- only returns here
* for "not pressed" (false) or "quick press" (true, show the menu). */ * 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 "driver/gpio.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h" #include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "wifi_provisioning.h"
#include "next_button.h" #include "next_button.h"
static const char *TAG = "next_button"; 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_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
#define NEXT_BUTTON_DEBOUNCE_MS 20 #define NEXT_BUTTON_DEBOUNCE_MS 20
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3 #define NEXT_BUTTON_DEBOUNCE_CHECKS 3
#define NEXT_BUTTON_POLL_MS 100
void next_button_init(void) 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); 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 /* 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 * 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 * status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap * cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */ * regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) { bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
return true;
}
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh if (!caused_wake) {
* power-on/reflash) -- fall back to a live, debounced level check so /* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* holding the button down while powering on also works. */ * fresh power-on/reflash) -- fall back to a live, debounced level
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) { * check so holding the button down while powering on also
return false; * works. */
}
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) { 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"); /* Confirmed pressed (either the wake cause, or debounced during
return true; * 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 #else
void next_button_init(void) {} 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 #endif
+22 -5
View File
@@ -12,10 +12,27 @@
*/ */
void next_button_init(void); 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 * Checks the next-photo button and, if it's pressed at all (either what
* a couple of short re-checks to reject noise. No long hold-to-confirm * caused this wake, per the latched wakeup-status register, or held
* gate -- advancing a photo is low-stakes and should feel immediate, so * through a debounced power-on check), blocks polling its level until
* this returns right away either way. * 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); 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 * 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); 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 * Returns this device's provisioning AP identity: a fixed SSID (from
* Kconfig) and a password that's generated once on first use and persisted * 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 WORKDIR /app
# tzdata/fonts in their own layer, kept separate from the much larger # tzdata/fonts/Node.js all from Debian's own repo in one layer -- no
# Node.js/npm layers below -- see those layers' own comments for why # external curl/gnupg dance needed (see below for why that changed).
# they're split up the way they are. tzdata: python:3.12-slim doesn't # tzdata: python:3.12-slim doesn't include it by default, so the
# include it by default, so the zoneinfo database backing the web UI's # zoneinfo database backing the web UI's "Timezone" setting (used by
# "Timezone" setting (used by "Quiet hours") would have no named zones # "Quiet hours") would have no named zones to resolve without this --
# to resolve without this -- ZoneInfo() would raise for anything other # ZoneInfo() would raise for anything other than "UTC".
# than "UTC". fontconfig/fonts-dejavu-core: whiteboard mode's # fontconfig/fonts-dejavu-core: whiteboard mode's render-service/ (own
# render-service/ (own README there) needs something to render # README there) needs something to render whiteboard text with.
# 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/*
# Node.js: whiteboard frame mode's render-service/ runs as a second # Node.js: whiteboard frame mode's render-service/ runs as a second
# process in this same container rather than a separate compose service # process in this same container rather than a separate compose service
# -- it's a lightweight, stateless, localhost-only sidecar with nothing # -- it's a lightweight, stateless, localhost-only sidecar with nothing
# worth independently scaling or restarting. NodeSource's setup script is # worth independently scaling or restarting. Used to be installed via
# used instead of Debian bookworm's own apt Node package, which is both # NodeSource's setup script (Debian's own nodejs package was too old for
# older than jsdom's minimum (20.19+) and inconsistently available. # jsdom's minimum back when this base image tracked Debian bookworm) --
# curl/gnupg are only needed to add and fetch NodeSource's repo -- purged # switched to Debian's own `nodejs`/`npm` packages after NodeSource's
# again in this same RUN (not a later one; Docker layers are immutable, # deb.nodesource.com started intermittently 403ing on both its setup_*.x
# so removing them in a *different* instruction wouldn't shrink this # scripts *and* its GPG key (a live NodeSource-side S3/CDN issue,
# one's actual pushed size) so their bytes don't end up in the image at # confirmed 2026-07-27 by hitting deb.nodesource.com directly -- some
# all, only nodejs's. # setup_NN.x paths 403, others 200, no consistent pattern, so no
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates gnupg \ # NodeSource-hosted install path could be trusted not to silently break
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ # again). This base image now tracks Debian trixie, whose own `nodejs`
&& apt-get install -y --no-install-recommends nodejs \ # package is 20.19.2 -- inside jsdom 29's stated engines range
&& apt-get purge -y --auto-remove curl gnupg \ # (`^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/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
+11 -9
View File
@@ -121,9 +121,11 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
this response may grow. this response may grow.
- `GET /frame/photo-info` -- location/date overlay text for the manage - `GET /frame/photo-info` -- location/date overlay text for the manage
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`). menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich - `GET /frame/share/{manage_token}` -- creates a 30-minute public Immich
share link and 302s to it; scoped to the photo currently showing or share link covering every photo widget's currently-showing photo on
queued on *this* frame only. *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 - `GET /frame/face-labels` -- up to 4 named faces with 800x480
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser. 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 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. that lookahead; it doesn't add or remove photos from the album.
- Auth in one breath: browsers use sessions (+CSRF), devices use - Auth in one breath: browsers use sessions (+CSRF), devices use
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own per-frame tokens (`?id=` + `?token=`), the manage QR and the
limited token, and `MANAGEMENT_TOKEN` survives only as the migration scan-to-download QR both use the frame's own `manage_token` (device
credential for pre-multi-frame firmware. `/frame/share` stays scoped tokens don't work for either -- neither is ever called by firmware,
to photos this frame is actually showing or has queued, not any both are opened by a phone that has no way to supply `?id=`/`?token=`),
Immich asset ID someone might guess -- a second layer a leaked device and `MANAGEMENT_TOKEN` survives only as the migration credential for
token alone wouldn't bypass. pre-multi-frame firmware.
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events - Calendar frame mode (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-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 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, logical_render_size,
) )
from .weather import weather_category from .weather import weather_category
from .weather_render import draw_weather_row
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"] CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)", 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 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, 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, region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None, 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) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += _draw_weather_row(img, draw, text_x0, y, text_w, 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) 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) day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14 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 y = MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see # Columns are narrow, so only what actually fits gets drawn (see
# _draw_weather_row) -- typically one city, no label (the column # weather_render.draw_weather_row) -- typically one city, no label
# itself makes which day it's for obvious; a city name wouldn't fit # (the column itself makes which day it's for obvious; a city name
# anyway). Never more than that -- this is already the tight view. # wouldn't fit anyway). Never more than that -- this is already
# the tight view.
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, 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) icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb)
row_h = chip_font.size + 10 row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h) max_rows = max(0, (target_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
+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",
}
+12 -1
View File
@@ -24,7 +24,16 @@ GRID_SHORT = 5
# scaling (see calendar_render.py); whiteboard needs enough room to be # 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 # 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 # 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]] = { MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1), "photos": (1, 1),
"calendar": (3, 2), "calendar": (3, 2),
@@ -32,6 +41,8 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"tasks": (2, 2), "tasks": (2, 2),
"static": (1, 1), "static": (1, 1),
"text": (2, 1), "text": (2, 1),
"weather": (2, 2),
"battery": (1, 1),
} }
Rect = tuple[int, int, int, int] # (x, y, w, h) Rect = tuple[int, int, int, int] # (x, y, w, h)
+108
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import io import io
import math
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps 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) 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) 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 # How each orientation maps the logically-composed image onto the native
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the # 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
# crop ratio matches how the frame actually hangs) and rotate into native # 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. # upstream.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6] 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]: def palette_to_hex(palette_rgb: list) -> list[str]:
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced """[(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] 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: def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly """"#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 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() resp.raise_for_status()
return resp.json() return resp.json()
def create_share_link(self, asset_id: str, expires_in_s: int) -> str: def create_share_link(self, asset_ids: list[str], expires_in_s: int) -> str:
"""Creates a public, view-only Immich share link for a single """Creates a public, view-only Immich share link covering one or
asset, expiring expires_in_s seconds from now, and returns its more assets, expiring expires_in_s seconds from now, and returns
public URL. Used by the manage-button overlay's share QR -- its public URL. Used by the manage-button overlay's share QR --
created lazily (only when someone actually scans it), not when created lazily (only when someone actually scans it), not when
the button's pressed, so the expiry clock starts when it's the button's pressed, so the expiry clock starts when it's
actually used.""" actually used."""
@@ -93,7 +93,7 @@ class ImmichClient:
headers=self._headers, headers=self._headers,
json={ json={
"type": "INDIVIDUAL", "type": "INDIVIDUAL",
"assetIds": [asset_id], "assetIds": list(asset_ids),
"expiresAt": expires_at, "expiresAt": expires_at,
"allowUpload": False, "allowUpload": False,
"allowDownload": True, "allowDownload": True,
+161 -25
View File
@@ -24,7 +24,6 @@ from .models import (
BatteryLog, BatteryLog,
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameButtonAction,
FrameTaskList, FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
ServerSettings, ServerSettings,
@@ -32,6 +31,7 @@ from .models import (
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
Widget, Widget,
) )
from .widgets import default_button_actions
logger = logging.getLogger(__name__) 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" "SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
), {"frame_id": row["frame_id"]}).scalar() ), {"frame_id": row["frame_id"]}).scalar()
result = conn.execute(text( result = conn.execute(text(
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) " "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)" "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, ), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
"sort_order": max_sort + 1, "created_at": now}) "sort_order": max_sort + 1, "created_at": now})
new_widget_id = result.lastrowid new_widget_id = result.lastrowid
@@ -624,6 +625,155 @@ def _migration_23(conn) -> None:
)) ))
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 = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -648,6 +798,12 @@ MIGRATIONS = [
(21, _migration_21), (21, _migration_21),
(22, _migration_22), (22, _migration_22),
(23, _migration_23), (23, _migration_23),
(24, _migration_24),
(25, _migration_25),
(26, _migration_26),
(27, _migration_27),
(28, _migration_28),
(29, _migration_29),
] ]
@@ -858,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: 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 """Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type widget system existed to after tasks became their own widget type
@@ -927,7 +1063,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.flush() # assign ids before the FK'd config rows reference them db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id)) db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_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( _maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2 db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
) )
@@ -943,7 +1079,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.add(_calendar_config_from_frame(frame, widget.id)) db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard": elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id)) 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": if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1) _maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
+96 -9
View File
@@ -311,6 +311,23 @@ class Frame(Base):
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0) firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="") 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 (flattened from the old nested FrameStats) --
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0) stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=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) id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) 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) x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer) y: Mapped[int] = mapped_column(Integer)
w: 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. # table needs to match a specific attribute name here.
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[float] = mapped_column(Float, default=time.time) 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"),) __table_args__ = (Index("ix_widgets_frame", "frame_id"),)
@@ -469,6 +500,7 @@ class PhotoWidgetConfig(Base):
queue_cursor: Mapped[int] = mapped_column(Integer, default=0) queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: 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): class CalendarWidgetConfig(Base):
@@ -553,6 +585,41 @@ class WhiteboardWidgetConfig(Base):
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) 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): class TextWidgetConfig(Base):
"""One text widget's authored content + display settings -- another """One text widget's authored content + display settings -- another
no-live-upstream type like StaticWidgetConfig, just parsed rich text no-live-upstream type like StaticWidgetConfig, just parsed rich text
@@ -608,6 +675,22 @@ class StaticWidgetConfig(Base):
display_mode: Mapped[str] = mapped_column(String, default="crop_fill") 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 # widget_type -> its per-type extension table, keyed by widget_id. Used
# by db.widget_locked() to resolve the right config row without importing # by db.widget_locked() to resolve the right config row without importing
# app/widgets/'s heavier render/action registry just for this lookup. # app/widgets/'s heavier render/action registry just for this lookup.
@@ -617,21 +700,24 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"whiteboard": WhiteboardWidgetConfig, "whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig, "tasks": TaskWidgetConfig,
"static": StaticWidgetConfig, "static": StaticWidgetConfig,
"battery": BatteryWidgetConfig,
"text": TextWidgetConfig, "text": TextWidgetConfig,
"weather": WeatherWidgetConfig,
} }
class FrameButtonAction(Base): class FrameButtonAction(Base):
"""One (widget, action) binding for one of a frame's two physical """One (widget, action) binding for one of a frame's two physical
buttons -- e.g. {button: "next", widget_id: <photo widget>, action: buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
"advance"}. A button can have several of these (sort_order gives "advance"}. At most one binding per (widget, button) -- edited from
execution order); on a press, every row for that (frame, button) runs that widget's own config dialog (routers/api_widgets.py's
-- see routers/device.py's frame_advance/frame_back. Deliberately api_widget_config_save), prefilled with a sane default at widget
unconstrained about which widget/action pairs with which button (the creation (app/widgets/default_button_actions). On a press, every
user's own idea for resolving "what does NEXT even mean with several widget's row for that (frame, button) runs -- see routers/device.py's
widgets on screen": let them assign literally anything to either frame_advance/frame_back. sort_order is unused (which widget's action
button, including mismatched combinations, rather than the server runs first never matters: each only touches its own state, and one
guessing a sensible default).""" shared re-render happens after all of them finish) but kept around so
dispatch has a stable, deterministic query order."""
__tablename__ = "frame_button_actions" __tablename__ = "frame_button_actions"
@@ -645,6 +731,7 @@ class FrameButtonAction(Base):
__table_args__ = ( __table_args__ = (
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"), Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
Index("ix_frame_button_actions_widget_button", "widget_id", "button", unique=True),
) )
+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 /api/queue), so without this an open browser tab polling overnight
would silently advance the current photo on raw elapsed time alone, would silently advance the current photo on raw elapsed time alone,
even though the device itself is correctly asleep through the 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} valid_ids = {a["id"] for a in assets}
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids 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 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: if not stale:
return False return False
advance_forced(cfg, assets, frame) advance_forced(cfg, assets, frame)
+25 -94
View File
@@ -22,17 +22,16 @@ import time
import httpx import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import gitea_releases, grid, quiet_hours from .. import gitea_releases, grid, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db from ..db import frame_locked, get_db
from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, FrameButtonAction, Widget from ..models import BatteryLog, Frame, Widget
from ..widgets import WIDGET_TYPES
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
from .device import render_frame_preview_png from .device import render_frame_preview_png
@@ -45,6 +44,11 @@ MAX_REFRESH_INTERVAL_S = 86400
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped") 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: 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 """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), color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None), contrast_boost: float | None = Form(None),
dither_strength: 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), frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -120,7 +127,14 @@ def api_config_save(
_reset_widget_layout_for_new_orientation) -- widget placement is _reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a 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 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: with frame_locked(db, frame.id) as cfg:
if name is not None: if name is not None:
cfg.name = name.strip()[:64] or cfg.name 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)) cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None: if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength)) 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 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -251,95 +271,6 @@ def api_frame_preview(
return Response(content=png, media_type="image/png") 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") @router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute( rows = db.execute(
+30 -17
View File
@@ -62,6 +62,7 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
"static": ("display_mode", "original_filename"), "static": ("display_mode", "original_filename"),
"text": ("content", "font_size", "font_family", "align", "background_color"), "text": ("content", "font_size", "font_family", "align", "background_color"),
"whiteboard": ("user_id", "url"), "whiteboard": ("user_id", "url"),
"battery": ("mode",),
} }
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind) # widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
@@ -236,25 +237,19 @@ def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(ge
return {"status": "deleted"} return {"status": "deleted"}
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply") def apply_layout_to_frame(db: Session, frame: Frame, layout: SavedLayout) -> int:
def api_layout_apply( """Replaces frame's entire widget arrangement with layout's snapshot
layout_id: int, request: Request, -- every current widget (and its own config/sources/button actions,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), all ondelete="CASCADE") is deleted first, same "act unconditionally
): on the server, confirm on the client" posture as
"""Replaces this frame's entire widget arrangement with a saved
layout's -- 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 api_widgets.api_widgets_clear. A source whose owning user account
(or a whiteboard's user_id) no longer exists is silently dropped (or a whiteboard's user_id) no longer exists is silently dropped
rather than left dangling -- config is JSON, not FK-checked, so rather than left dangling -- config is JSON, not FK-checked, so
nothing enforces that at the storage layer.""" nothing enforces that at the storage layer. Shared by api_layout_apply
user = require_user_api(request, db) (explicit user action) and global_actions.cycle_layout (a hold-
layout = _user_owned_layout(db, layout_id, user) triggered global action, see app/global_actions.py) -- caller is
cols, rows = grid.grid_dims(frame.orientation) responsible for checking the grid-size match first. Returns the
if (layout.cols, layout.rows) != (cols, rows): number of widgets applied."""
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
snapshots = db.scalars( snapshots = db.scalars(
select(SavedLayoutWidget) select(SavedLayoutWidget)
.where(SavedLayoutWidget.saved_layout_id == layout.id) .where(SavedLayoutWidget.saved_layout_id == layout.id)
@@ -317,4 +312,22 @@ def api_layout_apply(
sort_order=action.sort_order, created_at=time.time(), sort_order=action.sort_order, created_at=time.time(),
)) ))
db.commit() db.commit()
return {"status": "applied", "widget_count": len(snapshots)} 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}
+308 -7
View File
@@ -26,14 +26,18 @@ from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session 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 ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import ( from ..image_pipeline import (
BORDER_STYLES,
DEFAULT_DISPLAY_MODE, DEFAULT_DISPLAY_MODE,
DEFAULT_STATIC_DISPLAY_MODE, DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES, DISPLAY_MODES,
hex_to_rgb, hex_to_rgb,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
render_preview_png, render_preview_png,
) )
@@ -41,18 +45,21 @@ from ..image_upload import decode_upload
from ..models import ( from ..models import (
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameButtonAction,
FrameCalendar, FrameCalendar,
FrameTaskList, FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
StaticWidgetConfig, StaticWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
TextWidgetConfig, TextWidgetConfig,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS, WIDGET_CONFIG_MODELS,
Widget, Widget,
) )
from ..text_content import has_text, parse_rich_text from ..text_content import has_text, parse_rich_text
from ..widgets import WIDGET_TYPES from ..widgets import WIDGET_TYPES, default_button_actions
from ..widgets import battery as battery_widget
from ..widgets import text as text_widget from ..widgets import text as text_widget
from .common import ( from .common import (
calendar_sources_for_widget, calendar_sources_for_widget,
@@ -60,6 +67,7 @@ from .common import (
get_or_refresh_calendar_events_for_widget, get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget, get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget, get_or_refresh_weather_for_widget,
get_or_refresh_weather_widget_data,
get_or_refresh_whiteboard_for_widget, get_or_refresh_whiteboard_for_widget,
immich_client_for, immich_client_for,
immich_creds, immich_creds,
@@ -79,9 +87,11 @@ CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks 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, 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( def require_widget_view(
@@ -133,12 +143,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view
widgets = db.scalars( widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all() ).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 { return {
"orientation": frame.orientation, "orientation": frame.orientation,
"grid": {"cols": cols, "rows": rows}, "grid": {"cols": cols, "rows": rows},
"widget_types": list(WIDGET_TYPES.keys()), "widget_types": list(WIDGET_TYPES.keys()),
"min_footprint": grid.MIN_FOOTPRINT, "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": { "control": {
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None, "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, "you": frame.controlled_by_user_id == user.id,
@@ -196,6 +215,7 @@ def api_widget_create(
db.add(widget) db.add(widget)
db.flush() db.flush()
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id)) 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() db.commit()
return _widget_dict(widget) return _widget_dict(widget)
@@ -223,6 +243,38 @@ def api_widget_move(
return _widget_dict(widget) 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}") @router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_delete( def api_widget_delete(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
@@ -283,6 +335,14 @@ def api_widget_config_save(
text_font_family: str | None = Form(None), text_font_family: str | None = Form(None),
text_align: str | None = Form(None), text_align: str | None = Form(None),
text_background_color: 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 """Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one convention as the old frame-level api_config_save, now scoped to one
@@ -379,11 +439,93 @@ def api_widget_config_save(
xcfg.background_color = ( xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff" 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: with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} 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 ------------------------------------ # --- Photos: queue/thumbnail/preview ------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue") @router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
@@ -404,6 +546,7 @@ def api_widget_queue(
photo_queue.sync_queue_length(locked_pcfg, assets) photo_queue.sync_queue_length(locked_pcfg, assets)
current_asset_id = locked_pcfg.current_asset_id current_asset_id = locked_pcfg.current_asset_id
queue = list(locked_pcfg.queue) queue = list(locked_pcfg.queue)
locked = locked_pcfg.locked
controller_id = locked_frame.controlled_by_user_id controller_id = locked_frame.controlled_by_user_id
controller = ( controller = (
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username) (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
@@ -416,6 +559,7 @@ def api_widget_queue(
return { return {
"current": entry(current_asset_id) if current_asset_id else None, "current": entry(current_asset_id) if current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in queue], "upcoming": [entry(asset_id) for asset_id in queue],
"locked": locked,
"control": {"controller": controller, "you": controller_id == user.id}, "control": {"controller": controller, "you": controller_id == user.id},
} }
@@ -485,6 +629,26 @@ def api_widget_queue_remove(
return {"status": "removed"} 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}") @router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
def api_widget_thumbnail( def api_widget_thumbnail(
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
@@ -493,8 +657,8 @@ def api_widget_thumbnail(
"""Scoped to what this widget is actually showing/queuing -- a user """Scoped to what this widget is actually showing/queuing -- a user
merely linked to view this frame shouldn't be able to pull thumbnails 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 for arbitrary asset ids in the owner's Immich library, only this
widget's own curated album. Same rule device.frame_share and widget's own curated album. Same rule manage.manage_thumbnail
manage.manage_thumbnail already enforce.""" already enforces."""
frame, widget = frame_widget frame, widget = frame_widget
_require_widget_type(widget, "photos") _require_widget_type(widget, "photos")
pcfg = db.get(PhotoWidgetConfig, widget.id) pcfg = db.get(PhotoWidgetConfig, widget.id)
@@ -833,6 +997,124 @@ def api_widget_weather_city_remove(
return {"status": "saved"} 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 ----------------------------------------- # --- Static image: upload/preview -----------------------------------------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload") @router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
@@ -903,6 +1185,25 @@ def api_widget_preview_text(
return Response(content=png, media_type="image/png") 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 ------------------------------------------ # --- Whiteboard: source/preview ------------------------------------------
class WhiteboardSourceRequest(BaseModel): class WhiteboardSourceRequest(BaseModel):
+73 -1
View File
@@ -31,6 +31,7 @@ from ..models import (
TaskWidgetConfig, TaskWidgetConfig,
User, User,
Widget, Widget,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
) )
@@ -449,7 +450,15 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
exif = asset.get("exifInfo") or {} exif = asset.get("exifInfo") or {}
content["location_lines"] = _format_location(exif) content["location_lines"] = _format_location(exif)
content["taken_at"] = _format_taken_at(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) panel_w, panel_h = logical_render_size(frame.orientation)
face_labels: list[dict] = [] face_labels: list[dict] = []
@@ -566,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
return result 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 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 logging
import time import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request 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 pydantic import BaseModel
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,8 +25,9 @@ from .. import grid, mail, quiet_hours
from ..auth import get_server_settings, require_device from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db from ..db import frame_locked, get_db
from ..firmware import firmware_path from ..firmware import firmware_path
from ..image_pipeline import logical_render_size, render_panel, render_placeholder from ..global_actions import GLOBAL_ACTIONS
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget 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 ..widgets import WIDGET_TYPES
from .common import ( from .common import (
BATTERY_HISTORY_MAX, BATTERY_HISTORY_MAX,
@@ -35,9 +35,6 @@ from .common import (
RECHARGE_JUMP_PCT, RECHARGE_JUMP_PCT,
RECHARGE_LOOKBACK, RECHARGE_LOOKBACK,
build_manage_content, build_manage_content,
immich_client_for,
immich_creds,
photo_widgets_for_frame,
) )
logger = logging.getLogger(__name__) 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, def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
as_png: bool = False) -> bytes: as_png: bool = False) -> bytes:
"""The widget-system compositor: renders every widget on this frame """The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math) and into its own region (see app/grid.py for grid-cell -> pixel math),
hands the results to image_pipeline.render_panel for the single draws that widget's own optional border directly onto its region
shared paste/enhance/overlay/quantize/pack pass. Replaces the old (models.Widget.border_style, a shared per-widget property no
per-mode RENDERERS dict -- a frame can now show several widgets at widget_type module needs to know about) and hands the results to
once instead of exactly one mode owning the whole panel.""" 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( all_widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all() ).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) 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) 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)) regions.append(((px, py, pw, ph), img))
return render_panel( return render_panel(
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb, 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") @router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)): def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its """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 = { response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked), "refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None, "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 # Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte- # 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") 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): class BatteryReport(BaseModel):
percent: int percent: int
@@ -356,34 +405,3 @@ def frame_firmware(frame: Frame = Depends(require_device)):
if not path.exists(): if not path.exists():
raise HTTPException(404, "No firmware uploaded") raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream") 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)
+63 -7
View File
@@ -17,19 +17,27 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import weather
from ..auth import can_view_frame, current_user from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db from ..db import get_db
from ..global_actions import GLOBAL_ACTION_LABELS
from ..image_pipeline import ( from ..image_pipeline import (
BORDER_STYLES,
BORDER_STYLE_LABELS,
DEFAULT_PALETTE_RGB, DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS, DISPLAY_MODE_LABELS,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS, PALETTE_LABELS,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
palette_to_hex, palette_to_hex,
) )
from ..models import ( from ..models import (
BatteryWidgetConfig,
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameButtonAction,
FrameCalendar, FrameCalendar,
FrameTaskList, FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
@@ -38,10 +46,12 @@ from ..models import (
TextWidgetConfig, TextWidgetConfig,
User, User,
UserFrame, UserFrame,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
Widget, Widget,
) )
from ..quiet_hours import ALL_TIMEZONES from ..quiet_hours import ALL_TIMEZONES
from ..widgets import WIDGET_TYPES
from ..widgets import text as text_widget from ..widgets import text as text_widget
from .common import shell_context, widget_of_type from .common import shell_context, widget_of_type
@@ -81,6 +91,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
default_palette_rgb=DEFAULT_PALETTE_RGB, default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex, palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id, photo_widget_id=photo_widget_id,
global_action_labels=GLOBAL_ACTION_LABELS,
) )
@@ -211,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: if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget") 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": if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id) photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", { return templates.TemplateResponse("_widget_dialog_photos.html", {
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg, "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": if widget.widget_type == "calendar":
@@ -226,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), "calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS, "week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS, "calendar_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB, **border_ctx, **button_ctx,
"palette_to_hex": palette_to_hex,
}) })
if widget.widget_type == "tasks": if widget.widget_type == "tasks":
@@ -236,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, "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_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS, "task_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB, **border_ctx, **button_ctx,
"palette_to_hex": palette_to_hex,
}) })
if widget.widget_type == "static": if widget.widget_type == "static":
@@ -245,13 +287,14 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
return templates.TemplateResponse("_widget_dialog_static.html", { return templates.TemplateResponse("_widget_dialog_static.html", {
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg, "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}, "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": if widget.widget_type == "text":
text_cfg = db.get(TextWidgetConfig, widget.id) text_cfg = db.get(TextWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_text.html", { return templates.TemplateResponse("_widget_dialog_text.html", {
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg, "request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
"text_font_families": text_widget.FONT_FAMILIES, "text_font_families": text_widget.FONT_FAMILIES, **border_ctx, **button_ctx,
}) })
if widget.widget_type == "whiteboard": if widget.widget_type == "whiteboard":
@@ -262,7 +305,20 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
return templates.TemplateResponse("_widget_dialog_whiteboard.html", { return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user, "request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg), "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}") raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
+43 -3
View File
@@ -11,7 +11,7 @@ import logging
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request 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 fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@@ -19,8 +19,14 @@ from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours from .. import photo_queue, quiet_hours
from ..db import get_db, widget_locked from ..db import get_db, widget_locked
from ..models import Frame from ..models import Frame, PhotoWidgetConfig
from .common import immich_client_for, list_assets, photo_widget_config_or_404 from .common import (
immich_client_for,
immich_creds,
list_assets,
photo_widget_config_or_404,
photo_widgets_for_frame,
)
logger = logging.getLogger(__name__) 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: except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type) 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). // UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = { const WIDGET_LABELS = {
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks', photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
static: 'Static image', text: 'Text', static: 'Static image', text: 'Text', weather: 'Weather', battery: 'Battery',
}; };
function showStatus(ok, message) { 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() { async function takeControl() {
try { try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
@@ -363,203 +388,3 @@ loadFirmwareCheck();
// cheap either way. // cheap either way.
setInterval(loadFirmwareCheck, 60000); 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; label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
box.appendChild(label); 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'); const settingsBtn = document.createElement('button');
settingsBtn.type = 'button'; settingsBtn.type = 'button';
settingsBtn.className = 'widget-box-settings'; settingsBtn.className = 'widget-box-settings';
@@ -286,11 +294,13 @@ window.addEventListener('resize', () => {
// icon was clicked). // icon was clicked).
const DIALOG_INIT = { const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
battery: initBatteryDialog,
}; };
const DIALOG_CLOSE = { const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
battery: closeBatteryDialog,
}; };
let openDialogWidgetType = null; let openDialogWidgetType = null;
@@ -341,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => {
openDialogWidgetType = null; openDialogWidgetType = null;
window.FRAME_API = window.FRAME_BASE_API; window.FRAME_API = window.FRAME_BASE_API;
document.getElementById('widget-dialog-body').innerHTML = ''; document.getElementById('widget-dialog-body').innerHTML = '';
loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge)
}); });
loadWidgets(); loadWidgets();
+15 -18
View File
@@ -265,22 +265,6 @@ input:focus, select:focus {
box-shadow: 0 0 0 3px var(--focus-ring); 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 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
}
.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; }
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; } .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-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-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
@@ -308,8 +292,6 @@ input:focus, select:focus {
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; } .saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.saved-layout-controls .btn-inline { margin: 0; } .saved-layout-controls .btn-inline { margin: 0; }
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; } .saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
.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; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; } .checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; } .checkbox-row input { width: auto; margin-top: 0; }
@@ -364,6 +346,7 @@ input:focus, select:focus {
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); } .calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
.calendar-row { flex-wrap: wrap; } .calendar-row { flex-wrap: wrap; }
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; } .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 { .color-swatch {
width: 20px; height: 20px; padding: 0; margin: 0; width: 20px; height: 20px; padding: 0; margin: 0;
border: 2px solid var(--border); border-radius: 5px; border: 2px solid var(--border); border-radius: 5px;
@@ -476,6 +459,20 @@ button.secondary:hover { background: var(--surface-alt); }
.widget-box-remove { right: 4px; } .widget-box-remove { right: 4px; }
.widget-box-settings { right: 28px; } .widget-box-settings { right: 28px; }
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); } .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 { .widget-box-resize-handle {
position: absolute; position: absolute;
bottom: 0; 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); document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview(); loadCalendarPreview();
initBorderFields();
initButtonActionFields();
} }
function closeCalendarDialog() { function closeCalendarDialog() {
+36
View File
@@ -8,6 +8,33 @@
// FRAME_API + a global loadQueue()" contract queue.js has always had. // FRAME_API + a global loadQueue()" contract queue.js has always had.
let photosPollTimer = null; 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() { async function loadQueue() {
if (dragState) { if (dragState) {
@@ -21,9 +48,14 @@ async function loadQueue() {
currentEl.innerHTML = currentEl.innerHTML =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>'; '<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
renderUpcoming([]); renderUpcoming([]);
photoHasCurrent = false;
renderLockButton();
return; return;
} }
const data = await resp.json(); const data = await resp.json();
photoLocked = !!data.locked;
photoHasCurrent = !!data.current;
renderLockButton();
currentEl.innerHTML = ''; currentEl.innerHTML = '';
if (data.current) { if (data.current) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
@@ -109,10 +141,14 @@ function initPhotosDialog() {
} }
}); });
document.getElementById('lock-photo-btn').addEventListener('click', toggleLock);
loadQueue(); loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited // Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag. // from elsewhere) without a manual refresh. Skipped mid-drag.
photosPollTimer = setInterval(loadQueue, 10000); photosPollTimer = setInterval(loadQueue, 10000);
initBorderFields();
initButtonActionFields();
} }
function closePhotosDialog() { function closePhotosDialog() {
@@ -55,6 +55,8 @@ function initStaticDialog() {
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview); document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
loadStaticPreview(); loadStaticPreview();
initBorderFields();
initButtonActionFields();
} }
function closeStaticDialog() { function closeStaticDialog() {
+2
View File
@@ -88,6 +88,8 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview); document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview(); loadTasksPreview();
initBorderFields();
initButtonActionFields();
} }
function closeTasksDialog() { function closeTasksDialog() {
+2
View File
@@ -130,6 +130,8 @@ function initTextDialog() {
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview); document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
loadTextPreview(); loadTextPreview();
initBorderFields();
initButtonActionFields();
} }
function closeTextDialog() { function closeTextDialog() {
+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). // whiteboard's `force` param).
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true)); document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
loadWhiteboardPreview(false); loadWhiteboardPreview(false);
initBorderFields();
initButtonActionFields();
// --- file picker (Browse...) --- // --- file picker (Browse...) ---
const browseToggle = document.getElementById('whiteboard-browse-toggle'); 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> </div>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -41,9 +41,17 @@
</form> </form>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Now displaying</h2> <h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div> <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>
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
@@ -36,6 +36,10 @@
</form> </form>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -59,6 +59,10 @@
</form> </form>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -47,6 +47,10 @@
</form> </form>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -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 %} {% endif %}
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
+30 -30
View File
@@ -55,37 +55,37 @@
</section> </section>
<section class="card"> <section class="card">
<h2 class="card-title">Button assignments</h2> <h2 class="card-title">Hold actions</h2>
<p class="sub">What the frame's physical NEXT and BACK buttons do -- <p class="sub">Holding NEXT or BACK past this duration runs a
assign one or more widget actions to each, in the order they frame-wide action instead of each widget's normal short-press
should run. A button with several actions runs all of them, in binding (set per-widget in that widget's own config dialog).
order, then the panel redraws once.</p> Not scoped to any widget -- e.g. cycling through your saved
<p class="sub" id="button-assign-empty-hint" style="display: none;"> layouts.</p>
Add a widget on the Layout tab first -- there's nothing to assign <form id="hold-config-form">
a button to yet.</p> <label>Hold duration (seconds)
<input type="number" id="hold_duration_s" min="3" max="10"
<div id="button-assign-groups"> value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
<div class="button-assign-group"> </label>
<h3 class="button-assign-label">NEXT button</h3> <label>NEXT held action
<ul class="button-action-list" id="button-actions-next"></ul> <select id="next_hold_action">
<div class="button-action-add"> <option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
<select id="button-add-widget-next"></select> {% for action, label in global_action_labels.items() %}
<select id="button-add-action-next"></select> <option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button> {% endfor %}
</div> </select>
</div> </label>
<label>BACK held action
<div class="button-assign-group" style="margin-top: 20px;"> <select id="back_hold_action">
<h3 class="button-assign-label">BACK button</h3> <option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
<ul class="button-action-list" id="button-actions-back"></ul> {% for action, label in global_action_labels.items() %}
<div class="button-action-add"> <option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
<select id="button-add-widget-back"></select> {% endfor %}
<select id="button-add-action-back"></select> </select>
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button> </label>
</div> <button type="submit">Save</button>
</div> </form>
</div>
</section> </section>
</div> </div>
<div class="side-col"> <div class="side-col">
+4
View File
@@ -74,6 +74,10 @@
<script src="/static/widget_dialog_tasks.js"></script> <script src="/static/widget_dialog_tasks.js"></script>
<script src="/static/widget_dialog_static.js"></script> <script src="/static/widget_dialog_static.js"></script>
<script src="/static/widget_dialog_text.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/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script> <script src="/static/saved_layouts.js"></script>
{% endblock %} {% endblock %}
+64
View File
@@ -0,0 +1,64 @@
"""Weather provider registry -- the app/widgets/ "dispatch registry over
pluggable implementations" pattern applied to weather data sources
instead of widget types. Three providers, all free/no API key:
Open-Meteo (app/weather/open_meteo.py, worldwide), NWS (app/weather/
nws.py, US-only), and Environment Canada (app/weather/ec.py, Canada-only,
its own bbox/nearest-site lookup shape rather than simple lat/lon REST --
see that module's own docstring).
geocode_city stays Open-Meteo-backed regardless of which provider is
chosen to actually fetch forecasts -- it's just free-text-name-to-lat/lon
resolution, done once when a location is added, and Open-Meteo's
geocoder covers the whole world where NWS's own data plainly doesn't.
Every provider module exposes the same four functions:
geocode_city(name) -> {"label", "latitude", "longitude"} (open_meteo only, see above)
fetch_current(lat, lon, units) -> {"temp", "category"}
fetch_hourly(lat, lon, units, hours) -> [{"time", "temp", "category"}, ...]
fetch_daily(lat, lon, units, days) -> {"YYYY-MM-DD": {"high", "low", "category"}, ...}
"category" is always one of the shared set (clear/partly_cloudy/cloudy/
fog/rain/snow/thunderstorm) that app/weather_render.py's icon-drawing
knows how to draw -- callers never need to know which provider supplied
an entry.
"""
from __future__ import annotations
class WeatherFetchError(Exception):
"""Geocoding or forecast fetch failed -- network, no match, an
unexpected response shape, or (NWS) a location outside its US
coverage. Raised loudly; callers (the dialog's location-set/preview
endpoints, get_or_refresh_*_for_widget) decide what to do."""
from . import ec, nws, open_meteo # noqa: E402 -- after WeatherFetchError, which all three submodules import
# Re-exported for existing call sites (routers/common.py, routers/
# api_widgets.py, calendar_render.py) -- all Open-Meteo-only and
# untouched by the provider abstraction below.
from .open_meteo import ( # noqa: E402,F401
CHECK_INTERVAL_S,
fetch_daily_forecast,
geocode_city,
weather_category,
)
PROVIDERS = {"open_meteo": open_meteo, "nws": nws, "ec": ec}
PROVIDER_LABELS = {
"open_meteo": "Open-Meteo",
"nws": "National Weather Service (US)",
"ec": "Environment Canada",
}
def fetch_current(provider: str, latitude: float, longitude: float, units: str) -> dict:
return PROVIDERS[provider].fetch_current(latitude, longitude, units)
def fetch_hourly(provider: str, latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
return PROVIDERS[provider].fetch_hourly(latitude, longitude, units, hours)
def fetch_daily(provider: str, latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
return PROVIDERS[provider].fetch_daily(latitude, longitude, units, days)
+227
View File
@@ -0,0 +1,227 @@
"""Environment Canada (ECCC MSC GeoMet OGC API, api.weather.gc.ca)
provider -- Canada-only, free, no API key. Unlike NWS's grid-point
lookup or Open-Meteo's plain lat/lon REST, EC's `citypageweather-realtime`
collection (an OGC API - Features collection, the modern replacement for
the old dd.weatheroffice.gc.ca XML feed -- that host no longer resolves)
is only queryable by bounding box, not a direct by-coordinate endpoint --
this widens the box progressively until it finds at least one site, then
picks the nearest by straight-line distance. This station/bbox-lookup
shape (not simple lat/lon REST) is exactly why EC was documented as a
follow-up rather than shipped alongside Open-Meteo/NWS in the first
pass -- see docs/widgets.md.
EC's numeric icon codes are its own set, distinct from WMO's (Open-
Meteo) or NWS's icon-URL condition codes. _category_from_code_and_text
below only maps the codes actually confirmed against live data (see
this module's own tests, captured from real api.weather.gc.ca
responses), falling back to the same keyword-match-on-condition-text
safety net app/weather/nws.py uses for anything unmapped -- correctness
comes from the text fallback, the numeric table is just a fast path.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py/nws.py.
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta
import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0
BASE_URL = "https://api.weather.gc.ca"
COLLECTION = "citypageweather-realtime"
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
# Progressively wider bounding boxes (degrees) around the target point --
# EC's ~844 sites are dense near cities but sparse in the north, so a
# small box can come back empty even for a real Canadian location. 25
# degrees (~2000-2700 km depending on latitude) is already far beyond
# _MAX_DISTANCE_KM, so there's no point widening past it.
_BBOX_PADDINGS_DEG = (1.0, 3.0, 8.0, 25.0)
# See _nearest_site's own docstring for why this exists and how it was
# calibrated (a real Miami query matched 1824 km away without it).
_MAX_DISTANCE_KM = 300
_ICON_CODE_CATEGORIES = {
0: "clear", 1: "clear", 30: "clear",
2: "partly_cloudy", 5: "partly_cloudy", 31: "partly_cloudy", 32: "partly_cloudy",
3: "cloudy", 4: "cloudy", 10: "cloudy", 33: "cloudy",
6: "rain", 12: "rain", 28: "rain", 36: "rain",
9: "thunderstorm", 19: "thunderstorm", 39: "thunderstorm",
24: "fog",
}
_TEXT_CATEGORY_KEYWORDS = [
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"), ("tornado", "thunderstorm"),
("flurr", "snow"), ("snow", "snow"), ("sleet", "snow"), ("ice pellet", "snow"), ("hail", "snow"),
("freezing", "snow"),
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
("overcast", "cloudy"), ("cloudy", "cloudy"),
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
]
def _category_from_code_and_text(code: int | None, text: str) -> str:
if code is not None and code in _ICON_CODE_CATEGORIES:
return _ICON_CODE_CATEGORIES[code]
lowered = text.lower()
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
if keyword in lowered:
return category
return "cloudy" # same generic-icon fallback Open-Meteo/NWS both use
def _convert_temp(celsius: float, units: str) -> float:
"""EC's citypage feed reports temperatures in Celsius only (its
unitType is always "metric" in this feed) -- convert to the widget's
requested units, no-op if celsius was actually asked for."""
return celsius * 9 / 5 + 32 if units == "fahrenheit" else celsius
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _items(bbox: str) -> list[dict]:
try:
resp = httpx.get(
f"{BASE_URL}/collections/{COLLECTION}/items",
params={"f": "json", "bbox": bbox, "limit": 50},
headers=HEADERS, timeout=HTTP_TIMEOUT_S,
)
resp.raise_for_status()
return resp.json()["features"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def _nearest_site(latitude: float, longitude: float) -> dict:
"""This location's nearest Environment Canada citypage site's
`properties` dict -- widens the bounding box until it finds one
within _MAX_DISTANCE_KM, trying every padding rather than stopping
at the first non-empty box: since each padding's box is a superset
of the previous one's, a wider box's nearest match can only be the
same distance or closer, never farther, so an early non-empty box
whose nearest site is still too far away doesn't mean a closer one
isn't waiting just outside it.
Without the distance cutoff, an unconditional "just take whatever's
nearest" happily matches a US or overseas location to some real EC
site thousands of km away (confirmed live: Miami matched to
Leamington, Ontario, 1824 km off) instead of reporting that EC
simply doesn't cover this location. 300 km is generous enough for a
legitimate rural-Canada query against EC's sparse northern coverage
(~844 sites total) while still correctly rejecting a non-Canadian
one -- a US border city like Seattle, genuinely ~100 km from the
nearest EC site in Victoria, BC, still passes. Deliberately NOT a
single whole-country query instead of progressive widening: that
collection response is ~29 MB for cheap, in-city lookups fetching a
handful of nearby sites."""
best_distance_km = None
for pad in _BBOX_PADDINGS_DEG:
bbox = f"{longitude - pad},{latitude - pad},{longitude + pad},{latitude + pad}"
features = _items(bbox)
if not features:
continue
nearest = min(
features,
key=lambda f: _haversine_km(
latitude, longitude, f["geometry"]["coordinates"][1], f["geometry"]["coordinates"][0]
),
)
best_distance_km = _haversine_km(
latitude, longitude, nearest["geometry"]["coordinates"][1], nearest["geometry"]["coordinates"][0]
)
if best_distance_km <= _MAX_DISTANCE_KM:
return nearest["properties"]
raise WeatherFetchError("No Environment Canada site found near this location (is it in Canada?)")
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
props = _nearest_site(latitude, longitude)
cc = props["currentConditions"]
temp_c = cc["temperature"]["value"]["en"]
code = (cc.get("iconCode") or {}).get("value")
text = (cc.get("condition") or {}).get("en") or ""
return {"temp": _convert_temp(temp_c, units), "category": _category_from_code_and_text(code, text)}
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""EC's hourlyForecastGroup is a fixed 24-hour window (unlike Open-
Meteo/NWS's own longer hourly ranges) -- `hours` just caps how much
of it gets returned, same as the other providers."""
props = _nearest_site(latitude, longitude)
entries = props["hourlyForecastGroup"]["hourlyForecasts"]
result = []
for h in entries[:hours]:
temp_c = h["temperature"]["value"]["en"]
code = (h.get("iconCode") or {}).get("value")
text = (h.get("condition") or {}).get("en") or ""
result.append({
"time": h["timestamp"], "temp": _convert_temp(temp_c, units),
"category": _category_from_code_and_text(code, text),
})
return result
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""Pairs EC's named day/night periods ("Today"/"Tonight"/"Tuesday"/
"Tuesday night"/...) into calendar dates by walking them in issued
order -- unlike NWS's periods (which carry a real startTime), EC's
forecast periods are named relative to "today", not dated, so the
date is inferred: a "night" period shares its preceding day period's
date, any other period starts the calendar day after the previous
one (the forecastGroup's own issued-at timestamp anchors day 0)."""
props = _nearest_site(latitude, longitude)
group = props["forecastGroup"]
issued = datetime.fromisoformat(group["timestamp"]["en"].replace("Z", "+00:00")).date()
by_date: dict[str, dict] = {}
order: list[str] = []
current_day = None
for period in group["forecasts"]:
name = period["period"]["textForecastName"]["en"].strip().lower()
if "night" in name:
day_date = current_day or issued
else:
day_date = issued if current_day is None else current_day + timedelta(days=1)
current_day = day_date
key = day_date.isoformat()
entry = by_date.setdefault(key, {"category": None})
if key not in order:
order.append(key)
temps = period.get("temperatures", {}).get("temperature") or []
if temps:
temp = _convert_temp(temps[0]["value"]["en"], units)
if temps[0]["class"]["en"] == "high":
entry["high"] = temp
else:
entry["low"] = temp
if entry["category"] is None:
icon = (period.get("abbreviatedForecast") or {}).get("icon") or {}
text = (period.get("abbreviatedForecast") or {}).get("textSummary", {}).get("en") or ""
entry["category"] = _category_from_code_and_text(icon.get("value"), text)
result = {}
for key in order[:max(1, days)]:
entry = by_date[key]
if "high" not in entry and "low" not in entry:
continue
result[key] = {
"high": entry.get("high", entry.get("low")),
"low": entry.get("low", entry.get("high")),
"category": entry["category"] or "cloudy",
}
return result
+196
View File
@@ -0,0 +1,196 @@
"""NWS (National Weather Service, api.weather.gov) provider -- US
locations only, free, no API key, but requires a `User-Agent` header
identifying the calling app (NWS API policy: unidentified traffic gets
throttled/blocked). No geocoder of its own; app/weather/__init__.py's
geocode_city (Open-Meteo's) resolves a place name to lat/lon regardless
of which provider is then chosen to fetch with it.
Unlike Open-Meteo's numeric WMO codes, NWS periods carry a `shortForecast`
text description and an `icon` URL encoding a condition code (e.g.
".../icons/land/day/tsra,40?size=medium") -- _category_from_period below
normalizes either into the same shared category set
(clear/partly_cloudy/cloudy/fog/rain/snow/thunderstorm) Open-Meteo's
weather_category() already produces, so app/weather_render.py's build_*
functions never need to know which provider supplied an entry.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py.
"""
from __future__ import annotations
from datetime import datetime
import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0
BASE_URL = "https://api.weather.gov"
# NWS blocks/deprioritizes traffic with no identifying User-Agent -- a
# contact-ish string is their documented convention, not a real account.
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
# NWS's icon condition codes (https://api.weather.gov/icons), grouped into
# the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO
# codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same
# sky condition plus wind -- stripped before lookup, since this project's
# vendored icon set (app/weather_render.py, app/weather_icons/) doesn't
# have a separate windy glyph.
_ICON_CODE_CATEGORIES = {
"skc": "clear", "clear": "clear",
"few": "partly_cloudy", "sct": "partly_cloudy",
"bkn": "cloudy", "ovc": "cloudy",
"fog": "fog", "haze": "fog", "smoke": "fog", "dust": "fog",
"rain": "rain", "rain_showers": "rain", "rain_showers_hi": "rain",
"showers": "rain", "drizzle": "rain",
"snow": "snow", "rain_snow": "snow", "sleet": "snow",
"fzra": "snow", "rain_fzra": "snow", "snow_fzra": "snow",
"tsra": "thunderstorm", "tsra_sct": "thunderstorm", "tsra_hi": "thunderstorm",
}
# Fallback keyword match against shortForecast text, in priority order --
# used when the icon URL can't be parsed at all (unexpected shape) or its
# condition code isn't in the table above (NWS occasionally adds new
# icon variants).
_TEXT_CATEGORY_KEYWORDS = [
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"),
("snow", "snow"), ("sleet", "snow"), ("ice", "snow"),
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
("overcast", "cloudy"), ("cloudy", "cloudy"),
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
]
def _category_from_icon(icon_url: str) -> str | None:
"""The first condition code segment out of an icon URL path like
".../icons/land/day/tsra,40/skc,20?size=medium" -- only the first
(the dominant/nearest-term condition) is used, same "one glyph per
entry" simplicity as Open-Meteo's single-WMO-code-per-day shape."""
path = icon_url.split("?")[0]
segments = [s for s in path.split("/") if s]
for i, seg in enumerate(segments):
if seg in ("day", "night") and i + 1 < len(segments):
code = segments[i + 1].split(",")[0]
code = code.removeprefix("wind_")
return _ICON_CODE_CATEGORIES.get(code)
return None
def _category_from_text(text: str) -> str:
lowered = text.lower()
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
if keyword in lowered:
return category
return "cloudy" # same generic-icon fallback Open-Meteo's weather_category uses
def _category_from_period(period: dict) -> str:
icon = period.get("icon") or ""
category = _category_from_icon(icon) if icon else None
return category or _category_from_text(period.get("shortForecast") or "")
def _convert_temp(value: float, from_unit: str, to_units: str) -> float:
"""NWS periods report temperatureUnit per-period (almost always "F"
for US points) -- converts to the widget's own requested `units`
("fahrenheit"/"celsius") only if they actually differ, so this is a
no-op in the common case."""
to_unit = "F" if to_units == "fahrenheit" else "C"
if from_unit == to_unit:
return value
if from_unit == "F" and to_unit == "C":
return (value - 32) * 5 / 9
return value * 9 / 5 + 32
def _points(latitude: float, longitude: float) -> dict:
try:
resp = httpx.get(
f"{BASE_URL}/points/{latitude:.4f},{longitude:.4f}", headers=HEADERS, timeout=HTTP_TIMEOUT_S
)
resp.raise_for_status()
return resp.json()["properties"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(f"NWS points lookup failed (is this location in the US?): {e}") from e
def _periods(url: str) -> list[dict]:
try:
resp = httpx.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
return resp.json()["properties"]["periods"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
"""{"temp": float, "category": str} -- NWS has no simple by-
coordinate "current conditions" endpoint (that needs a second
stations-list + latest-observation lookup); this approximates
"current" with the first hourly forecast period instead, which is
plenty for a frame that only refreshes every few hours."""
points = _points(latitude, longitude)
periods = _periods(points["forecastHourly"])
if not periods:
raise WeatherFetchError("NWS returned no hourly forecast periods")
period = periods[0]
temp = _convert_temp(period["temperature"], period.get("temperatureUnit", "F"), units)
return {"temp": temp, "category": _category_from_period(period)}
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
one entry per hour -- NWS's forecastHourly is already 1-hour
resolution, same as Open-Meteo's."""
points = _points(latitude, longitude)
periods = _periods(points["forecastHourly"])
return [
{
"time": p["startTime"],
"temp": _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units),
"category": _category_from_period(p),
}
for p in periods[:hours]
]
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
-- NWS's /forecast returns day/night period pairs (12h each, ~7 days
/ 14 periods), not one row per day; this pairs them by calendar date
(a daytime period's high, the following night's low) and clamps to
however many full dates actually came back once `days` is asked for
more than that -- no error, just fewer days, same graceful-
degradation idiom as app/weather_render.py's draw_weather_row."""
points = _points(latitude, longitude)
periods = _periods(points["forecast"])
by_date: dict[str, dict] = {}
order: list[str] = []
for p in periods:
day = datetime.fromisoformat(p["startTime"]).date().isoformat()
entry = by_date.setdefault(day, {"category": None})
if day not in order:
order.append(day)
temp = _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units)
if p["isDaytime"]:
entry["high"] = temp
entry["category"] = _category_from_period(p)
else:
entry["low"] = temp
if entry["category"] is None:
entry["category"] = _category_from_period(p)
result = {}
for day in order[:max(1, days)]:
entry = by_date[day]
if "high" not in entry and "low" not in entry:
continue
result[day] = {
"high": entry.get("high", entry.get("low")),
"low": entry.get("low", entry.get("high")),
"category": entry["category"] or "cloudy",
}
return result
@@ -1,10 +1,10 @@
"""Weather strip for calendar frame mode (agenda/today & tomorrow/week """Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry)
views only -- never month, there's no room, see calendar_render.py's -- free geocoding + forecast APIs, no API key, no signup, no per-request
_BUILDERS). A frame can list multiple cities; each is geocoded once via quota to manage. Backs both the calendar widget's embedded weather strip
Open-Meteo's free geocoding API (no API key, no signup, no per-request (fetch_daily_forecast/weather_category, its original shape, untouched)
quota to manage) when added from the Calendar tab, then its daily and the standalone weather widget's per-mode fetches below (fetch_current/
forecast is refreshed on its own throttle -- same shape idiom as fetch_hourly/fetch_daily, which return already-normalized {"category":
calendar_feed.py's merge-fetch cache. ...} entries from the shared category set instead of a raw WMO code).
Pure functions -- no ORM, no FastAPI Depends -- same testability Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py. philosophy as calendar_feed.py/caldav_client.py.
@@ -14,6 +14,8 @@ from __future__ import annotations
import httpx import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0 HTTP_TIMEOUT_S = 15.0
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast" FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
@@ -36,12 +38,6 @@ _CODE_CATEGORIES = {
} }
class WeatherFetchError(Exception):
"""Geocoding or forecast fetch failed -- network, no match, or an
unexpected response shape. Raised loudly; callers (the Calendar
tab's add-city endpoint, get_or_refresh_weather) decide what to do."""
def weather_category(code: int) -> str: def weather_category(code: int) -> str:
"""Falls back to "cloudy" for any WMO code Open-Meteo might add later """Falls back to "cloudy" for any WMO code Open-Meteo might add later
that isn't in the table above -- an unrecognized code shouldn't drop that isn't in the table above -- an unrecognized code shouldn't drop
@@ -133,3 +129,67 @@ def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[
} }
except (httpx.HTTPError, KeyError) as e: except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e raise WeatherFetchError(str(e)) from e
# --- Standalone weather widget fetches (app/widgets/weather.py) --------
#
# Unlike fetch_daily_forecast above (kept as-is for the calendar widget's
# embedded strip, which does its own weather_category(code) lookup),
# these return already-normalized {"category": ...} entries so
# app/weather_render.py's build_* functions never need to know which
# provider supplied the data (see app/weather/nws.py, which normalizes
# its own icon/text shape to the same category set).
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
"""{"temp": float, "category": str} for right now."""
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"current": "temperature_2m,weathercode",
"temperature_unit": units,
"timezone": "auto",
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
current = resp.json()["current"]
return {"temp": current["temperature_2m"], "category": weather_category(current["weathercode"])}
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
one entry per hour, for the next `hours` hours (Open-Meteo's hourly
data is always 1-hour resolution regardless of how far apart the
ticks a caller actually wants to display are -- see
app/weather_render.py's build_hourly, which samples every Nth
entry)."""
forecast_days = max(1, -(-hours // 24)) # ceil division -- enough days to cover `hours`
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"hourly": "temperature_2m,weathercode",
"temperature_unit": units,
"timezone": "auto",
"forecast_days": forecast_days,
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
hourly = resp.json()["hourly"]
return [
{"time": t, "temp": temp, "category": weather_category(code)}
for t, temp, code in zip(hourly["time"], hourly["temperature_2m"], hourly["weathercode"])
][:hours]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
-- same request as fetch_daily_forecast, just normalized to a
category instead of a raw WMO code, and clamped to `days` (Open-
Meteo's own real max is FORECAST_DAYS)."""
clamped = max(1, min(days, FORECAST_DAYS))
raw = fetch_daily_forecast(latitude, longitude, units)
return {
day: {"high": d["high"], "low": d["low"], "category": weather_category(d["code"])}
for day, d in list(raw.items())[:clamped]
}
+421
View File
@@ -0,0 +1,421 @@
"""Weather icon-drawing primitives (draw_weather_icon/draw_weather_row --
extracted out of calendar_render.py, which still imports draw_weather_row
for its own embedded weather strip, unchanged) plus the standalone
weather widget's four per-mode renderers (build_current/build_hourly/
build_daily/build_multi_city, dispatched by build()) and its preview-PNG
wrapper -- the weather analogue of calendar_render.py's own
_build_tasks/render_tasks_preview_png relationship.
Every build_* function takes already-normalized data (see app/weather/'s
provider modules -- a `category` key from the shared clear/partly_cloudy/
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
returns an RGB Image exactly target_w x target_h, same contract every
other widget renderer in this project follows.
Icons are hand-drawn (no custom font/icon asset, same primitives-only
approach calendar_render.py uses elsewhere for e.g. month view's density
dots), styled after Environment Canada's own icon set (pointed sun rays,
a smooth puffy cloud, teardrop rain, dendrite snowflakes, a zigzag bolt)
but filled with this frame's *exact* panel ink RGB values rather than an
arbitrary bitmap's anti-aliased colors -- a flat fill that's already one
of the palette's 6 colors quantizes with zero dithering error to diffuse,
where a fetched/vendored bitmap's colors (almost never an exact palette
match) dither into a visible speckle. An early plain circle-with-4-ticks
"sun" also just didn't read as a sun at a glance -- pointed triangular
rays fixed that without giving up the clean-quantization property.
"""
from __future__ import annotations
import io
import math
from datetime import date, datetime
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size
MARGIN = 20
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
RULE = (0, 0, 0)
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
already this module's BG/FG) -- same resolution idiom as
calendar_render.py's _event_colors, so a custom palette override
(Frame.palette_rgb) still gets its own actual yellow/blue, and every
fill stays an exact, ditherless palette match either way."""
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
with a clean outline -- drawn as one outline-color pass slightly
larger than the shapes, then the same shapes again in `fill` 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=outline)
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0, y0, x1, y1], fill=fill)
draw.rectangle(base, fill=fill)
def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None:
"""A filled disc + 8 pointed triangular rays -- styled after
Environment Canada's own sun glyph. Rays are solid triangles (base on
the disc's edge, tip pointing outward), not thin lines: at small icon
sizes thin lines read as a crosshair/asterisk, not sun rays, which is
exactly what an earlier attempt here looked like."""
draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
base_r, tip_r, half_w = r * 0.6, r * 1.2, r * 0.16
for i in range(8):
angle = i * (math.pi / 4)
perp = angle + math.pi / 2
bx, by = cx + math.cos(angle) * base_r, cy + math.sin(angle) * base_r
tx, ty = cx + math.cos(angle) * tip_r, cy + math.sin(angle) * tip_r
p1 = (bx + math.cos(perp) * half_w, by + math.sin(perp) * half_w)
p2 = (bx - math.cos(perp) * half_w, by - math.sin(perp) * half_w)
draw.polygon([p1, p2, (tx, ty)], fill=color)
def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None:
"""A rounded teardrop (point up, bulb down) -- the standard rain
glyph, not a bare diagonal tick."""
draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color)
draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color)
def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None:
"""A 6-pointed dendrite -- three crossing lines plus a short
perpendicular tick near each of the 6 tips, closer to a real
snowflake glyph than a bare asterisk."""
for i in range(3):
angle = i * (math.pi / 3)
dx, dy = math.cos(angle) * r, math.sin(angle) * r
draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.28)))
perp = angle + math.pi / 2
tick = r * 0.35
for sign in (1, -1):
tx, ty = x + dx * sign, y + dy * sign
ex, ey = tx * 0.75 + x * 0.25, ty * 0.75 + y * 0.25
draw.line([(ex - math.cos(perp) * tick, ey - math.sin(perp) * tick),
(ex + math.cos(perp) * tick, ey + math.sin(perp) * tick)], fill=color, width=2)
def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None:
"""A zigzag bolt polygon -- the standard lightning glyph, not a bare
3-segment line."""
points = [
(cx + size * 0.15, cy - size * 0.7),
(cx - size * 0.35, cy + size * 0.05),
(cx - size * 0.05, cy + size * 0.05),
(cx - size * 0.2, cy + size * 0.7),
(cx + size * 0.4, cy - size * 0.1),
(cx + size * 0.05, cy - size * 0.1),
]
draw.polygon(points, fill=color)
def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str,
palette_rgb: list | None = None) -> None:
"""A small glyph for one weather category, styled after Environment
Canada's own icon set but hand-drawn in this frame's exact panel ink
colors (yellow sun/bolt, blue rain/snow) -- see module docstring for
why that's better for this display than reusing an actual bitmap."""
yellow = _ink(palette_rgb, 2)
blue = _ink(palette_rgb, 4)
if category == "clear":
draw_sun(draw, cx, cy, r, yellow)
return
if category == "partly_cloudy":
draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow)
draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9)
return
cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25
draw_cloud(draw, cx, cloud_cy, r)
if category == "fog":
for i in range(3):
y = cy + r * 0.55 + i * (r * 0.4)
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
elif category == "rain":
for dx in (-0.55, 0, 0.55):
draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue)
elif category == "snow":
for dx in (-0.55, 0, 0.55):
draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue)
elif category == "thunderstorm":
draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow)
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, palette_rgb: list | None = None) -> 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"], palette_rgb)
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
# --- Standalone weather widget (app/widgets/weather.py) -----------------
def _format_hour_label(iso_time: str) -> str:
dt = datetime.fromisoformat(iso_time)
text = dt.strftime("%I %p").lstrip("0")
return text if text else "12 AM"
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
min_size: int = 9) -> int:
"""Largest size <= max_size at which every string in `texts` fits
within max_width -- used to size a per-column label/temp font against
the actual column width instead of an icon-radius-derived guess,
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
next column at a large icon size on a narrow column."""
for size in range(max_size, min_size - 1, -1):
font = ImageFont.load_default(size=size)
if all(draw.textlength(t, font=font) <= max_width for t in texts):
return size
return min_size
def _day_label(day_date: date) -> str:
delta = (day_date - date.today()).days
if delta == 0:
return "Today"
if delta == 1:
return "Tomorrow"
return day_date.strftime("%a")
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
"""One big icon + big temp number + (optional) city label, centered --
entry is {"temp", "category"} or None if nothing's been fetched yet
(callers normally catch that earlier and show a placeholder instead,
but this degrades to a blank canvas rather than erroring either
way)."""
img = Image.new("RGB", (target_w, target_h), BG)
if not entry:
return img
draw = ImageDraw.Draw(img)
icon_r = max(20, min(target_w, target_h) // 4)
cx, cy = target_w // 2, target_h // 2 - icon_r // 2
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
temp_size = max(24, min(target_w, target_h) // 3)
temp_font = ImageFont.load_default(size=temp_size)
temp_text = f"{round(entry['temp'])}°{unit_suffix}"
bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
temp_y = cy + icon_r + 12
draw_text(img, (target_w // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
if city_label:
label_size = max(12, temp_size // 3)
label_font = ImageFont.load_default(size=label_size)
lbbox = draw.textbbox((0, 0), city_label, font=label_font)
draw_text(img, (target_w // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
city_label, label_font, MUTED)
return img
def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", interval_hours: int = 4, city_label: str = "") -> Image.Image:
"""A row of ticks across the day, one every `interval_hours` hours
(entries is always 1-hour resolution -- see app/weather's provider
fetch_hourly), each showing an hour label, icon, and temp. Same
"draw however many fit" graceful degradation as draw_weather_row if
the box is too narrow for every tick."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
text_x0 = MARGIN
text_w = target_w - MARGIN * 2
y = MARGIN
title_size = max(14, min(target_w, target_h) // 16)
if city_label:
title_font = ImageFont.load_default(size=title_size)
draw_text(img, (text_x0, y), city_label, title_font)
y += title_size + 10
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
# Capped to however many columns actually fit at a legible width
# (narrower widgets/smaller intervals just show fewer ticks) rather
# than cramming every sampled tick in regardless of how narrow that
# makes each one -- same graceful-degradation idiom as
# draw_weather_row's own per-pixel-width stopping point.
min_col_w = 46
max_ticks = max(1, text_w // min_col_w)
ticks = entries[::max(1, interval_hours)][:max_ticks]
if not ticks:
return img
col_w = max(1, text_w // len(ticks))
icon_r = max(10, min(col_w // 3, (target_h - y - MARGIN) // 4))
unit_suffix = "F" if units == "fahrenheit" else "C"
time_labels = [_format_hour_label(e["time"]) for e in ticks]
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
label_font = ImageFont.load_default(size=label_size)
for i, entry in enumerate(ticks):
cx = text_x0 + i * col_w + col_w // 2
time_label = time_labels[i]
tbbox = draw.textbbox((0, 0), time_label, font=label_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
cy = y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
temp_label = temp_labels[i]
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
return img
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
"""A day-by-day strip (day label, icon, high/low), however many days
fit `target_w` (`daily` is already clamped to the widget's own
configured day count by app/weather's provider fetch_daily -- this
just draws whatever it's handed, same "stop once it doesn't fit"
graceful degradation as draw_weather_row)."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
text_x0 = MARGIN
text_w = target_w - MARGIN * 2
y = MARGIN
title_size = max(14, min(target_w, target_h) // 16)
if city_label:
title_font = ImageFont.load_default(size=title_size)
draw_text(img, (text_x0, y), city_label, title_font)
y += title_size + 10
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
days = list(daily.items())
if not days:
return img
col_w = max(1, text_w // len(days))
icon_r = max(12, min(col_w // 3, (target_h - y - MARGIN) // 4))
unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
label_font = ImageFont.load_default(size=label_size)
for i, (_, d) in enumerate(days):
x0 = text_x0 + i * col_w
label, temps = labels[i], temps_strs[i]
lbbox = draw.textbbox((0, 0), label, font=label_font)
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
tbbox = draw.textbbox((0, 0), temps, font=label_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
return img
def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit") -> Image.Image:
"""Several cities' current-day high/low/icon side by side -- directly
reuses draw_weather_row (the same layout calendar_render.py's
embedded strip uses), just as the whole widget's own content instead
of a strip above an agenda day."""
img = Image.new("RGB", (target_w, target_h), BG)
if not cities:
return img
draw = ImageDraw.Draw(img)
# Just the city name on-panel ("Portland", not the full disambiguated
# "Portland, Oregon, United States") -- that fuller form matters for
# telling apart geocoder candidates when adding a city (see
# weather.geocode_city, and the dialog's own "Cities" management
# list), not for a compact display row. Same shortening
# calendar_render.py's _weather_for_day already does for its own
# embedded strip.
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
text_w = target_w - MARGIN * 2
# Sized against how many entries actually need to fit side by side,
# not just the box's height -- an icon/font picked from target_h
# alone (as this used to do) drew each entry so wide that only the
# first city ever fit, and draw_weather_row's own "stop once it
# doesn't fit" degradation silently dropped every city after it,
# even in an ordinary-sized widget with plenty of cities configured.
col_w = max(1, text_w // len(cities))
icon_r = max(10, min(col_w // 6, target_h // 6, 40))
unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
font = ImageFont.load_default(size=font_size)
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
show_labels=True, palette_rgb=palette_rgb)
return img
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", interval_hours: int = 4) -> Image.Image:
"""Dispatches to the right build_* function for this widget's
configured mode -- shared by app/widgets/weather.py's render() and
render_weather_preview_png below, so the two never drift apart."""
if mode == "current":
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
if mode == "hourly":
return build_hourly(data, target_w, target_h, palette_rgb, units, interval_hours, city_label)
if mode == "daily":
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
if mode == "multi_city":
return build_multi_city(data, target_w, target_h, palette_rgb, units)
return Image.new("RGB", (target_w, target_h), BG) # unreachable via a valid config -- see WeatherWidgetConfig.mode
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", manage: dict | None = None,
city_label: str = "", interval_hours: int = 4) -> bytes:
"""Same pipeline as calendar_render.render_tasks_preview_png -- a
normal browser-viewable PNG in logical (upright) orientation."""
target_w, target_h = logical_render_size(orientation)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+32 -1
View File
@@ -36,7 +36,8 @@ Each module in this package exposes:
from __future__ import annotations from __future__ import annotations
from . import calendar, photos, static_image, tasks, text, whiteboard from ..models import FrameButtonAction
from . import battery, calendar, photos, static_image, tasks, text, weather, whiteboard
WIDGET_TYPES = { WIDGET_TYPES = {
"photos": photos, "photos": photos,
@@ -45,4 +46,34 @@ WIDGET_TYPES = {
"tasks": tasks, "tasks": tasks,
"static": static_image, "static": static_image,
"text": text, "text": text,
"weather": weather,
"battery": battery,
} }
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.
Called both when backfilling pre-widget-system frames (see
app/migration.py) and when a widget is newly created (see
routers/api_widgets.py's api_widget_create) -- a widget is never left
without a sane starting binding, so the physical buttons always do
something reasonable for it until someone deliberately reassigns
them in that widget's own config dialog."""
if widget_type == "whiteboard":
# No real "next"/"back" concept for a static board -- both
# buttons mean "check now".
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"),
]
if widget_type == "weather":
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"),
]
if widget_type in ("photos", "calendar"):
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"),
]
return []
+150
View File
@@ -0,0 +1,150 @@
"""Battery widget: shows the frame's own last-reported battery level --
no live upstream to poll, unlike almost every other widget type. The
content is frame-level state that already exists regardless of this
widget (frame.battery_percent/battery_as_of, set by routers/device.py's
frame_battery on every device wake-on-battery report) plus routers.
common.battery_estimate_s's existing recency-weighted "how much longer"
estimate (computed there for the Device panel's own history chart) --
this widget just draws them, it doesn't fetch or compute anything new.
BatteryWidgetConfig only holds a display mode (compact: icon + percent;
detailed: also the estimate + last-report age).
No button actions -- there's nothing to advance/back/force for a number
the device itself pushes on every wake."""
from __future__ import annotations
import io
import time
from PIL import Image, ImageDraw, ImageFont
from sqlalchemy.orm import Session
from ..image_pipeline import DEFAULT_PALETTE_RGB, _quantize, draw_text, logical_render_size
from ..models import BatteryWidgetConfig, Frame, Widget
from ..routers.common import battery_estimate_s
from ._shared import placeholder_image
ACTIONS: dict = {}
ACTION_LABELS: dict[str, str] = {}
BG = (255, 255, 255)
MUTED = (110, 110, 110)
# Same thresholds/colors as manage_overlay.py's own battery glyph (not
# shared code -- that one draws onto the manage-QR overlay in a fixed
# small size, this one fills an arbitrary widget region -- but the
# "how worried should I be" color story should read the same wherever a
# battery glyph shows up on a panel). Exact panel ink RGB values, not
# arbitrary reds/yellows/greens -- a flat fill already at a palette
# color quantizes with zero dithering error once the whole composited
# canvas gets quantized, where an off-palette color would dither into a
# visible speckle at these small on-panel sizes.
_LOW = DEFAULT_PALETTE_RGB[3] # red
_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
_HIGH = DEFAULT_PALETTE_RGB[5] # green
def _fill_color(percent: int) -> tuple[int, int, int]:
if percent <= 15:
return _LOW
if percent <= 40:
return _MEDIUM
return _HIGH
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int) -> None:
stroke = max(2, icon_h // 12)
nub_w = max(3, icon_w // 10)
nub_h = icon_h // 2
x0 = cx - (icon_w + nub_w) // 2
y0 = top
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
if fill_x1 > inner_x0:
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_fill_color(percent))
draw.rectangle([x0, y0, x0 + icon_w, y0 + icon_h], outline=(0, 0, 0), width=stroke)
nub_y = y0 + (icon_h - nub_h) // 2
draw.rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], fill=(0, 0, 0))
def _format_estimate(seconds: float) -> str:
days = seconds / 86400
if days >= 2:
return f"~{days:.0f}d left"
hours = seconds / 3600
if hours >= 20:
return "~1d left"
return f"~{max(1, round(hours))}h left"
def _format_age(as_of: float) -> str:
delta = max(0.0, time.time() - as_of)
if delta < 3600:
return f"{max(1, round(delta / 60))}m ago"
if delta < 86400:
return f"{round(delta / 3600)}h ago"
return f"{round(delta / 86400)}d ago"
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
percent = frame.battery_percent
if percent < 0:
return placeholder_image(target_w, target_h, ["Battery", "No reports yet"])
cfg = db.get(BatteryWidgetConfig, widget.id)
mode = cfg.mode if cfg else "detailed"
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
cx = target_w // 2
icon_h = max(20, min(target_w, target_h) // 3)
icon_w = int(icon_h * 1.8)
icon_top = max(4, target_h // 8)
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent)
pct_font_size = max(18, min(target_w, target_h) // 3)
pct_font = ImageFont.load_default(size=pct_font_size)
pct_text = f"{percent}%"
bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
pct_y = icon_top + icon_h + 10
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font)
if mode == "detailed":
lines = []
estimate_s = battery_estimate_s(frame, db)
if estimate_s is not None:
lines.append(_format_estimate(estimate_s))
if frame.battery_as_of:
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
small_font_size = max(11, pct_font_size // 3)
small_font = ImageFont.load_default(size=small_font_size)
y = pct_y + pct_font_size + 12
for line in lines:
if y + small_font_size > target_h - 4:
break
lbbox = draw.textbbox((0, 0), line, font=small_font)
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font, MUTED)
y += small_font_size + 6
return img
def render_preview_png(db: Session, frame: Frame, widget: Widget, orientation: str,
palette_rgb: list | None) -> bytes:
"""A normal browser-viewable PNG at full logical panel size -- same
"dialog preview always renders at the frame's full size, not the
widget's actual grid box" convention as text.py's render_preview_png."""
target_w, target_h = logical_render_size(orientation)
img = render(db, frame, widget, target_w, target_h)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+2 -2
View File
@@ -55,7 +55,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
def _advance(db: Session, frame: Frame, widget: Widget) -> None: def _advance(db: Session, frame: Frame, widget: Widget) -> None:
cfg = db.get(PhotoWidgetConfig, widget.id) cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id: if not cfg.album_id or cfg.locked:
return return
try: try:
client = immich_client_for(frame) client = immich_client_for(frame)
@@ -68,7 +68,7 @@ def _advance(db: Session, frame: Frame, widget: Widget) -> None:
def _back(db: Session, frame: Frame, widget: Widget) -> None: def _back(db: Session, frame: Frame, widget: Widget) -> None:
cfg = db.get(PhotoWidgetConfig, widget.id) cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id: if not cfg.album_id or cfg.locked:
return return
try: try:
client = immich_client_for(frame) client = immich_client_for(frame)
+40
View File
@@ -0,0 +1,40 @@
"""Weather widget: one of four display modes (see models.
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
py's build() dispatch. No real "next"/"back" concept (same as
whiteboard) -- a single "check now" action forces a re-fetch bypassing
the normal throttle."""
from __future__ import annotations
from PIL import Image
from sqlalchemy.orm import Session
from .. import weather_render
from ..models import Frame, WeatherWidgetConfig, Widget
from ..routers.common import get_or_refresh_weather_widget_data
from ._shared import placeholder_image
ACTION_LABELS = {"check_now": "Check for updates"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused here -- see app/widgets/photos.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
cfg = db.get(WeatherWidgetConfig, widget.id)
data = get_or_refresh_weather_widget_data(db, frame, widget)
if data is None:
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"])
return weather_render.build(
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours,
)
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
get_or_refresh_weather_widget_data(db, frame, widget, force=True)
ACTIONS = {"check_now": _check_now}
+132 -120
View File
@@ -1,11 +1,13 @@
"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API """POST /api/frames/{id}/widgets/{widget_id}/button-actions -- each
(see routers/api_frames.py's api_buttons_get/api_buttons_save and widget's own NEXT/BACK binding editor (see routers/api_widgets.py's
static/frame_config.js). Covers the CRUD/validation layer; multi-action api_widget_button_actions and models.FrameButtonAction), replacing the
execution order and partial-failure-continues on an actual button press old frame-level "Button assignments" card. Covers the CRUD/validation
are already exercised end-to-end in test_device_widget_dispatch.py and layer; multi-widget dispatch and partial-failure-continues on an actual
routers/device.py's _run_button_actions -- this file doesn't re-test button press are already exercised end-to-end in
device.py's dispatch, just that the assignment API stores/serves/ test_device_widget_dispatch.py and routers/device.py's
validates what the UI edits.""" _run_button_actions -- this file doesn't re-test device.py's dispatch,
just that each widget's own binding endpoint stores/validates what its
dialog edits."""
from __future__ import annotations from __future__ import annotations
@@ -16,6 +18,19 @@ from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig
from .conftest import csrf_headers, link_user, login, make_user from .conftest import csrf_headers, link_user, login, make_user
def _photo_widget_id(db_session) -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one().id
def _shrink_default_widget(client, db_session, w=4, h=5) -> int:
"""Frees up the right-hand side of the grid for a second widget."""
widget_id = _photo_widget_id(db_session)
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
json={"x": 0, "y": 0, "w": w, "h": h}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
return widget_id
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget: def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time()) sort_order=1, created_at=time.time())
@@ -26,153 +41,150 @@ def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
return widget return widget
def test_get_buttons_reflects_the_default_migration_mapping(client, db_session): def test_new_widget_gets_the_sane_default_binding(client, db_session):
"""Frame #1's auto-migrated photos widget should already have NEXT -> """A brand new photos widget (via the API, not the migration
advance, BACK -> back from _default_button_actions (see backfill) should already have NEXT -> advance, BACK -> back from
migration.py) -- the UI just needs to be able to see that default.""" widgets.default_button_actions -- no separate save required."""
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1) _shrink_default_widget(client, db_session)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() resp = client.post("/api/frames/1/widgets", json={"widget_type": "calendar"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget_id = resp.json()["id"]
resp = client.get("/api/frames/1/buttons") rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
assert resp.status_code == 200 assert {r.button: r.action for r in rows} == {"next": "advance", "back": "back"}
data = resp.json()
assert {w["id"]: w["widget_type"] for w in data["widgets"]} == {photo_widget.id: "photos"}
photo_actions = {a["action"] for w in data["widgets"] for a in w["actions"]}
assert photo_actions == {"advance", "back"}
assert data["next"] == [{"id": data["next"][0]["id"], "widget_id": photo_widget.id, "action": "advance"}]
assert data["back"] == [{"id": data["back"][0]["id"], "widget_id": photo_widget.id, "action": "back"}]
def test_get_buttons_includes_placement_and_grid_dims(client, db_session): def test_new_whiteboard_widget_gets_check_now_on_both_buttons(client, db_session):
"""Two widgets of the same type otherwise look identical in the
assignment UI ("Photos" / "Photos") -- the client tells them apart
using x/y/w/h against the frame's grid dims (see
static/frame_config.js's buildWidgetNames), so the API needs to
actually hand those over."""
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1) _shrink_default_widget(client, db_session)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h = 0, 0, 4, 5 headers=csrf_headers(client))
db_session.commit() assert resp.status_code == 200, resp.text
second = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5, widget_id = resp.json()["id"]
sort_order=1, created_at=time.time())
db_session.add(second)
db_session.flush()
from app.models import PhotoWidgetConfig
db_session.add(PhotoWidgetConfig(widget_id=second.id))
db_session.commit()
resp = client.get("/api/frames/1/buttons") rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
assert resp.status_code == 200 assert {r.button: r.action for r in rows} == {"next": "check_now", "back": "check_now"}
data = resp.json()
assert data["grid"] == {"cols": 8, "rows": 5}
by_id = {w["id"]: w for w in data["widgets"]}
assert by_id[photo_widget.id]["x"] == 0 and by_id[photo_widget.id]["w"] == 4
assert by_id[second.id]["x"] == 4 and by_id[second.id]["w"] == 4
def test_put_replaces_the_whole_list_in_order(client, db_session): def test_new_tasks_widget_gets_no_default_bindings(client, db_session):
"""tasks has an empty ACTIONS -- nothing sane to default to."""
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1) _shrink_default_widget(client, db_session)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() resp = client.post("/api/frames/1/widgets", json={"widget_type": "tasks"},
board_widget = _add_whiteboard_widget(db_session, frame) headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget_id = resp.json()["id"]
resp = client.put("/api/frames/1/buttons/next", json={ assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).count() == 0
"actions": [
{"widget_id": board_widget.id, "action": "check_now"},
{"widget_id": photo_widget.id, "action": "advance"}, def test_save_updates_an_existing_binding(client, db_session):
], client.post("/setup", data={"username": "alice", "password": "hunter22"})
}, headers=csrf_headers(client)) widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "back", "back_button_action": "advance"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
rows = db_session.query(FrameButtonAction).filter_by( rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
frame_id=frame.id, button="next" assert rows == {"next": "back", "back": "advance"}
).order_by(FrameButtonAction.sort_order).all()
assert [(r.widget_id, r.action) for r in rows] == [
(board_widget.id, "check_now"), (photo_widget.id, "advance"),
]
# BACK's own default mapping (photos -> back) is untouched by a PUT to next.
back_rows = db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="back").all()
assert len(back_rows) == 1
assert back_rows[0].action == "back"
def test_put_empty_list_clears_the_button(client, db_session): def test_save_empty_string_clears_the_binding(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "", "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
assert rows == {"back": "back"}
def test_save_rejects_action_the_widget_type_does_not_support(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "check_now", "back_button_action": ""},
headers=csrf_headers(client))
assert resp.status_code == 400
# Nothing partially applied -- the old default binding is untouched.
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance"
def test_save_is_one_binding_per_widget_per_button(client, db_session):
"""Saving twice for the same button updates the one row in place,
it never accumulates a second row (matches the new unique index on
(widget_id, button))."""
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
widget_id = _photo_widget_id(db_session)
resp = client.put("/api/frames/1/buttons/next", json={"actions": []}, headers=csrf_headers(client)) for action in ("advance", "back", "advance"):
assert resp.status_code == 200, resp.text resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0 json={"next_button_action": action, "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").count() == 1
def test_put_rejects_unknown_button_name(client, db_session): def test_save_404s_for_unknown_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.put("/api/frames/1/buttons/sideways", json={"actions": []}, headers=csrf_headers(client)) resp = client.post("/api/frames/1/widgets/999999/button-actions",
json={"next_button_action": "advance", "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 404 assert resp.status_code == 404
def test_put_rejects_widget_from_another_frame(client, db_session): def test_save_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time()) make_user(db_session, "mallory")
db_session.add(other) widget_id = _photo_widget_id(db_session)
db_session.flush()
other_widget = Widget(frame_id=other.id, widget_type="photos", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time())
db_session.add(other_widget)
db_session.commit()
resp = client.put("/api/frames/1/buttons/next", json={ client.cookies.clear()
"actions": [{"widget_id": other_widget.id, "action": "advance"}], login(client, "mallory")
}, headers=csrf_headers(client)) resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
assert resp.status_code == 400 json={"next_button_action": "back", "back_button_action": "advance"},
# Nothing partially applied -- the whole request is validated before any write. headers=csrf_headers(client))
assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1 assert resp.status_code == 404
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance"
def test_put_rejects_action_the_widget_type_does_not_support(client, db_session): def test_save_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [{"widget_id": photo_widget.id, "action": "check_now"}],
}, headers=csrf_headers(client))
assert resp.status_code == 400
def test_deleting_a_widget_cascades_its_button_bindings(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.delete(f"/api/frames/1/widgets/{photo_widget.id}", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id).count() == 0
def test_linked_user_can_view_and_control_can_save(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob") bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame) link_user(db_session, bob, frame)
widget_id = _photo_widget_id(db_session)
client.cookies.clear() client.cookies.clear()
login(client, "bob") login(client, "bob")
resp = client.get("/api/frames/1/buttons") resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
assert resp.status_code == 200 json={"next_button_action": "back", "back_button_action": "advance"},
headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
def test_unrelated_user_cannot_view(client, db_session): def test_two_widgets_of_the_same_type_have_independent_bindings(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory") photo_widget_id = _photo_widget_id(db_session)
frame = db_session.get(Frame, 1)
board = _add_whiteboard_widget(db_session, frame)
client.cookies.clear() resp = client.post(f"/api/frames/1/widgets/{board.id}/button-actions",
login(client, "mallory") json={"next_button_action": "check_now", "back_button_action": ""},
resp = client.get("/api/frames/1/buttons") headers=csrf_headers(client))
assert resp.status_code == 404 assert resp.status_code == 200, resp.text
photo_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=photo_widget_id)}
board_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=board.id)}
assert photo_rows == {"next": "advance", "back": "back"}
assert board_rows == {"next": "check_now"}
+3 -34
View File
@@ -1,7 +1,7 @@
"""End-to-end HTTP tests for the widget-system cutover in """End-to-end HTTP tests for the widget-system cutover in
routers/device.py -- /frame/image, /frame/advance, /frame/back, and routers/device.py -- /frame/image, /frame/advance, and /frame/back
/frame/share against real widget rows (via the migration-backfilled against real widget rows (via the migration-backfilled frame #1, or a
frame #1, or a purpose-built second frame), a real TestClient, real purpose-built second frame), a real TestClient, real
render_panel/compose_into. Only Immich itself is mocked (monkeypatched render_panel/compose_into. Only Immich itself is mocked (monkeypatched
at the app.widgets.photos module boundary, same pattern as at the app.widgets.photos module boundary, same pattern as
test_widgets_photos.py) -- everything else in the pipeline is real. test_widgets_photos.py) -- everything else in the pipeline is real.
@@ -23,7 +23,6 @@ from app.models import (
Frame, Frame,
FrameButtonAction, FrameButtonAction,
PhotoWidgetConfig, PhotoWidgetConfig,
User,
Widget, Widget,
) )
@@ -145,33 +144,3 @@ def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_s
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id) photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
assert cal_cfg.browse_offset == 1 assert cal_cfg.browse_offset == 1
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
def test_frame_share_checks_widget_scoped_state(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.owner_user_id = db_session.query(User).filter_by(username="alice").one().id
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
cfg.current_asset_id = "asset-1"
cfg.queue = ["asset-2"]
frame.immich_url = "http://immich.example.com"
frame.immich_api_key = "key"
db_session.commit()
# Not showing/queued -- rejected before ever touching Immich
resp = client.get("/frame/share/asset-not-on-this-frame")
assert resp.status_code == 404
# Currently showing -- allowed through to the (mocked) Immich call
monkeypatch.setattr(
"app.routers.device.immich_client_for",
lambda frame: type("C", (), {"create_share_link": lambda self, asset_id, expires_in_s: "https://immich.example.com/share/abc"})(),
)
resp = client.get("/frame/share/asset-1", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
# Queued (not current) -- also allowed
resp = client.get("/frame/share/asset-2", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
+294
View File
@@ -0,0 +1,294 @@
"""Hold-for-global-action (see app/global_actions.py): the
Configuration-tab save path (hold_duration_ms/next_hold_action/
back_hold_action, folded into api_frames.py's api_config_save), the
device-facing dispatch (/frame/global-next, /frame/global-back in
routers/device.py), and the three registry actions themselves
(cycle_layout, refresh_all_widgets, toggle_all_photo_locks)."""
from __future__ import annotations
import time
from app import global_actions, widgets
from app.models import (
CalendarWidgetConfig,
Frame,
PhotoWidgetConfig,
SavedLayout,
Widget,
WhiteboardWidgetConfig,
)
from .conftest import csrf_headers, link_user, login, make_user
EXPECTED_BYTES = 800 * 480 // 2
# --- Configuration-tab save (api_frames.py's api_config_save) --------------
def test_save_hold_duration_clamps_to_range(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/config", data={"hold_duration_ms": "999"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).hold_duration_ms == 3000 # MIN_HOLD_DURATION_MS
resp = client.post("/api/frames/1/config", data={"hold_duration_ms": "999999"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).hold_duration_ms == 10000 # MAX_HOLD_DURATION_MS
def test_save_sets_and_clears_hold_actions(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post(
"/api/frames/1/config",
data={"next_hold_action": "cycle_layout", "back_hold_action": "refresh_all_widgets"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.next_hold_action == "cycle_layout"
assert frame.back_hold_action == "refresh_all_widgets"
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "", "back_hold_action": ""},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
db_session.refresh(frame)
assert frame.next_hold_action is None
assert frame.back_hold_action is None
def test_save_unknown_action_clears_to_none(client, db_session):
"""Not a 400 -- same silent-normalize posture as this endpoint's
other enum-ish fields (orientation, timezone)."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "not_a_real_action"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).next_hold_action is None
def test_save_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
client.cookies.clear()
login(client, "bob")
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "cycle_layout"}, headers=csrf_headers(client)
)
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
def test_save_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
client.cookies.clear()
login(client, "mallory")
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "cycle_layout"}, headers=csrf_headers(client)
)
assert resp.status_code == 404
assert db_session.get(Frame, 1).next_hold_action is None
def test_save_logged_out_401s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
client.cookies.clear()
resp = client.post("/api/frames/1/config", data={"next_hold_action": "cycle_layout"})
assert resp.status_code == 401
# --- Device dispatch (/frame/global-next, /frame/global-back) --------------
def test_global_next_is_a_noop_when_unset(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
def test_global_next_runs_the_configured_action(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.next_hold_action = "toggle_all_photo_locks"
db_session.commit()
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
def test_global_back_runs_the_configured_action(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.back_hold_action = "toggle_all_photo_locks"
db_session.commit()
resp = client.post("/frame/global-back")
assert resp.status_code == 200
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
def test_global_next_with_an_unrecognized_stored_action_is_a_noop(client, db_session):
"""Defensive -- a value that stopped being a valid registry key
(e.g. after a downgrade) shouldn't 500 the whole request."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.next_hold_action = "no_longer_exists"
db_session.commit()
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
# --- Registry: toggle_all_photo_locks ---------------------------------------
def test_toggle_all_photo_locks_locks_then_unlocks(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
global_actions.toggle_all_photo_locks(db_session, frame)
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
global_actions.toggle_all_photo_locks(db_session, frame)
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is False
def test_toggle_all_photo_locks_noop_with_no_photo_widgets(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
db_session.delete(widget)
db_session.commit()
global_actions.toggle_all_photo_locks(db_session, frame) # should not raise
# --- Registry: refresh_all_widgets ------------------------------------------
def test_refresh_all_widgets_only_calls_check_now_capable_types(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
board_widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=1, h=1,
sort_order=1, created_at=time.time())
db_session.add(board_widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=board_widget.id))
db_session.commit()
calls = []
monkeypatch.setattr(
widgets.whiteboard, "get_or_refresh_whiteboard_for_widget",
lambda db, frame, widget, force=False: calls.append((widget.id, force)),
)
global_actions.refresh_all_widgets(db_session, frame)
# Only the whiteboard widget has a check_now action -- the photos
# widget (no check_now in its ACTIONS) is left untouched.
assert calls == [(board_widget.id, True)]
assert photo_widget.id not in [c[0] for c in calls]
def test_refresh_all_widgets_one_failure_does_not_block_others(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
board_a = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=1, h=1,
sort_order=1, created_at=time.time())
board_b = Widget(frame_id=frame.id, widget_type="whiteboard", x=1, y=0, w=1, h=1,
sort_order=2, created_at=time.time())
db_session.add_all([board_a, board_b])
db_session.flush()
db_session.add_all([WhiteboardWidgetConfig(widget_id=board_a.id), WhiteboardWidgetConfig(widget_id=board_b.id)])
db_session.commit()
calls = []
def _flaky(db, frame, widget, force=False):
if widget.id == board_a.id:
raise RuntimeError("simulated fetch failure")
calls.append(widget.id)
monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget", _flaky)
global_actions.refresh_all_widgets(db_session, frame) # should not raise
assert calls == [board_b.id]
# --- Registry: cycle_layout --------------------------------------------------
def test_cycle_layout_noop_when_frame_unclaimed(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.owner_user_id = None
db_session.commit()
global_actions.cycle_layout(db_session, frame) # should not raise
assert db_session.query(Widget).filter_by(frame_id=frame.id).count() == 1 # untouched
def test_cycle_layout_noop_when_no_compatible_layouts(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
global_actions.cycle_layout(db_session, frame) # no saved layouts at all yet
assert db_session.query(Widget).filter_by(frame_id=frame.id).count() == 1
def test_cycle_layout_applies_and_wraps_around(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
resp_a = client.post("/api/frames/1/layouts", json={"name": "Layout A"}, headers=csrf_headers(client))
assert resp_a.status_code == 200, resp_a.text
layout_a_id = resp_a.json()["id"]
# A second, differently-shaped widget arrangement to save as "Layout B".
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
photo_widget.w = 4
cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=4, y=0, w=4, h=5,
sort_order=1, created_at=time.time())
db_session.add(cal_widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=cal_widget.id))
db_session.commit()
resp_b = client.post("/api/frames/1/layouts", json={"name": "Layout B"}, headers=csrf_headers(client))
assert resp_b.status_code == 200, resp_b.text
layout_b_id = resp_b.json()["id"]
# A layout with the wrong grid dims must never be selected.
incompatible = SavedLayout(user_id=frame.owner_user_id, name="Wrong size", cols=1, rows=1,
created_at=time.time(), updated_at=time.time())
db_session.add(incompatible)
db_session.commit()
global_actions.cycle_layout(db_session, frame)
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_a_id
global_actions.cycle_layout(db_session, frame)
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_b_id
global_actions.cycle_layout(db_session, frame) # wraps back to the first
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_a_id
+103
View File
@@ -119,3 +119,106 @@ def test_manage_thumbnail_scoped_to_showing_or_queued(client, db_session, monkey
def test_unknown_manage_token_404s(client, db_session): def test_unknown_manage_token_404s(client, db_session):
resp = client.get("/api/m/not-a-real-token/queue") resp = client.get("/api/m/not-a-real-token/queue")
assert resp.status_code == 404 assert resp.status_code == 404
def test_manage_share_requires_current_photo(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, _ = _configure_photo_widget(db_session)
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
assert resp.status_code == 404
def test_manage_share_creates_link_and_redirects(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, widget = _configure_photo_widget(db_session)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.current_asset_id = "asset-1"
db_session.commit()
calls = []
monkeypatch.setattr(
"app.routers.manage.immich_client_for",
lambda frame: type("C", (), {
"create_share_link": lambda self, asset_ids, expires_in_s: (
calls.append(asset_ids) or "https://immich.example.com/share/abc"
),
})(),
)
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
assert calls == [["asset-1"]]
def test_manage_share_covers_every_photo_widget(client, db_session, monkeypatch):
from app.models import Widget
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, widget = _configure_photo_widget(db_session)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.current_asset_id = "asset-1"
second_widget = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
sort_order=1, created_at=0)
db_session.add(second_widget)
db_session.flush()
db_session.add(PhotoWidgetConfig(widget_id=second_widget.id, album_id="album-2", current_asset_id="asset-9"))
db_session.commit()
calls = []
monkeypatch.setattr(
"app.routers.manage.immich_client_for",
lambda frame: type("C", (), {
"create_share_link": lambda self, asset_ids, expires_in_s: (
calls.append(asset_ids) or "https://immich.example.com/share/abc"
),
})(),
)
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
assert calls == [["asset-1", "asset-9"]]
def test_manage_share_scoped_to_its_own_frame(client, db_session, monkeypatch):
"""The regression test for the original bug: each frame's own share
QR must only ever surface that frame's own current photo, never
another frame's -- previously it silently resolved via whichever
frame happened to still carry the legacy migration token, regardless
of which frame's QR was actually scanned."""
from app.models import Widget
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame_a, widget_a = _configure_photo_widget(db_session)
cfg_a = db_session.get(PhotoWidgetConfig, widget_a.id)
cfg_a.current_asset_id = "asset-a"
frame_b = Frame(
name="Frame B", device_id="112233445566", device_token="devtok-b",
manage_token="mtok-b", orientation="landscape", created_at=0,
immich_url="http://immich.example.com", immich_api_key="key",
)
db_session.add(frame_b)
db_session.flush()
widget_b = Widget(frame_id=frame_b.id, widget_type="photos", x=0, y=0, w=4, h=5,
sort_order=0, created_at=0)
db_session.add(widget_b)
db_session.flush()
db_session.add(PhotoWidgetConfig(widget_id=widget_b.id, album_id="album-b", current_asset_id="asset-b"))
db_session.commit()
calls = []
monkeypatch.setattr(
"app.routers.manage.immich_client_for",
lambda frame: type("C", (), {
"create_share_link": lambda self, asset_ids, expires_in_s: (
calls.append(asset_ids) or "https://immich.example.com/share/abc"
),
})(),
)
client.get(f"/frame/share/{frame_a.manage_token}", follow_redirects=False)
client.get(f"/frame/share/{frame_b.manage_token}", follow_redirects=False)
assert calls == [["asset-a"], ["asset-b"]]
def test_unknown_manage_share_token_404s(client, db_session):
resp = client.get("/frame/share/not-a-real-token")
assert resp.status_code == 404
+100 -20
View File
@@ -79,6 +79,19 @@ def test_expected_columns_exist_on_current_schema():
assert "text_widget_configs" in inspector.get_table_names() # migration 21 assert "text_widget_configs" in inspector.get_table_names() # migration 21
text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")} text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")}
assert "font_family" in text_widget_columns # migration 22 assert "font_family" in text_widget_columns # migration 22
assert "weather_widget_configs" in inspector.get_table_names() # migration 24
weather_widget_columns = {c["name"] for c in inspector.get_columns("weather_widget_configs")}
assert {"mode", "provider", "city_latitude", "cities"} <= weather_widget_columns
assert "battery_widget_configs" in inspector.get_table_names() # migration 25
battery_widget_columns = {c["name"] for c in inspector.get_columns("battery_widget_configs")}
assert "mode" in battery_widget_columns
widget_columns = {c["name"] for c in inspector.get_columns("widgets")}
assert {"border_style", "border_thickness", "border_color_index"} <= widget_columns # migration 26
photo_widget_columns = {c["name"] for c in inspector.get_columns("photo_widget_configs")}
assert "locked" in photo_widget_columns # migration 27
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
@@ -188,22 +201,25 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
migrate.""" migrate."""
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs are migration 20/21 # static_widget_configs/text_widget_configs are migration 20/21
# tables, and saved_layouts/saved_layout_widgets/saved_layout_ # tables, saved_layouts/saved_layout_widgets/saved_layout_
# sources/saved_layout_button_actions are migration 23's (all # sources/saved_layout_button_actions are migration 23's,
# post-16, like the rest of this list) -- dropped here too so a # weather_widget_configs is migration 24's, and
# real version-15 database is what's actually being simulated, # battery_widget_configs is migration 25's (all post-16, like the
# not "version 15 plus tables that wouldn't exist yet". Harmless # rest of this list) -- dropped here too so a real version-15
# to omit as long as no migration after the one that creates a # database is what's actually being simulated, not "version 15
# table also ALTERs it (that's what let static_widget_configs go # plus tables that wouldn't exist yet". Harmless to omit as long
# unlisted safely so far -- Base.metadata.create_all is # as no migration after the one that creates a table also ALTERs
# idempotent against an already-present table with no later # or re-CREATEs it (that's what let a create_all-based migration
# ALTER to collide with), but text_widget_configs' migration 22 # go unlisted safely so far), but static_widget_configs/
# ALTER makes the gap a real "table already exists"/"duplicate # text_widget_configs/weather_widget_configs/battery_widget_
# column" collision instead of a silent no-op. # configs all use a raw CREATE TABLE (not create_all -- see
# migration 20's own docstring on why), so an already-present one
# is a real "table already exists" collision, not a silent no-op.
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists", for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs", "calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources", "text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
"saved_layout_widgets", "saved_layouts", "widgets"): "saved_layout_widgets", "saved_layouts", "weather_widget_configs",
"battery_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}")) conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text( conn.execute(text(
@@ -256,6 +272,65 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
assert config.queue == ["legacy-asset", "next-asset"] assert config.queue == ["legacy-asset", "next-asset"]
def test_migration_28_dedupes_and_enforces_one_action_per_widget_per_button(db_session):
"""Exercises _migration_28's real ALTER path: a pre-existing database
with more than one FrameButtonAction row bound to the same (widget,
button) -- the old frame-level "Button assignments" card allowed
this -- gets de-duped down to one row (MIN(id) survives) before the
new unique index is created, rather than the migration failing
outright on "UNIQUE constraint failed"."""
frame = db_session.get(Frame, 1)
widget = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).first()
with db_module.engine.begin() as conn:
# The fresh-install create_all() copy already has the unique
# index (it reflects models.py's current shape) -- drop it first
# so the duplicate insert below doesn't just fail immediately,
# simulating a real pre-migration-28 database.
conn.execute(text("DROP INDEX IF EXISTS ix_frame_button_actions_widget_button"))
conn.execute(text(
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
"VALUES (:frame_id, 'next', :widget_id, 'back', 1, 0)"
), {"frame_id": frame.id, "widget_id": widget.id})
conn.execute(text("UPDATE schema_version SET version = 27"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
actions = db_session.scalars(
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == "next")
).all()
assert len(actions) == 1
inspector = inspect(db_module.engine)
indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
assert "ix_frame_button_actions_widget_button" in indexes
def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_session):
"""Exercises _migration_29's real guarded ALTER path (frames isn't
dropped/recreated by the pre-widget-system replay tests, so its
columns must be added defensively, same reasoning as migration
26/27's own comments)."""
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE schema_version SET version = 28"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
frame = db_session.get(Frame, 1)
assert frame.hold_duration_ms == 3000
assert frame.next_hold_action is None
assert frame.back_hold_action is None
assert frame.last_cycled_layout_id is None
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session): def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction """Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database SQL back to back (the real "existing widget-system database
@@ -270,11 +345,12 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d
tasks_* columns and TaskWidgetConfig no longer having user_id/ tasks_* columns and TaskWidgetConfig no longer having user_id/
calendar_key columns either.""" calendar_key columns either."""
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs/saved_layout_* dropped # static_widget_configs/text_widget_configs/saved_layout_*/
# too -- see the comment on the identical setup in # weather_widget_configs/battery_widget_configs dropped too --
# see the comment on the identical setup in
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations # test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
# 20/21/23's raw CREATE TABLE collides with an already-present table # 20/21/23/24/25's raw CREATE TABLE collides with an already-present table
# otherwise, since this test replays 17 through 23 and none of # otherwise, since this test replays 17 through 25 and none of
# these tables would really exist yet at a genuine pre-migration-17 # these tables would really exist yet at a genuine pre-migration-17
# schema_version). # schema_version).
conn.execute(text("DROP TABLE task_widget_configs")) conn.execute(text("DROP TABLE task_widget_configs"))
@@ -286,6 +362,8 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d
conn.execute(text("DROP TABLE saved_layout_sources")) conn.execute(text("DROP TABLE saved_layout_sources"))
conn.execute(text("DROP TABLE saved_layout_widgets")) conn.execute(text("DROP TABLE saved_layout_widgets"))
conn.execute(text("DROP TABLE saved_layouts")) conn.execute(text("DROP TABLE saved_layouts"))
conn.execute(text("DROP TABLE weather_widget_configs"))
conn.execute(text("DROP TABLE battery_widget_configs"))
conn.execute(text( conn.execute(text(
"CREATE TABLE calendar_widget_configs (" "CREATE TABLE calendar_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
@@ -380,13 +458,15 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
the widget backfill, not as a numbered migration racing ahead of the widget backfill, not as a numbered migration racing ahead of
it (see _ensure_frame_calendars_rekeyed's own docstring).""" it (see _ensure_frame_calendars_rekeyed's own docstring)."""
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs/saved_layout_* dropped # static_widget_configs/text_widget_configs/saved_layout_*/
# too -- see the comment on the identical setup in # weather_widget_configs dropped too -- see the comment on the
# identical setup in
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above. # test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists", for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs", "calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources", "text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
"saved_layout_widgets", "saved_layouts", "widgets"): "saved_layout_widgets", "saved_layouts", "weather_widget_configs",
"battery_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}")) conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text( conn.execute(text(
+366
View File
@@ -0,0 +1,366 @@
"""app/weather/'s provider modules -- pure-logic, no HTTP/DB: monkeypatches
httpx.get with canned responses. Covers category normalization (Open-
Meteo's WMO codes, NWS's icon-URL/text shapes, EC's numeric icon codes)
landing on the same shared category set, and each provider's
fetch_current/fetch_hourly/fetch_daily shape. The EC fixtures below are
trimmed-down real response shapes captured live against
api.weather.gc.ca (Toronto, 2026-07-27), not guessed."""
from __future__ import annotations
import httpx
import pytest
from app.weather import WeatherFetchError, ec, nws, open_meteo
class _FakeResponse:
def __init__(self, data: dict):
self._data = data
def raise_for_status(self) -> None:
pass
def json(self) -> dict:
return self._data
# --- open_meteo -----------------------------------------------------------
def test_open_meteo_fetch_current(monkeypatch):
monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse(
{"current": {"temperature_2m": 72.5, "weathercode": 3}}
))
result = open_meteo.fetch_current(45.5, -122.6, "fahrenheit")
assert result == {"temp": 72.5, "category": "cloudy"}
def test_open_meteo_fetch_hourly_respects_hours_limit(monkeypatch):
monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse({
"hourly": {
"time": [f"2026-07-27T{h:02d}:00" for h in range(24)],
"temperature_2m": list(range(24)),
"weathercode": [0] * 24,
}
}))
result = open_meteo.fetch_hourly(45.5, -122.6, "fahrenheit", hours=6)
assert len(result) == 6
assert result[0] == {"time": "2026-07-27T00:00", "temp": 0, "category": "clear"}
def test_open_meteo_fetch_daily_normalizes_category_and_clamps_days(monkeypatch):
monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse({
"daily": {
"time": ["2026-07-27", "2026-07-28", "2026-07-29"],
"weathercode": [95, 71, 61],
"temperature_2m_max": [80, 60, 65],
"temperature_2m_min": [65, 45, 50],
}
}))
result = open_meteo.fetch_daily(45.5, -122.6, "fahrenheit", days=2)
assert list(result.keys()) == ["2026-07-27", "2026-07-28"]
assert result["2026-07-27"] == {"high": 80, "low": 65, "category": "thunderstorm"}
assert result["2026-07-28"]["category"] == "snow"
def test_open_meteo_weather_category_unknown_code_falls_back_to_cloudy():
assert open_meteo.weather_category(9999) == "cloudy"
def test_open_meteo_raises_weather_fetch_error_on_http_failure(monkeypatch):
def _raise(*a, **kw):
raise httpx.ConnectError("boom")
monkeypatch.setattr(httpx, "get", _raise)
with pytest.raises(WeatherFetchError):
open_meteo.fetch_current(45.5, -122.6, "fahrenheit")
# --- nws --------------------------------------------------------------------
@pytest.mark.parametrize("icon_url,expected", [
("https://api.weather.gov/icons/land/day/skc?size=medium", "clear"),
("https://api.weather.gov/icons/land/night/few?size=medium", "partly_cloudy"),
("https://api.weather.gov/icons/land/day/bkn?size=medium", "cloudy"),
("https://api.weather.gov/icons/land/day/tsra,40?size=medium", "thunderstorm"),
("https://api.weather.gov/icons/land/night/wind_skc?size=medium", "clear"),
("https://api.weather.gov/icons/land/day/snow,80?size=medium", "snow"),
("https://api.weather.gov/icons/land/day/rain_showers,60?size=medium", "rain"),
])
def test_nws_category_from_icon(icon_url, expected):
assert nws._category_from_icon(icon_url) == expected
def test_nws_category_from_icon_unrecognized_code_falls_back_to_none():
assert nws._category_from_icon("https://api.weather.gov/icons/land/day/mystery_code?size=medium") is None
@pytest.mark.parametrize("text,expected", [
("Chance Thunderstorms", "thunderstorm"),
("Snow likely", "snow"),
("Rain showers", "rain"),
("Patchy Fog", "fog"),
("Mostly Sunny", "clear"),
("Something Unrelated", "cloudy"),
])
def test_nws_category_from_text_fallback(text, expected):
assert nws._category_from_text(text) == expected
def test_nws_convert_temp_fahrenheit_to_celsius():
assert round(nws._convert_temp(32, "F", "celsius"), 1) == 0.0
def test_nws_convert_temp_no_op_when_units_already_match():
assert nws._convert_temp(75, "F", "fahrenheit") == 75
def _points_response():
return _FakeResponse({"properties": {
"forecast": "https://api.weather.gov/gridpoints/PQR/1,1/forecast",
"forecastHourly": "https://api.weather.gov/gridpoints/PQR/1,1/forecast/hourly",
}})
def _hourly_periods_response(n=6):
return _FakeResponse({"properties": {"periods": [
{"startTime": f"2026-07-27T{h:02d}:00:00-07:00", "temperature": 60 + h, "temperatureUnit": "F",
"shortForecast": "Sunny", "icon": "https://api.weather.gov/icons/land/day/skc?size=medium"}
for h in range(n)
]}})
def test_nws_fetch_current_uses_first_hourly_period(monkeypatch):
def _get(url, headers, timeout):
if "/points/" in url:
return _points_response()
return _hourly_periods_response()
monkeypatch.setattr(httpx, "get", _get)
result = nws.fetch_current(45.5, -122.6, "fahrenheit")
assert result == {"temp": 60, "category": "clear"}
def test_nws_fetch_hourly_respects_hours_limit(monkeypatch):
def _get(url, headers, timeout):
if "/points/" in url:
return _points_response()
return _hourly_periods_response(n=24)
monkeypatch.setattr(httpx, "get", _get)
result = nws.fetch_hourly(45.5, -122.6, "fahrenheit", hours=4)
assert len(result) == 4
assert result[0]["category"] == "clear"
def test_nws_fetch_daily_pairs_day_night_periods_by_date(monkeypatch):
def _get(url, headers, timeout):
if "/points/" in url:
return _points_response()
return _FakeResponse({"properties": {"periods": [
{"startTime": "2026-07-27T06:00:00-07:00", "isDaytime": True, "temperature": 80,
"temperatureUnit": "F", "shortForecast": "Sunny",
"icon": "https://api.weather.gov/icons/land/day/skc?size=medium"},
{"startTime": "2026-07-27T18:00:00-07:00", "isDaytime": False, "temperature": 60,
"temperatureUnit": "F", "shortForecast": "Clear",
"icon": "https://api.weather.gov/icons/land/night/skc?size=medium"},
{"startTime": "2026-07-28T06:00:00-07:00", "isDaytime": True, "temperature": 75,
"temperatureUnit": "F", "shortForecast": "Rain",
"icon": "https://api.weather.gov/icons/land/day/rain,80?size=medium"},
{"startTime": "2026-07-28T18:00:00-07:00", "isDaytime": False, "temperature": 55,
"temperatureUnit": "F", "shortForecast": "Rain",
"icon": "https://api.weather.gov/icons/land/night/rain,80?size=medium"},
]}})
monkeypatch.setattr(httpx, "get", _get)
result = nws.fetch_daily(45.5, -122.6, "fahrenheit", days=2)
assert result == {
"2026-07-27": {"high": 80, "low": 60, "category": "clear"},
"2026-07-28": {"high": 75, "low": 55, "category": "rain"},
}
def test_nws_fetch_daily_clamps_to_however_many_dates_came_back(monkeypatch):
"""Asking for more days than NWS actually returned degrades gracefully
(fewer days), not an error -- same idiom as Open-Meteo's own clamp."""
def _get(url, headers, timeout):
if "/points/" in url:
return _points_response()
return _FakeResponse({"properties": {"periods": [
{"startTime": "2026-07-27T06:00:00-07:00", "isDaytime": True, "temperature": 80,
"temperatureUnit": "F", "shortForecast": "Sunny",
"icon": "https://api.weather.gov/icons/land/day/skc?size=medium"},
]}})
monkeypatch.setattr(httpx, "get", _get)
result = nws.fetch_daily(45.5, -122.6, "fahrenheit", days=7)
assert len(result) == 1
def test_nws_raises_weather_fetch_error_when_points_lookup_fails(monkeypatch):
def _raise(*a, **kw):
raise httpx.ConnectError("boom")
monkeypatch.setattr(httpx, "get", _raise)
with pytest.raises(WeatherFetchError):
nws.fetch_current(45.5, -122.6, "fahrenheit")
# --- ec ---------------------------------------------------------------------
def _ec_feature(lon: float, lat: float, properties: dict) -> dict:
return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": properties}
def _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy") -> dict:
return {
"currentConditions": {
"temperature": {"value": {"en": temp_c}},
"iconCode": {"value": icon_code},
"condition": {"en": condition},
},
"hourlyForecastGroup": {"hourlyForecasts": []},
"forecastGroup": {"timestamp": {"en": "2026-07-27T15:00:00Z"}, "forecasts": []},
}
def _ec_items_response(features: list[dict]) -> _FakeResponse:
return _FakeResponse({"features": features})
@pytest.mark.parametrize("code,expected", [
(0, "clear"), (1, "clear"), (30, "clear"),
(2, "partly_cloudy"), (5, "partly_cloudy"), (31, "partly_cloudy"), (32, "partly_cloudy"),
(3, "cloudy"), (4, "cloudy"), (10, "cloudy"), (33, "cloudy"),
(6, "rain"), (12, "rain"), (28, "rain"), (36, "rain"),
(9, "thunderstorm"), (19, "thunderstorm"), (39, "thunderstorm"),
(24, "fog"),
])
def test_ec_category_from_known_codes(code, expected):
assert ec._category_from_code_and_text(code, "irrelevant text") == expected
@pytest.mark.parametrize("text,expected", [
("Periods of snow", "snow"), ("Flurries", "snow"), ("Freezing rain", "snow"),
("Periods of rain", "rain"), ("Chance of showers", "rain"), ("Drizzle", "rain"),
("Risk of thunderstorms", "thunderstorm"), ("Tornado warning", "thunderstorm"),
("Patchy fog", "fog"), ("Mainly sunny", "clear"), ("Something unrelated", "cloudy"),
])
def test_ec_category_from_text_fallback_for_unmapped_code(text, expected):
assert ec._category_from_code_and_text(9999, text) == expected
def test_ec_convert_temp_celsius_to_fahrenheit():
assert ec._convert_temp(0, "fahrenheit") == 32
assert ec._convert_temp(0, "celsius") == 0
def test_ec_fetch_current_converts_and_categorizes(monkeypatch):
props = _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy")
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
[_ec_feature(-79.38, 43.65, props)]
))
result = ec.fetch_current(43.65, -79.38, "fahrenheit")
assert result == {"temp": pytest.approx(83.84), "category": "cloudy"}
def test_ec_fetch_hourly_respects_hours_limit(monkeypatch):
hourly = [
{"timestamp": f"2026-07-27T{h:02d}:00:00Z", "temperature": {"value": {"en": 20 + h}},
"iconCode": {"value": 2}, "condition": {"en": "A mix of sun and cloud"}}
for h in range(24)
]
props = _ec_current_properties()
props["hourlyForecastGroup"] = {"hourlyForecasts": hourly}
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
[_ec_feature(-79.38, 43.65, props)]
))
result = ec.fetch_hourly(43.65, -79.38, "celsius", hours=5)
assert len(result) == 5
assert result[0]["category"] == "partly_cloudy"
def _ec_forecast_period(name: str, temp_class: str, temp_c: float, icon_code: int, summary: str) -> dict:
return {
"period": {"textForecastName": {"en": name}},
"temperatures": {"temperature": [{"value": {"en": temp_c}, "class": {"en": temp_class}}]},
"abbreviatedForecast": {"icon": {"value": icon_code}, "textSummary": {"en": summary}},
}
def test_ec_fetch_daily_pairs_day_night_periods_by_walking_order(monkeypatch):
props = _ec_current_properties()
props["forecastGroup"] = {
"timestamp": {"en": "2026-07-27T15:00:00Z"},
"forecasts": [
_ec_forecast_period("Today", "high", 29, 9, "Chance of showers"),
_ec_forecast_period("Tonight", "low", 15, 30, "Clear"),
_ec_forecast_period("Tuesday", "high", 25, 2, "Partly cloudy"),
_ec_forecast_period("Tuesday night", "low", 17, 32, "Cloudy periods"),
],
}
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
[_ec_feature(-79.38, 43.65, props)]
))
result = ec.fetch_daily(43.65, -79.38, "celsius", days=2)
assert result == {
"2026-07-27": {"high": 29, "low": 15, "category": "thunderstorm"},
"2026-07-28": {"high": 25, "low": 17, "category": "partly_cloudy"},
}
def test_ec_fetch_daily_clamps_to_however_many_dates_came_back(monkeypatch):
props = _ec_current_properties()
props["forecastGroup"] = {
"timestamp": {"en": "2026-07-27T15:00:00Z"},
"forecasts": [_ec_forecast_period("Today", "high", 29, 9, "Chance of showers")],
}
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
[_ec_feature(-79.38, 43.65, props)]
))
result = ec.fetch_daily(43.65, -79.38, "celsius", days=7)
assert len(result) == 1
def test_ec_nearest_site_widens_bbox_until_within_max_distance(monkeypatch):
"""A site right at the query point should be picked up by the very
first (smallest) bounding box -- no need to widen."""
props = _ec_current_properties()
calls = []
def _get(url, params, headers, timeout):
calls.append(params["bbox"])
return _ec_items_response([_ec_feature(-79.38, 43.65, props)])
monkeypatch.setattr(httpx, "get", _get)
result = ec.fetch_current(43.65, -79.38, "celsius")
assert result["temp"] == pytest.approx(28.8)
assert len(calls) == 1 # first (smallest) padding already found it
def test_ec_nearest_site_rejects_a_match_beyond_max_distance(monkeypatch):
"""Regression test: without a distance cutoff, progressively
widening the bbox until non-empty eventually matches ANY location on
Earth to some real EC site once the box is big enough (confirmed
live: a Miami, FL query matched Leamington, Ontario, 1824 km away).
A site ~785 km from the query point must be rejected as "not
covered", not returned as if it were a legitimate nearby match."""
far_props = _ec_current_properties()
widest_bbox = f"{-ec._BBOX_PADDINGS_DEG[-1]},{-ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]}"
def _get(url, params, headers, timeout):
# Only the widest padding's box actually reaches the far site --
# every narrower one comes back empty, same as a genuine gap.
if params["bbox"] == widest_bbox:
return _ec_items_response([_ec_feature(5.0, 5.0, far_props)])
return _ec_items_response([])
monkeypatch.setattr(httpx, "get", _get)
with pytest.raises(WeatherFetchError):
ec.fetch_current(0.0, 0.0, "celsius")
def test_ec_raises_weather_fetch_error_on_http_failure(monkeypatch):
def _raise(*a, **kw):
raise httpx.ConnectError("boom")
monkeypatch.setattr(httpx, "get", _raise)
with pytest.raises(WeatherFetchError):
ec.fetch_current(43.65, -79.38, "fahrenheit")
+108
View File
@@ -0,0 +1,108 @@
"""app.weather_render -- pure-logic drawing helpers, no HTTP/DB.
Mirrors calendar_render.py's own testing posture (this module has no
dedicated test file there either, since it's exercised indirectly via
test_widgets_calendar.py) but covers the non-obvious behavior worth
pinning down directly: build_multi_city's label shortening, and that
weather icons are filled with the panel's *exact* ink colors (not an
arbitrary bitmap's anti-aliased ones) -- the whole reason icons are
hand-drawn rather than a fetched/vendored image: an exact palette match
quantizes with zero dithering error to diffuse, where anything else
dithers into a visible speckle at these small on-panel sizes."""
from __future__ import annotations
from PIL import Image, ImageDraw
from app import weather_render
from app.image_pipeline import DEFAULT_PALETTE_RGB, _quantize
def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch):
"""Cached entries carry the full disambiguated geocoder label (e.g.
"Seattle, Washington, United States" -- see get_or_refresh_weather_
widget_data/weather.geocode_city). Drawing the whole thing made a
single city's row overflow past the widget's own width in practice
(caught via browser verification) -- only the city name should reach
draw_weather_row, same shortening calendar_render.py's
_weather_for_day already does for its own embedded strip."""
seen_labels = []
real_draw_weather_row = weather_render.draw_weather_row
def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True, palette_rgb=None):
seen_labels.extend(e["label"] for e in entries)
return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels,
palette_rgb)
monkeypatch.setattr(weather_render, "draw_weather_row", spy)
cities = [
{"label": "Seattle, Washington, United States", "high": 65, "low": 50, "category": "rain"},
{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"},
]
img = weather_render.build_multi_city(cities, 400, 150)
assert img.size == (400, 150)
assert seen_labels == ["Seattle", "Portland"]
def test_build_multi_city_empty_list_returns_blank_canvas():
img = weather_render.build_multi_city([], 400, 150)
assert img.size == (400, 150)
def _colors_present(img: Image.Image) -> set[tuple[int, int, int]]:
return {c for _, c in img.getcolors(maxcolors=100_000)}
def test_draw_weather_icon_clear_uses_exact_panel_yellow():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear")
assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow sun
assert (0, 0, 0) not in _colors_present(img) # not a flat-black glyph
def test_draw_weather_icon_rain_uses_exact_panel_blue():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "rain")
assert tuple(DEFAULT_PALETTE_RGB[4]) in _colors_present(img) # blue raindrops
def test_draw_weather_icon_thunderstorm_uses_exact_panel_yellow():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "thunderstorm")
assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow bolt
def test_draw_weather_icon_respects_a_custom_frame_palette():
"""A frame with an Advanced-configuration palette override (see
Frame.palette_rgb) should still get ITS actual yellow, not the
hardcoded default -- same resolution rule as calendar_render.py's
_event_colors."""
custom_palette = [(0, 0, 0), (255, 255, 255), (10, 20, 30), (0, 0, 0), (0, 0, 0), (0, 0, 0)]
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear", palette_rgb=custom_palette)
assert (10, 20, 30) in _colors_present(img)
def test_icon_fill_quantizes_uniformly_with_no_dithering_speckle():
"""The actual point of hand-drawing icons in exact panel colors: a
small crop taken from deep inside the sun disc (nowhere near its own
edge) is a single flat fill -- if that fill weren't an exact palette
match, _quantize's Floyd-Steinberg dithering would diffuse rounding
error across it, breaking the crop into a speckle of 2+ colors. An
exact match has zero error to diffuse, so the crop stays perfectly
uniform after quantizing, same as before."""
img = Image.new("RGB", (200, 200), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 100, 100, 60, "clear")
quantized = _quantize(img, DEFAULT_PALETTE_RGB, dither_strength=1.0).convert("RGB")
# A 20x20 crop centered on the disc -- draw_sun's disc radius is
# 0.55*60=33px, comfortably clear of both the outer edge and the ray
# triangles' base line.
crop = quantized.crop((90, 90, 110, 110))
colors_in_crop = _colors_present(crop)
assert colors_in_crop == {tuple(DEFAULT_PALETTE_RGB[2])} # solid yellow, no speckle
+149
View File
@@ -0,0 +1,149 @@
"""routers/api_widgets.py's POST .../border endpoint (models.Widget.
border_style/border_thickness/border_color_index) -- a Widget-level
property, not a per-type config field, so this is exercised independently
of any specific widget_type. End-to-end rendering (does a saved border
actually show up in the composited panel) is covered separately in
test_device_widget_dispatch.py-style tests below via the frame preview
endpoint, the same real render_panel path /frame/image uses."""
from __future__ import annotations
import io
from PIL import Image
from app.models import Frame, Widget
from .conftest import csrf_headers, link_user, login, make_user
def _widget_id(db_session, widget_type="photos") -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id
def test_new_widget_defaults_to_no_border(db_session):
widget = db_session.query(Widget).filter_by(frame_id=1).one()
assert widget.border_style == "none"
assert widget.border_thickness == 3
assert widget.border_color_index == 0
def test_set_border_persists(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
json={"border_style": "dashed", "border_thickness": 5, "border_color_index": 3},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.json() == {
"id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0,
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "locked": False,
}
widget = db_session.get(Widget, widget_id)
assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3)
def test_set_border_thickness_clamps_to_range(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
json={"border_style": "solid", "border_thickness": 999, "border_color_index": 0},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.json()["border_thickness"] == 8 # image_pipeline.MAX_BORDER_THICKNESS
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
json={"border_style": "solid", "border_thickness": -5, "border_color_index": 0},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.json()["border_thickness"] == 1 # image_pipeline.MIN_BORDER_THICKNESS
def test_set_border_rejects_unknown_style(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
json={"border_style": "sparkly", "border_thickness": 3, "border_color_index": 0},
headers=csrf_headers(client))
assert resp.status_code == 400
assert "border_style" in resp.json()["detail"]
def test_set_border_rejects_out_of_range_color_index(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
json={"border_style": "solid", "border_thickness": 3, "border_color_index": 6},
headers=csrf_headers(client))
assert resp.status_code == 400
assert "border_color_index" in resp.json()["detail"]
def test_set_border_404s_for_unknown_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets/999999/border",
json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0},
headers=csrf_headers(client))
assert resp.status_code == 404
def test_set_border_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "mallory")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0},
headers=csrf_headers(client))
assert resp.status_code == 404
assert db_session.get(Widget, widget_id).border_style == "none"
def test_set_border_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0},
headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
# --- actually renders (real render_panel path, same as /frame/image) ------
def _preview_pixels(client) -> Image.Image:
resp = client.get("/api/frames/1/preview")
assert resp.status_code == 200, resp.text
return Image.open(io.BytesIO(resp.content)).convert("RGB")
def test_solid_border_draws_the_configured_palette_color(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
# color_index 3 == Red, see image_pipeline.PALETTE_LABELS/DEFAULT_PALETTE_RGB
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
json={"border_style": "solid", "border_thickness": 6, "border_color_index": 3},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
img = _preview_pixels(client)
assert img.getpixel((0, 0)) == (207, 0, 15) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke
def test_no_border_leaves_the_edge_unmarked(client, db_session):
"""The default "none" style is a true no-op -- this is the negative
case for test_solid_border_draws_the_configured_palette_color, using
the exact same photo-widget fixture minus the border, so a red edge
pixel there can only be the border, not something else in the
render path already painting it that color."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
img = _preview_pixels(client)
assert img.getpixel((0, 0)) != (207, 0, 15)
@@ -14,12 +14,14 @@ import io
from PIL import Image from PIL import Image
from app.models import ( from app.models import (
BatteryWidgetConfig,
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
PhotoWidgetConfig, PhotoWidgetConfig,
StaticWidgetConfig, StaticWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
TextWidgetConfig, TextWidgetConfig,
WeatherWidgetConfig,
Widget, Widget,
) )
@@ -80,6 +82,30 @@ def _add_text_widget(db_session) -> Widget:
return widget return widget
def _add_weather_widget(db_session, **cfg_kwargs) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="weather", x=0, y=0, w=2, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return widget
def _add_battery_widget(db_session, **cfg_kwargs) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="battery", x=0, y=0, w=1, h=1,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(BatteryWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return widget
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes: def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
buf = io.BytesIO() buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG") Image.new("RGB", size, color).save(buf, format="PNG")
@@ -150,6 +176,73 @@ def test_config_save_updates_a_tasks_widget(client, db_session):
assert cfg.show_completed is True assert cfg.show_completed is True
def test_config_save_updates_a_weather_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "hourly", "weather_provider": "nws", "weather_units": "celsius",
"weather_hourly_interval_hours": "6", "weather_daily_days": "3"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.mode == "hourly"
assert cfg.provider == "nws"
assert cfg.units == "celsius"
assert cfg.hourly_interval_hours == 6
assert cfg.daily_days == 3
def test_config_save_ignores_an_invalid_weather_mode(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "not_a_real_mode"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.mode == "current" # unchanged -- invalid value silently ignored, same as calendar_view's own validation
def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db_session, monkeypatch):
"""Regression test: a widget's `cached` shape depends on its mode (a
single-temp dict for current, a list for hourly/multi_city, a dict
for daily). Switching modes without clearing the old cache used to
crash the very next preview -- get_or_refresh_weather_widget_data's
multi_city branch tried `c["label"]` against a leftover "current"-
mode dict, TypeError: string indices must be integers -- rather than
just triggering a fresh fetch shaped for the new mode."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current", cached={"temp": 70.0, "category": "clear"},
checked_at=1e15) # far in the future -- would still be "fresh" if not cleared
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "multi_city"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.cached is None
cfg.cities = [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
db_session.commit()
monkeypatch.setattr(
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
lambda db, frame, widget, force=False: [
{"label": "Portland", "high": 75, "low": 55, "category": "clear"},
],
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 200, resp.text
def test_config_save_truncates_an_overlong_tasks_name(client, db_session): def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session) widget = _add_tasks_widget(db_session)
@@ -197,6 +290,38 @@ def test_config_save_rejects_an_unrecognized_static_display_mode(client, db_sess
assert cfg.display_mode == "crop_fill" assert cfg.display_mode == "crop_fill"
def test_config_save_updates_a_battery_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_battery_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"battery_mode": "compact"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(BatteryWidgetConfig, widget.id)
assert cfg.mode == "compact"
def test_config_save_rejects_an_unrecognized_battery_mode(client, db_session):
"""Falls back to the default rather than erroring -- same "clamp,
don't reject" posture as the other config-save fields."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_battery_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"battery_mode": "graph"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(BatteryWidgetConfig, widget.id)
assert cfg.mode == "detailed"
def test_config_save_updates_a_text_widget(client, db_session): def test_config_save_updates_a_text_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session) widget = _add_text_widget(db_session)
@@ -420,3 +545,162 @@ def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
widget = _add_static_widget(db_session) widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text") resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 400 assert resp.status_code == 400
# --- battery: preview ---------------------------------------------------
def test_preview_battery_renders_even_before_any_report(client, db_session):
"""Unlike every other widget type's preview endpoint, there's no
"not configured yet" 400 -- render() always has something to show
(a placeholder, here, since the frame's never reported)."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_battery_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/battery")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
def test_preview_battery_renders_after_a_report(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_battery_widget(db_session)
frame = db_session.get(Frame, 1)
frame.battery_percent = 77
db_session.commit()
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/battery")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
def test_preview_battery_400s_for_a_widget_that_is_not_battery(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/battery")
assert resp.status_code == 400
# --- weather: location/cities/preview ---------------------------------
def _mock_geocode(monkeypatch, label="Portland, Oregon, United States", latitude=45.5, longitude=-122.6):
monkeypatch.setattr(
"app.routers.api_widgets.weather.geocode_city",
lambda name: {"label": label, "latitude": latitude, "longitude": longitude},
)
def test_weather_location_set_and_clear(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current")
_mock_geocode(monkeypatch)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-location",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
assert resp.json()["city"]["label"] == "Portland, Oregon, United States"
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.city_label == "Portland, Oregon, United States"
assert cfg.city_latitude == 45.5
assert cfg.city_longitude == -122.6
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-location",
json={"name": None},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.city_label is None
assert cfg.city_latitude is None
def test_weather_location_400s_for_a_widget_that_is_not_weather(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
_mock_geocode(monkeypatch)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-location",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 400
def test_weather_widget_cities_add_and_remove(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="multi_city")
_mock_geocode(monkeypatch)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.cities == [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 400 # already on the list
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/remove",
json={"label": "Portland, Oregon, United States"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.cities == []
def test_weather_widget_cities_400s_for_a_widget_that_is_not_weather(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
_mock_geocode(monkeypatch)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 400
def test_preview_weather_400s_before_anything_is_configured(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current")
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 400
def test_preview_weather_400s_before_any_city_for_multi_city_mode(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="multi_city")
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 400
def test_preview_weather_renders_after_location_is_set(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current")
monkeypatch.setattr(
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
lambda db, frame, widget, force=False: {"temp": 72.0, "category": "clear"},
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
def test_preview_weather_400s_for_a_widget_that_is_not_weather(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 400
+100
View File
@@ -0,0 +1,100 @@
"""routers/api_widgets.py's POST .../lock endpoint (models.PhotoWidgetConfig.
locked) -- same permission shape as test_widget_border.py's own endpoint,
just for a photos-only field instead of a shared Widget-level one."""
from __future__ import annotations
import time
from app.models import Frame, PhotoWidgetConfig, StaticWidgetConfig, Widget
from .conftest import csrf_headers, link_user, login, make_user
def _widget_id(db_session, widget_type="photos") -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id
def test_new_widget_defaults_to_unlocked(db_session):
widget_id = _widget_id(db_session)
assert db_session.get(PhotoWidgetConfig, widget_id).locked is False
def test_set_lock_persists(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock",
json={"locked": True}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.json() == {"status": "saved", "locked": True}
assert db_session.get(PhotoWidgetConfig, widget_id).locked is True
resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock",
json={"locked": False}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.get(PhotoWidgetConfig, widget_id).locked is False
def test_set_lock_404s_for_unknown_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets/999999/lock",
json={"locked": True}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_set_lock_rejects_non_photos_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
# Built directly rather than through the placement-validated create
# endpoint -- the default frame's photos widget already covers the
# whole grid, and overlap isn't the thing under test here.
static_widget = Widget(frame_id=1, widget_type="static", x=0, y=0, w=1, h=1,
sort_order=99, created_at=time.time())
db_session.add(static_widget)
db_session.flush()
db_session.add(StaticWidgetConfig(widget_id=static_widget.id))
db_session.commit()
resp = client.post(f"/api/frames/1/widgets/{static_widget.id}/lock",
json={"locked": True}, headers=csrf_headers(client))
assert resp.status_code == 400
def test_set_lock_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "mallory")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock",
json={"locked": True}, headers=csrf_headers(client))
assert resp.status_code == 404
assert db_session.get(PhotoWidgetConfig, widget_id).locked is False
def test_set_lock_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock",
json={"locked": True}, headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
def test_widgets_list_reports_locked_state(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.get("/api/frames/1/widgets")
assert resp.status_code == 200, resp.text
assert next(w for w in resp.json()["widgets"] if w["id"] == widget_id)["locked"] is False
client.post(f"/api/frames/1/widgets/{widget_id}/lock",
json={"locked": True}, headers=csrf_headers(client))
resp = client.get("/api/frames/1/widgets")
assert next(w for w in resp.json()["widgets"] if w["id"] == widget_id)["locked"] is True
+6 -4
View File
@@ -145,8 +145,9 @@ def test_move_unknown_widget_404s(client, db_session):
def test_delete_widget_and_cascades_button_actions(client, db_session): def test_delete_widget_and_cascades_button_actions(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session) widget_id = _widget_id(db_session)
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=widget_id, action="advance", sort_order=0)) # Already has a default next->advance binding from creation (see
db_session.commit() # widgets.default_button_actions) -- nothing to add manually.
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).count() > 0
resp = client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client)) resp = client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
assert resp.status_code == 200 assert resp.status_code == 200
@@ -168,8 +169,9 @@ def test_clear_all_removes_every_widget_and_cascades_button_actions(client, db_s
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"}, create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client)) headers=csrf_headers(client))
assert create_resp.status_code == 200, create_resp.text assert create_resp.status_code == 200, create_resp.text
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=photos_id, action="advance", sort_order=0)) # photos_id already has a default next->advance binding from creation
db_session.commit() # (see widgets.default_button_actions) -- nothing to add manually.
assert db_session.query(FrameButtonAction).filter_by(widget_id=photos_id).count() > 0
resp = client.delete("/api/frames/1/widgets", headers=csrf_headers(client)) resp = client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
+83
View File
@@ -0,0 +1,83 @@
"""app.widgets.battery -- unit-level, no HTTP: constructs Widget/
BatteryWidgetConfig rows directly and sets Frame.battery_percent/
battery_as_of straight on the Frame row, same as routers/device.py's
frame_battery would."""
from __future__ import annotations
import time
from app import widgets
from app.models import BatteryWidgetConfig, Frame, Widget
def _make_widget(db_session, mode="detailed") -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(BatteryWidgetConfig(widget_id=widget.id, mode=mode))
db_session.commit()
return frame, widget
def test_render_shows_a_placeholder_when_frame_has_never_reported(db_session):
frame, widget = _make_widget(db_session)
assert frame.battery_percent == -1
img = widgets.battery.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_shows_the_reported_percent(db_session):
frame, widget = _make_widget(db_session)
frame.battery_percent = 42
frame.battery_as_of = time.time()
db_session.commit()
img = widgets.battery.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_compact_mode_omits_the_estimate_lines(db_session):
frame, widget = _make_widget(db_session, mode="compact")
frame.battery_percent = 80
frame.battery_as_of = time.time()
db_session.commit()
img = widgets.battery.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
def test_render_at_minimum_grid_footprint(db_session):
"""grid.MIN_FOOTPRINT["battery"] is (1, 1) cells -- on an 8x5 grid
against a full 800x480 panel that's a 100x96 box, the smallest a
battery widget can actually be placed at."""
frame, widget = _make_widget(db_session)
frame.battery_percent = 15
db_session.commit()
img = widgets.battery.render(db_session, frame, widget, 100, 96)
assert img.size == (100, 96)
def test_render_falls_back_to_detailed_mode_with_no_config_row(db_session):
"""widget_id has no BatteryWidgetConfig row at all -- render() must
not raise, matching every other widget type's "never raises for a
foreseeable failure" contract."""
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.commit()
frame.battery_percent = 60
db_session.commit()
img = widgets.battery.render(db_session, frame, widget, 100, 96)
assert img.size == (100, 96)
def test_no_button_actions():
"""A number the device itself pushes on every wake -- nothing to
advance/back/check."""
assert widgets.battery.ACTIONS == {}
assert widgets.battery.ACTION_LABELS == {}
+35
View File
@@ -109,6 +109,41 @@ def test_advance_action_is_a_no_op_when_unconfigured(db_session):
assert cfg.current_asset_id == "" assert cfg.current_asset_id == ""
def test_render_does_not_advance_when_locked(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
source = Image.new("RGB", (100, 80), (10, 20, 30))
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
with widget_locked(db_session, frame.id, widget.id) as (_, _, cfg):
cfg.current_asset_id = "asset-1"
cfg.current_asset_set_at = 0.0 # long ago -- refresh_interval_s has definitely elapsed
cfg.locked = True
widgets.photos.render(db_session, frame, widget, 400, 300)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.current_asset_id == "asset-1"
def test_advance_and_back_actions_are_no_ops_when_locked(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
with widget_locked(db_session, frame.id, widget.id) as (_, _, cfg):
cfg.current_asset_id = "asset-1"
cfg.locked = True
widgets.photos.ACTIONS["advance"](db_session, frame, widget)
widgets.photos.ACTIONS["back"](db_session, frame, widget)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.current_asset_id == "asset-1"
assert cfg.history == []
def test_advance_uses_the_frame_stats_counter_not_the_widget_config(): def test_advance_uses_the_frame_stats_counter_not_the_widget_config():
"""advance_forced's frame/stats split (see app/photo_queue.py) means """advance_forced's frame/stats split (see app/photo_queue.py) means
stats_photos_displayed should land on the Frame row, never on stats_photos_displayed should land on the Frame row, never on
+111
View File
@@ -0,0 +1,111 @@
"""app.widgets.weather -- unit-level, no HTTP: constructs Widget/
WeatherWidgetConfig rows directly and monkeypatches the underlying fetch
call (get_or_refresh_weather_widget_data, whose own throttle/provider-
dispatch logic is covered by test_weather_providers.py and exercised at
the HTTP layer in test_widget_config_and_queue_endpoints.py) -- this file
is about the widget wiring itself: does render() produce a correctly-
sized image for each of the four modes, does it fall back to a
placeholder when unconfigured, does check_now force a refetch."""
from __future__ import annotations
import time
from app import grid, widgets
from app.models import Frame, WeatherWidgetConfig, Widget
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="weather", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return frame, widget
def test_render_falls_back_to_placeholder_when_not_configured(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: None)
img = widgets.weather.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_current_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current", city_label="Portland")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
img = widgets.weather.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_hourly_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="hourly", city_label="Seattle", hourly_interval_hours=4)
hourly = [{"time": f"2026-07-27T{h:02d}:00", "temp": 60 + h, "category": "rain"} for h in range(24)]
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: hourly)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
def test_render_daily_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", daily_days=5)
daily = {f"2026-07-{27 + i}": {"high": 70 + i, "low": 50 + i, "category": "clear"} for i in range(5)}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
def test_render_multi_city_mode(db_session, monkeypatch):
"""cached entries carry the full disambiguated geocoder label (see
get_or_refresh_weather_widget_data/weather.geocode_city) -- render()
must still produce a correctly-sized image (weather_render.
build_multi_city shortens to just the city name for display, same as
calendar_render.py's _weather_for_day)."""
frame, widget = _make_widget(db_session, mode="multi_city")
cities = [
{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"},
{"label": "Seattle, Washington, United States", "high": 65, "low": 50, "category": "rain"},
]
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: cities)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
def test_render_at_minimum_grid_footprint(db_session, monkeypatch):
"""grid.MIN_FOOTPRINT["weather"] is (2, 2) cells -- on an 8x5 grid
against a full 800x480 panel that's a 200x192 box, the smallest a
weather widget can actually be placed at."""
assert grid.MIN_FOOTPRINT["weather"] == (2, 2)
frame, widget = _make_widget(db_session, mode="current")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
img = widgets.weather.render(db_session, frame, widget, 200, 192)
assert img.size == (200, 192)
def test_check_now_forces_a_refetch(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current")
calls = []
def _fake_refresh(db, frame, widget, force=False):
calls.append(force)
return {"temp": 70.0, "category": "clear"}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", _fake_refresh)
widgets.weather.ACTIONS["check_now"](db_session, frame, widget)
assert calls == [True]
def test_action_labels():
assert set(widgets.weather.ACTIONS.keys()) == {"check_now"}
assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"}