Compare commits
14
Commits
6fa3e2c2d2
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d34eca5d7 | ||
|
|
fcf3aec4c0 | ||
|
|
9911151d8d | ||
|
|
4e8c6e534b | ||
|
|
b15747a604 | ||
|
|
eb7127718b | ||
|
|
90a014d161 | ||
|
|
efb0f2e22d | ||
|
|
270979949f | ||
|
|
52ebafab78 | ||
|
|
6118705c37 | ||
|
|
5247f5e512 | ||
|
|
8bcc574f99 | ||
|
|
c323402895 |
@@ -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
|
||||
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
|
||||
image processing (crop/dither/quantize/pack), and serves a placeable
|
||||
photos/calendar/whiteboard widget system to the device.
|
||||
photos/calendar/whiteboard/weather widget system to the device.
|
||||
|
||||
CURRENT TODO
|
||||
-add more actions for buttons (i.e. change widget/layout)
|
||||
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
|
||||
-FIX Scan to download
|
||||
-make a weather widget
|
||||
-widget border option
|
||||
-battery life widget
|
||||
-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
|
||||
-scan to download for non-immich photos too?
|
||||
-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,
|
||||
not just the top-level package -- and present the finding and tradeoff
|
||||
in plain text rather than picking an approach unilaterally (hand-rolling
|
||||
an alternative, swapping packages, silently accepting
|
||||
a "coming up this week" widget). This project
|
||||
an alternative, swapping packages, silently accepting it). This project
|
||||
has knowingly accepted AGPL-3.0-or-later exposure once already
|
||||
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
|
||||
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
|
||||
covered `/api/*` but left `/frame/image` -- the actual photo bytes --
|
||||
open; caught immediately in production.
|
||||
- **Commit and push once a task is verified working, without waiting to
|
||||
be asked separately.** Once tests pass (and, for UI changes, the
|
||||
browser check has been done), stage the relevant files, write a normal
|
||||
commit message, and push to the current branch -- the maintainer's
|
||||
standing authorization for the commit/push step itself. This doesn't
|
||||
relax anything else: still run `git status`/review the diff before
|
||||
staging, still never force-push/amend a pushed commit/skip hooks, and
|
||||
still surface anything that looks like it needs a real decision (e.g.
|
||||
a change that would trigger `main`'s deploy workflow, see below)
|
||||
instead of pushing through it silently.
|
||||
|
||||
## Working in this repo
|
||||
|
||||
@@ -73,8 +78,7 @@ Start here, don't re-derive from scratch:
|
||||
- **New/changed UI must work at both desktop and mobile widths --
|
||||
screenshot both, don't assume one implies the other.** The layout
|
||||
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
|
||||
goes off-canvas behind a hamburger below
|
||||
a "coming up this week" widget. A dialog, header
|
||||
goes off-canvas behind a hamburger below it. A dialog, header
|
||||
control, or new widget that looks right at a wide viewport can
|
||||
overflow, overlap the mobile bar, or mis-center at phone widths.
|
||||
`run-server`'s driver has a `viewport` command for exactly this
|
||||
|
||||
+182
-32
@@ -1,10 +1,10 @@
|
||||
# Widget system
|
||||
|
||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
|
||||
arranging icons on an Android home screen. A frame can hold several widgets of the
|
||||
same type (e.g. two photo widgets pointed at different Immich albums side
|
||||
by side).
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
|
||||
weather/battery), like arranging icons on an Android home screen. A frame
|
||||
can hold several widgets of the same type (e.g. two photo widgets pointed
|
||||
at different Immich albums side by side).
|
||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
||||
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
||||
@@ -20,14 +20,32 @@ a button press does.
|
||||
## Data model
|
||||
|
||||
- `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
|
||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||
already checked) -- that's what keeps compositing simple: no z-order,
|
||||
no blending, just N independent regions pasted onto one shared canvas.
|
||||
Also carries an optional per-widget border (`border_style` -- `"none"`
|
||||
| `"solid"` | `"dashed"` | `"dotted"` | `"fancy"`, `border_thickness`,
|
||||
`border_color_index`, an index into the frame's palette so a border
|
||||
always renders as one of the panel's exact 6 ink colors) directly on
|
||||
`Widget` itself rather than a per-type config table, since every
|
||||
widget type can have one regardless of `widget_type`. Drawn by
|
||||
`image_pipeline.draw_widget_border` onto each widget's own region in
|
||||
`routers/device.py`'s `_render_widgets`, before that region is pasted
|
||||
onto the shared canvas -- one central integration point instead of
|
||||
every `app/widgets/*.py` module needing to know about it. Set via the
|
||||
gear-icon dialog's shared "Border" card (`_widget_border_fields.html`,
|
||||
included by every `_widget_dialog_*.html` template) and
|
||||
`POST .../widgets/{id}/border`, its own endpoint (not folded into
|
||||
`api_widget_config_save`) since that endpoint's per-type dispatch is
|
||||
keyed on a config row via `widget_locked`, and border fields live on
|
||||
`Widget` itself, not any per-type config table.
|
||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||
`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
|
||||
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
||||
text (paragraphs of styled runs), never raw HTML -- see
|
||||
@@ -36,11 +54,27 @@ a button press does.
|
||||
`PhotoWidgetConfig`
|
||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||
advance/back/queue logic ports across widget instances unchanged.
|
||||
`PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id`
|
||||
against both the timer-elapsed auto-advance
|
||||
(`photo_queue.get_current`) and the advance/back button actions
|
||||
(`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a
|
||||
"Lock this photo" button in the widget's own dialog
|
||||
(`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's
|
||||
box on the Layout tab canvas.
|
||||
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
|
||||
into its own widget type (migration 17) so a task list can be placed
|
||||
and sized independent of any calendar's view/footprint, then (migration
|
||||
18) given the same multi-source shape a calendar widget already has.
|
||||
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
|
||||
weather strip (still present and unchanged, `weather_*` columns) out
|
||||
into its own placeable widget type (migration 24) -- see "Weather
|
||||
widget" below. `BatteryWidgetConfig` (migration 25) is the odd one out
|
||||
-- its actual content (`Frame.battery_percent`/`battery_as_of`) isn't
|
||||
in this table at all, already existing frame-level state set by
|
||||
`routers/device.py`'s `frame_battery` regardless of whether a battery
|
||||
widget is even placed; the config row only holds a display-mode
|
||||
setting (`"compact"` | `"detailed"`).
|
||||
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
|
||||
`frame_id`) since a frame can now have more than one independent
|
||||
calendar/tasks widget, each with its own included set. Identical
|
||||
@@ -65,8 +99,13 @@ orientation change rather than trying to remap coordinates.
|
||||
|
||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
|
||||
Enforced both client-side
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
|
||||
weather 2x2 (its hourly/daily strips need the room; current/multi_city
|
||||
modes would tolerate smaller, but every mode shares one footprint value),
|
||||
battery 1x1 (just an icon + a percent, legible even at a single cell,
|
||||
like photos/static -- though see `MIN_FOOTPRINT`'s own comment in
|
||||
`grid.py` on a mobile-width gear-icon click-target gap at that size,
|
||||
already pre-existing for photos/static too). Enforced both client-side
|
||||
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
||||
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||
alone.
|
||||
@@ -75,7 +114,7 @@ alone.
|
||||
|
||||
`app/widgets/` is the render/action registry -- one module per
|
||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
||||
`static_image.py`, `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`:
|
||||
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.
|
||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||
for whiteboard). Empty for tasks, static image, and text -- nothing to
|
||||
advance/back/force for a passive checklist, a fixed uploaded image, or
|
||||
a fixed block of authored text.
|
||||
for whiteboard and weather -- both throttled external fetches with a
|
||||
forced-refetch action). Empty for tasks, static image, text, and
|
||||
battery -- nothing to advance/back/force for a passive checklist, a
|
||||
fixed uploaded image, a fixed block of authored text, or a number the
|
||||
device itself pushes on every wake.
|
||||
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||
assignment UI.
|
||||
|
||||
@@ -109,28 +150,49 @@ for month view to stay legible.
|
||||
|
||||
## Button actions
|
||||
|
||||
Each physical button (NEXT/BACK) maps to an **ordered list** of
|
||||
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be
|
||||
"photo widget A: advance" *and* "calendar widget B: advance" together, or
|
||||
even a mismatched combination on purpose. On a press,
|
||||
`routers/device.py`'s `_run_button_actions` runs every assigned action for
|
||||
that button in order (each in its own `widget_locked` span -- never nested,
|
||||
since the underlying per-frame lock isn't reentrant), catching and
|
||||
logging any single action's failure without blocking the rest, then
|
||||
re-renders and returns the whole composed panel once at the end regardless
|
||||
of which actions succeeded.
|
||||
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
|
||||
every widget on the frame that has one -- **at most one binding per
|
||||
widget per button** (a widget can't be bound to two different actions on
|
||||
the same button). On a press, `routers/device.py`'s `_run_button_actions`
|
||||
runs every widget's assigned action for that button (each in its own
|
||||
`widget_locked` span -- never nested, since the underlying per-frame lock
|
||||
isn't reentrant), catching and logging any single action's failure
|
||||
without blocking the rest, then re-renders and returns the whole composed
|
||||
panel once at the end regardless of which actions succeeded. Which
|
||||
widget's action runs first never matters -- each only touches its own
|
||||
state, and the shared re-render happens once, after all of them finish.
|
||||
|
||||
The web UI for this is the "Button assignments" card on a frame's
|
||||
Configuration tab (`static/frame_config.js`, `GET`/`PUT
|
||||
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of
|
||||
the same type would otherwise both just say "Photos" in the assignment
|
||||
dropdowns; the UI disambiguates using each widget's grid position (e.g.
|
||||
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them
|
||||
apart by eye on the Layout canvas.
|
||||
The UI for this lives in each widget's own gear-icon config dialog (the
|
||||
"Button actions" card, `templates/_widget_button_fields.html` +
|
||||
`static/widget_dialog_button_actions.js`, `POST
|
||||
/api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
|
||||
tab, since assigning a widget's next/back behavior is naturally part of
|
||||
configuring that widget. The card only renders for widget types with a
|
||||
non-empty `ACTIONS` (photos, calendar, whiteboard, weather); tasks/
|
||||
static/text/battery have nothing to bind so the card is omitted for
|
||||
them. An empty selection ("(none)") clears that button's binding for the
|
||||
widget.
|
||||
|
||||
A newly-created widget (including the one auto-migrated from a frame's old
|
||||
`mode` on upgrade) gets a sensible default binding reproducing its old
|
||||
button behavior -- see `migration.py`'s `_default_button_actions`.
|
||||
A newly-created widget (including the one auto-migrated from a frame's
|
||||
old `mode` on upgrade) gets a sensible default binding reproducing its
|
||||
old button behavior -- see `widgets.default_button_actions` (called from
|
||||
both `migration.py`'s backfill and `api_widgets.py`'s
|
||||
`api_widget_create`), so a widget is never left with nothing bound until
|
||||
someone deliberately reassigns it.
|
||||
|
||||
### Hold-for-global-action
|
||||
|
||||
Holding NEXT or BACK past a configurable duration (`Frame.hold_duration_ms`,
|
||||
minimum 3000ms, set on the Configuration tab) triggers a **global**
|
||||
action instead of the per-widget one -- not scoped to any widget, e.g.
|
||||
cycling through the user's saved layouts. See `app/global_actions.py`'s
|
||||
`GLOBAL_ACTIONS`/`GLOBAL_ACTION_LABELS` registry and
|
||||
`routers/device.py`'s `/frame/global-next`/`/frame/global-back` (the
|
||||
device calls these instead of `/frame/advance`/`/frame/back` once it
|
||||
detects a long press -- see `firmware/main/next_button.c`/`back_button.c`).
|
||||
`Frame.next_hold_action`/`back_hold_action` pick which registry entry (if
|
||||
any) each button's hold triggers; unset is a silent no-op, same
|
||||
convention as an unbound short-press button.
|
||||
|
||||
## Per-widget config UI
|
||||
|
||||
@@ -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
|
||||
greyed-out with a "different orientation" badge rather than hidden.
|
||||
|
||||
## Weather widget
|
||||
|
||||
A standalone widget type (`models.WeatherWidgetConfig`, `app/widgets/
|
||||
weather.py`) -- distinct from, and unrelated in code to,
|
||||
`CalendarWidgetConfig`'s own embedded weather strip (still present,
|
||||
still Open-Meteo-only, still working exactly as before). Four display
|
||||
modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
|
||||
`calendar_view`):
|
||||
|
||||
- `current` -- one city's current temp + a condition icon.
|
||||
- `hourly` -- one city, a row of ticks across the day at a configurable
|
||||
interval (`hourly_interval_hours`: 3/4/6/12).
|
||||
- `daily` -- one city, a multi-day strip (`daily_days`, 1-14).
|
||||
- `multi_city` -- several cities' current-day high/low/icon side by
|
||||
side -- the calendar widget's embedded strip, as a standalone
|
||||
widget's whole content instead of a strip above an agenda day.
|
||||
|
||||
`current`/`hourly`/`daily` share one configured location
|
||||
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
|
||||
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
|
||||
has its own list (`cities`, add/remove via `POST .../weather-widget-
|
||||
cities/add`|`remove` -- named to avoid colliding with the calendar
|
||||
widget's own, differently-scoped `weather-cities/add`|`remove` routes,
|
||||
which share the same `{widget_id}`-parameterized path shape).
|
||||
|
||||
**Providers** (`app/weather/`, a dispatch registry over pluggable
|
||||
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
|
||||
provider` selects which of `app/weather.PROVIDERS` actually fetches --
|
||||
`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
|
||||
only, no API key, approximates "current" with the first hourly forecast
|
||||
period rather than a real station observation), or `"ec"` (Environment
|
||||
Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
|
||||
Every provider function returns already-normalized `{"category": ...}`
|
||||
entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
|
||||
`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
|
||||
know which provider supplied an entry. `geocode_city` (name -> lat/lon)
|
||||
always goes through Open-Meteo's free geocoder regardless of which
|
||||
provider is chosen to fetch with the result.
|
||||
|
||||
EC's `citypageweather-realtime` collection is only queryable by bounding
|
||||
box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
|
||||
Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
|
||||
`_nearest_site` widens the box progressively and picks the closest site
|
||||
by straight-line distance, rejecting anything beyond 300 km (calibrated
|
||||
against a real bug caught in development: an unconditional "nearest
|
||||
site, however far" matched a Miami, FL query to a site in Ontario,
|
||||
1824 km away, once the box widened enough to cover the whole country).
|
||||
|
||||
`app/weather_render.py` holds every weather-related drawing primitive:
|
||||
`draw_weather_icon`/`draw_weather_row` (extracted out of
|
||||
`calendar_render.py`, which still imports `draw_weather_row` for its own
|
||||
embedded strip, unchanged) plus this widget's own `build_current`/
|
||||
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
|
||||
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
|
||||
`render_tasks_preview_png` relationship. Icons are hand-drawn (no custom
|
||||
font/icon asset), styled after Environment Canada's own icon set
|
||||
(pointed sun rays, a puffy cloud, teardrop rain, dendrite snowflakes, a
|
||||
zigzag bolt) but filled with the panel's *exact* ink RGB values rather
|
||||
than an arbitrary bitmap's anti-aliased colors -- a flat fill that's
|
||||
already a palette color quantizes with zero dithering error to diffuse,
|
||||
where a fetched/vendored icon's colors (almost never an exact match)
|
||||
dither into a visible speckle at these small on-panel sizes (confirmed
|
||||
by actually running one through the real quantize pass during
|
||||
development). Used for every provider's rendering, not just when EC is
|
||||
selected as the provider.
|
||||
|
||||
## Battery widget
|
||||
|
||||
The simplest widget type (`models.BatteryWidgetConfig`, `app/widgets/
|
||||
battery.py`): shows this frame's own last-reported battery level. Unlike
|
||||
every other widget type, there's no live upstream to poll and nothing to
|
||||
cache -- the content is `Frame.battery_percent`/`battery_as_of`, set by
|
||||
`routers/device.py`'s `frame_battery` on every device wake-on-battery
|
||||
report, which already existed for the Device panel's own history chart
|
||||
regardless of whether a battery widget is placed anywhere. The widget's
|
||||
own config is just a display mode: `"compact"` (icon + percent) or
|
||||
`"detailed"` (default, adds `routers/common.py`'s existing
|
||||
`battery_estimate_s` time-remaining estimate and the last report's age).
|
||||
`render()` falls back to a "No reports yet" placeholder for a frame that
|
||||
has never reported (never run on battery, or not yet claimed by a
|
||||
device) rather than showing a stale or fabricated number. The battery
|
||||
icon fill color (red/yellow/green by percent) uses the same exact-panel-
|
||||
ink-RGB approach as the weather icons above and `manage_overlay.py`'s own
|
||||
battery glyph on the "scan to manage" overlay -- a separate, unrelated
|
||||
piece of code with its own fixed small size, not shared with this
|
||||
widget, but drawing from the same thresholds/colors so a battery glyph
|
||||
reads the same wherever one shows up on a panel.
|
||||
|
||||
## Known gaps (Phase 6, not yet done)
|
||||
|
||||
The original 8-phase rollout plan's last phase is still open:
|
||||
|
||||
+33
-1
@@ -68,6 +68,7 @@ Under **ESPresso Frame Configuration**:
|
||||
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
|
||||
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
|
||||
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
|
||||
| `FRAME_HOLD_ACTION_MS` | 3000 | **Fallback only** -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below |
|
||||
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
|
||||
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
|
||||
|
||||
@@ -84,6 +85,34 @@ reflashing. The Kconfig value only applies before the device has ever
|
||||
successfully reached a configured server, or if the response doesn't
|
||||
include a valid interval.
|
||||
|
||||
### Holding NEXT/BACK for a global action
|
||||
|
||||
Past `FRAME_HOLD_ACTION_MS`, holding NEXT or BACK stops meaning "advance/
|
||||
back this widget" and instead triggers whatever frame-wide action (if
|
||||
any) is configured for that button's hold on the server's Configuration
|
||||
tab -- e.g. cycling through saved layouts (see
|
||||
`server/app/global_actions.py`). Fires immediately at the threshold,
|
||||
without waiting for release -- same convention as the combo button's
|
||||
factory-reset tier below.
|
||||
|
||||
Same "fallback only" caveat as `FRAME_SLEEP_INTERVAL_S` above, but with
|
||||
one more wrinkle: the server's actual `hold_duration_ms` (set on the
|
||||
Configuration tab, `GET /frame/config`'s response) can't be used for
|
||||
*this* wake's button decision -- that decision happens in `main.c`
|
||||
before WiFi even connects, but `/frame/config` isn't fetched until near
|
||||
the end of the wake cycle (after the image fetch, deliberately -- see
|
||||
`frame_client_run`'s own comment on why). So the device always acts on
|
||||
whatever value the *previous* wake fetched (persisted in NVS via
|
||||
`frame_config_set_hold_duration_ms`), falling back to
|
||||
`FRAME_HOLD_ACTION_MS` only before it's ever successfully fetched one.
|
||||
In practice this means changing the duration on the Configuration tab
|
||||
takes effect starting with the wake *after* the next one, not
|
||||
immediately.
|
||||
|
||||
Holding a button through the poll loop keeps the device awake and
|
||||
connected longer than a normal short-press wake -- the same tradeoff
|
||||
already accepted for the combo button's menu/reset holds below.
|
||||
|
||||
### WiFi fast-connect
|
||||
|
||||
After a successful home-WiFi connection, the device caches the AP's
|
||||
@@ -204,6 +233,8 @@ device (if asleep) and tells the server to advance to the next photo
|
||||
right away, regardless of the configured refresh interval -- no long hold
|
||||
needed, since advancing is easily reversible by pressing again. See
|
||||
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature.
|
||||
Holding it past `FRAME_HOLD_ACTION_MS` instead means something else
|
||||
entirely -- see "Holding NEXT/BACK for a global action" above.
|
||||
|
||||
Normal wakes and reboots never advance the photo on their own -- the
|
||||
server decides when to advance based on its own clock (see
|
||||
@@ -223,7 +254,8 @@ disable the feature.
|
||||
|
||||
If there's nothing to go back to yet (freshly provisioned, or you've
|
||||
already gone back as far as there is history), it's a no-op -- the
|
||||
current photo stays exactly as it was, no flash on the panel.
|
||||
current photo stays exactly as it was, no flash on the panel. Same
|
||||
long-hold caveat as the next-photo button above.
|
||||
|
||||
## Battery (XIAO ESP32-C6)
|
||||
|
||||
|
||||
@@ -188,6 +188,23 @@ menu "ESPresso Frame Configuration"
|
||||
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
|
||||
confused for each other.
|
||||
|
||||
config FRAME_HOLD_ACTION_MS
|
||||
int "Next/back hold-for-global-action duration (ms)"
|
||||
default 3000
|
||||
range 3000 10000
|
||||
help
|
||||
How long the NEXT or BACK button must be held before it
|
||||
triggers a frame-wide action (see server/app/global_actions.py
|
||||
-- e.g. cycling saved layouts) instead of that button's normal
|
||||
short-press behavior. Only a first-boot/never-connected
|
||||
fallback: once the device has fetched GET /frame/config at
|
||||
least once, the server's own Frame.hold_duration_ms (set on
|
||||
the Configuration tab) overrides this on every later boot --
|
||||
see wifi_provisioning.h's frame_config_get_hold_duration_ms.
|
||||
Floor matches the server's own minimum, so a long-held button
|
||||
never means something different depending on which value
|
||||
happened to apply.
|
||||
|
||||
config FRAME_BATTERY_ADC_GPIO
|
||||
int "Battery voltage-divider ADC GPIO (-1 to disable)"
|
||||
default -1
|
||||
|
||||
+36
-14
@@ -1,3 +1,5 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
@@ -5,6 +7,8 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "wifi_provisioning.h"
|
||||
|
||||
#include "back_button.h"
|
||||
|
||||
static const char *TAG = "back_button";
|
||||
@@ -14,6 +18,7 @@ static const char *TAG = "back_button";
|
||||
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
|
||||
#define BACK_BUTTON_DEBOUNCE_MS 20
|
||||
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
|
||||
#define BACK_BUTTON_POLL_MS 100
|
||||
|
||||
void back_button_init(void)
|
||||
{
|
||||
@@ -36,7 +41,7 @@ void back_button_init(void)
|
||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
}
|
||||
|
||||
bool back_button_check(void)
|
||||
back_button_result_t back_button_check(void)
|
||||
{
|
||||
/* A quick tap can easily release before this runs (~0.4-0.5s into
|
||||
* boot, confirmed on hardware -- a live gpio_get_level() check here
|
||||
@@ -44,32 +49,49 @@ bool back_button_check(void)
|
||||
* status register is latched at the moment of waking and isn't
|
||||
* cleared until the next sleep entry, so it reliably reflects a tap
|
||||
* regardless of how quickly it was released. */
|
||||
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) {
|
||||
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
|
||||
return true;
|
||||
}
|
||||
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
|
||||
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
|
||||
* power-on/reflash) -- fall back to a live, debounced level check so
|
||||
* holding the button down while powering on also works. */
|
||||
if (!caused_wake) {
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
|
||||
* fresh power-on/reflash) -- fall back to a live, debounced level
|
||||
* check so holding the button down while powering on also
|
||||
* works. */
|
||||
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
|
||||
return false;
|
||||
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 false; /* noise, not a real press */
|
||||
return BACK_BUTTON_NOT_PRESSED; /* noise, not a real press */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Back-photo button held during power-on, going back");
|
||||
return true;
|
||||
/* Confirmed pressed -- measure how long, same reasoning/pattern as
|
||||
* next_button_check(). */
|
||||
uint32_t hold_threshold_ms;
|
||||
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
|
||||
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
|
||||
}
|
||||
|
||||
uint32_t elapsed_ms = 0;
|
||||
while (gpio_get_level(BACK_BUTTON_GPIO) == 0) {
|
||||
if (elapsed_ms >= hold_threshold_ms) {
|
||||
ESP_LOGI(TAG, "Back button held past %ums, triggering global hold action",
|
||||
(unsigned)hold_threshold_ms);
|
||||
return BACK_BUTTON_HOLD;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_POLL_MS));
|
||||
elapsed_ms += BACK_BUTTON_POLL_MS;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Back-photo button short press (%ums), going back", (unsigned)elapsed_ms);
|
||||
return BACK_BUTTON_SHORT_PRESS;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void back_button_init(void) {}
|
||||
bool back_button_check(void) { return false; }
|
||||
back_button_result_t back_button_check(void) { return BACK_BUTTON_NOT_PRESSED; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,9 +12,23 @@
|
||||
*/
|
||||
void back_button_init(void);
|
||||
|
||||
typedef enum {
|
||||
BACK_BUTTON_NOT_PRESSED,
|
||||
/** A short press -- same immediate-response reasoning as the
|
||||
* next-photo button. */
|
||||
BACK_BUTTON_SHORT_PRESS,
|
||||
/** Held past the configured hold duration (see
|
||||
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
|
||||
* triggers a frame-wide action instead (see
|
||||
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_BACK).
|
||||
* Fires immediately at the threshold, without waiting for release. */
|
||||
BACK_BUTTON_HOLD,
|
||||
} back_button_result_t;
|
||||
|
||||
/**
|
||||
* Returns whether the back-photo button is currently held, debounced with
|
||||
* a couple of short re-checks to reject noise. Same immediate-response
|
||||
* reasoning as the next-photo button -- no long hold-to-confirm gate.
|
||||
* Checks the back-photo button and, if it's pressed at all, blocks
|
||||
* polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
|
||||
* or the hold duration elapses (BACK_BUTTON_HOLD) -- same pattern as
|
||||
* next_button_check(). Evaluated once per wake.
|
||||
*/
|
||||
bool back_button_check(void);
|
||||
back_button_result_t back_button_check(void);
|
||||
|
||||
@@ -276,6 +276,14 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
|
||||
typedef struct {
|
||||
bool reachable;
|
||||
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
|
||||
/* How long NEXT/BACK must be held to trigger a global action instead
|
||||
* of a short press (see next_button.h/back_button.h) --
|
||||
* CONFIG_FRAME_HOLD_ACTION_MS if absent/unparseable (older server) or
|
||||
* unreachable. Persisted via frame_config_set_hold_duration_ms() for
|
||||
* the *next* boot's button-hold decision -- this fetch happens too
|
||||
* late in the cycle for its own boot's decision, see that function's
|
||||
* own doc comment. */
|
||||
uint32_t hold_duration_ms;
|
||||
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
|
||||
/* Per-frame token the server pushes until this device has
|
||||
* authenticated with it once; empty when absent. Persisted via
|
||||
@@ -370,6 +378,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
frame_server_config_t result = {
|
||||
.reachable = false,
|
||||
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
|
||||
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
|
||||
};
|
||||
result.firmware_version[0] = '\0';
|
||||
result.device_token[0] = '\0';
|
||||
@@ -419,6 +428,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
|
||||
(int)result.refresh_interval_s);
|
||||
}
|
||||
uint32_t hold_ms;
|
||||
if (json_extract_uint(body, "hold_duration_ms", &hold_ms)) {
|
||||
result.hold_duration_ms = hold_ms;
|
||||
}
|
||||
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
|
||||
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
|
||||
|
||||
@@ -443,12 +456,13 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
||||
return n > 0 ? (size_t)n : 0;
|
||||
}
|
||||
|
||||
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
|
||||
* /frame/back to force a move in either direction (FETCH_ADVANCE /
|
||||
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the
|
||||
* manage button) appends &manage=1, telling the server to bake its
|
||||
* overlay into this same response instead of returning the bare
|
||||
* content -- see server/app/routers/device.py. Returning non-ESP_OK
|
||||
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
|
||||
* /frame/global-next, or /frame/global-back to force a move/action
|
||||
* (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
|
||||
* FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
|
||||
* manage=true (the manage button) appends &manage=1, telling the server
|
||||
* to bake its overlay into this same response instead of returning the
|
||||
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK
|
||||
* means the panel was never actually refreshed -- epd_display_stream()
|
||||
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
|
||||
* wrong-size stream, so a failure here always leaves the visible screen
|
||||
@@ -460,6 +474,10 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
|
||||
path = "frame/advance";
|
||||
} else if (action == FETCH_BACK) {
|
||||
path = "frame/back";
|
||||
} else if (action == FETCH_GLOBAL_NEXT) {
|
||||
path = "frame/global-next";
|
||||
} else if (action == FETCH_GLOBAL_BACK) {
|
||||
path = "frame/global-back";
|
||||
}
|
||||
|
||||
char url[256];
|
||||
@@ -720,6 +738,11 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
report_battery(cfg, battery_percent);
|
||||
frame_server_config_t server_cfg = fetch_frame_config(cfg);
|
||||
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
|
||||
if (server_cfg.reachable) {
|
||||
/* For next boot's button-hold decision, not this one -- see
|
||||
* frame_config_get_hold_duration_ms()'s own doc comment. */
|
||||
frame_config_set_hold_duration_ms(server_cfg.hold_duration_ms);
|
||||
}
|
||||
|
||||
/* One-time identity handshake: the server pushes this frame's
|
||||
* own token until we've authenticated with it once. Persist it
|
||||
|
||||
@@ -9,14 +9,19 @@
|
||||
* Which photo-fetch behavior this wake cycle should use -- normally the
|
||||
* idempotent GET /frame/image (the server decides on its own whether to
|
||||
* advance, based on its configured refresh interval, so a plain
|
||||
* wake/reboot never skips a photo just by asking), or POST
|
||||
* /frame/advance / POST /frame/back to force a move in either direction
|
||||
* (the next-photo / back-photo buttons).
|
||||
* wake/reboot never skips a photo just by asking), POST /frame/advance /
|
||||
* POST /frame/back to force a move in either direction (a short press of
|
||||
* the next-photo / back-photo buttons), or POST /frame/global-next /
|
||||
* POST /frame/global-back to run whatever frame-wide action (if any) is
|
||||
* configured for a held press (see next_button.h/back_button.h's
|
||||
* *_HOLD result and app/global_actions.py server-side).
|
||||
*/
|
||||
typedef enum {
|
||||
FETCH_NORMAL,
|
||||
FETCH_ADVANCE,
|
||||
FETCH_BACK,
|
||||
FETCH_GLOBAL_NEXT,
|
||||
FETCH_GLOBAL_BACK,
|
||||
} fetch_action_t;
|
||||
|
||||
/**
|
||||
|
||||
+13
-4
@@ -40,12 +40,21 @@ void app_main(void)
|
||||
back_button_init();
|
||||
combo_button_init();
|
||||
|
||||
bool next_pressed = next_button_check();
|
||||
bool back_pressed = back_button_check();
|
||||
next_button_result_t next_result = next_button_check();
|
||||
back_button_result_t back_result = back_button_check();
|
||||
/* Next takes priority over back if somehow both read pressed at once
|
||||
* (e.g. both held through a power-on) -- an arbitrary but
|
||||
* deterministic tie-break, not expected to matter in practice. */
|
||||
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
|
||||
* deterministic tie-break, not expected to matter in practice. Same
|
||||
* priority applies whether the winning button resolved to a short
|
||||
* press or a hold. */
|
||||
fetch_action_t action;
|
||||
if (next_result != NEXT_BUTTON_NOT_PRESSED) {
|
||||
action = (next_result == NEXT_BUTTON_HOLD) ? FETCH_GLOBAL_NEXT : FETCH_ADVANCE;
|
||||
} else if (back_result != BACK_BUTTON_NOT_PRESSED) {
|
||||
action = (back_result == BACK_BUTTON_HOLD) ? FETCH_GLOBAL_BACK : FETCH_BACK;
|
||||
} else {
|
||||
action = FETCH_NORMAL;
|
||||
}
|
||||
/* Soft-resets or clears config + restarts internally for a medium/
|
||||
* long hold and never returns in those cases -- only returns here
|
||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||
|
||||
+41
-14
@@ -1,3 +1,5 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
@@ -5,6 +7,8 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "wifi_provisioning.h"
|
||||
|
||||
#include "next_button.h"
|
||||
|
||||
static const char *TAG = "next_button";
|
||||
@@ -14,6 +18,7 @@ static const char *TAG = "next_button";
|
||||
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
|
||||
#define NEXT_BUTTON_DEBOUNCE_MS 20
|
||||
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
|
||||
#define NEXT_BUTTON_POLL_MS 100
|
||||
|
||||
void next_button_init(void)
|
||||
{
|
||||
@@ -40,7 +45,7 @@ void next_button_init(void)
|
||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
}
|
||||
|
||||
bool next_button_check(void)
|
||||
next_button_result_t next_button_check(void)
|
||||
{
|
||||
/* A quick tap can easily release before this runs (~0.4-0.5s into
|
||||
* boot, confirmed on hardware -- a live gpio_get_level() check here
|
||||
@@ -48,32 +53,54 @@ bool next_button_check(void)
|
||||
* status register is latched at the moment of waking and isn't
|
||||
* cleared until the next sleep entry, so it reliably reflects a tap
|
||||
* regardless of how quickly it was released. */
|
||||
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) {
|
||||
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
|
||||
return true;
|
||||
}
|
||||
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
|
||||
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
|
||||
* power-on/reflash) -- fall back to a live, debounced level check so
|
||||
* holding the button down while powering on also works. */
|
||||
if (!caused_wake) {
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
|
||||
* fresh power-on/reflash) -- fall back to a live, debounced level
|
||||
* check so holding the button down while powering on also
|
||||
* works. */
|
||||
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
|
||||
return false;
|
||||
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 false; /* noise, not a real press */
|
||||
return NEXT_BUTTON_NOT_PRESSED; /* noise, not a real press */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance");
|
||||
return true;
|
||||
/* Confirmed pressed (either the wake cause, or debounced during
|
||||
* power-on) -- measure how long, same polling pattern as
|
||||
* combo_button.c's own hold-tier detection. Reads the last hold
|
||||
* duration the server reported (persisted from a previous cycle,
|
||||
* see frame_config_get_hold_duration_ms's own doc comment), falling
|
||||
* back to the Kconfig default before the device has ever fetched
|
||||
* one. */
|
||||
uint32_t hold_threshold_ms;
|
||||
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
|
||||
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
|
||||
}
|
||||
|
||||
uint32_t elapsed_ms = 0;
|
||||
while (gpio_get_level(NEXT_BUTTON_GPIO) == 0) {
|
||||
if (elapsed_ms >= hold_threshold_ms) {
|
||||
ESP_LOGI(TAG, "Next button held past %ums, triggering global hold action",
|
||||
(unsigned)hold_threshold_ms);
|
||||
return NEXT_BUTTON_HOLD;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_POLL_MS));
|
||||
elapsed_ms += NEXT_BUTTON_POLL_MS;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Next-photo button short press (%ums), forcing advance", (unsigned)elapsed_ms);
|
||||
return NEXT_BUTTON_SHORT_PRESS;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void next_button_init(void) {}
|
||||
bool next_button_check(void) { return false; }
|
||||
next_button_result_t next_button_check(void) { return NEXT_BUTTON_NOT_PRESSED; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,10 +12,27 @@
|
||||
*/
|
||||
void next_button_init(void);
|
||||
|
||||
typedef enum {
|
||||
NEXT_BUTTON_NOT_PRESSED,
|
||||
/** A short press -- advancing a photo is low-stakes and should feel
|
||||
* immediate, so this fires the moment the button releases (or right
|
||||
* away for a wake-triggered press, once it's confirmed not a hold). */
|
||||
NEXT_BUTTON_SHORT_PRESS,
|
||||
/** Held past the configured hold duration (see
|
||||
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
|
||||
* triggers a frame-wide action instead (see
|
||||
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_NEXT).
|
||||
* Fires immediately at the threshold, without waiting for release --
|
||||
* same convention as combo_button.c's factory-reset tier. */
|
||||
NEXT_BUTTON_HOLD,
|
||||
} next_button_result_t;
|
||||
|
||||
/**
|
||||
* Returns whether the next-photo button is currently held, debounced with
|
||||
* a couple of short re-checks to reject noise. No long hold-to-confirm
|
||||
* gate -- advancing a photo is low-stakes and should feel immediate, so
|
||||
* this returns right away either way.
|
||||
* Checks the next-photo button and, if it's pressed at all (either what
|
||||
* caused this wake, per the latched wakeup-status register, or held
|
||||
* through a debounced power-on check), blocks polling its level until
|
||||
* either it's released (NEXT_BUTTON_SHORT_PRESS) or the hold duration
|
||||
* elapses (NEXT_BUTTON_HOLD, returned immediately, not waiting for
|
||||
* release). Evaluated once per wake.
|
||||
*/
|
||||
bool next_button_check(void);
|
||||
next_button_result_t next_button_check(void);
|
||||
|
||||
@@ -234,6 +234,29 @@ void frame_config_invalidate_last_display_crc32(void)
|
||||
nvs_close(handle);
|
||||
}
|
||||
|
||||
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
err = nvs_get_u32(handle, "hold_ms", out);
|
||||
nvs_close(handle);
|
||||
return err;
|
||||
}
|
||||
|
||||
void frame_config_set_hold_duration_ms(uint32_t ms)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
return;
|
||||
}
|
||||
nvs_set_u32(handle, "hold_ms", ms);
|
||||
nvs_commit(handle);
|
||||
nvs_close(handle);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
* WiFi fast-connect cache
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
@@ -94,6 +94,27 @@ void frame_config_set_last_display_crc32(uint32_t crc32);
|
||||
*/
|
||||
void frame_config_invalidate_last_display_crc32(void);
|
||||
|
||||
/**
|
||||
* Returns the hold_duration_ms the server most recently reported via GET
|
||||
* /frame/config (see frame_client.c's fetch_frame_config/frame_client_run)
|
||||
* -- how long NEXT/BACK must be held before next_button_check()/
|
||||
* back_button_check() treat it as a hold-for-global-action instead of a
|
||||
* short press. Returns ESP_ERR_NVS_NOT_FOUND if the device has never
|
||||
* fetched one yet (fresh install/factory reset); caller should fall back
|
||||
* to CONFIG_FRAME_HOLD_ACTION_MS in that case.
|
||||
*
|
||||
* Deliberately a *previous* cycle's value: this cycle's own button
|
||||
* decision happens in main.c before WiFi even connects, but
|
||||
* /frame/config isn't fetched until near the end of frame_client_run
|
||||
* (after the image fetch, for connection-warmth/timeout reasons -- see
|
||||
* its own comment) -- so there's no same-cycle fresh value to use yet.
|
||||
*/
|
||||
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out);
|
||||
|
||||
/** Persists the hold duration reported by the server, for the *next*
|
||||
* boot's button-hold decision to use. */
|
||||
void frame_config_set_hold_duration_ms(uint32_t ms);
|
||||
|
||||
/**
|
||||
* Returns this device's provisioning AP identity: a fixed SSID (from
|
||||
* Kconfig) and a password that's generated once on first use and persisted
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.3.0
|
||||
1.4.0
|
||||
|
||||
+29
-25
@@ -2,34 +2,38 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# tzdata/fonts in their own layer, kept separate from the much larger
|
||||
# Node.js/npm layers below -- see those layers' own comments for why
|
||||
# they're split up the way they are. tzdata: python:3.12-slim doesn't
|
||||
# include it by default, so the zoneinfo database backing the web UI's
|
||||
# "Timezone" setting (used by "Quiet hours") would have no named zones
|
||||
# to resolve without this -- ZoneInfo() would raise for anything other
|
||||
# than "UTC". fontconfig/fonts-dejavu-core: whiteboard mode's
|
||||
# render-service/ (own README there) needs something to render
|
||||
# whiteboard text with.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata fontconfig fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# tzdata/fonts/Node.js all from Debian's own repo in one layer -- no
|
||||
# external curl/gnupg dance needed (see below for why that changed).
|
||||
# tzdata: python:3.12-slim doesn't include it by default, so the
|
||||
# zoneinfo database backing the web UI's "Timezone" setting (used by
|
||||
# "Quiet hours") would have no named zones to resolve without this --
|
||||
# ZoneInfo() would raise for anything other than "UTC".
|
||||
# fontconfig/fonts-dejavu-core: whiteboard mode's render-service/ (own
|
||||
# README there) needs something to render whiteboard text with.
|
||||
#
|
||||
# Node.js: whiteboard frame mode's render-service/ runs as a second
|
||||
# process in this same container rather than a separate compose service
|
||||
# -- it's a lightweight, stateless, localhost-only sidecar with nothing
|
||||
# worth independently scaling or restarting. NodeSource's setup script is
|
||||
# used instead of Debian bookworm's own apt Node package, which is both
|
||||
# older than jsdom's minimum (20.19+) and inconsistently available.
|
||||
# curl/gnupg are only needed to add and fetch NodeSource's repo -- purged
|
||||
# again in this same RUN (not a later one; Docker layers are immutable,
|
||||
# so removing them in a *different* instruction wouldn't shrink this
|
||||
# one's actual pushed size) so their bytes don't end up in the image at
|
||||
# all, only nodejs's.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates gnupg \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& apt-get purge -y --auto-remove curl gnupg \
|
||||
# worth independently scaling or restarting. Used to be installed via
|
||||
# NodeSource's setup script (Debian's own nodejs package was too old for
|
||||
# jsdom's minimum back when this base image tracked Debian bookworm) --
|
||||
# switched to Debian's own `nodejs`/`npm` packages after NodeSource's
|
||||
# deb.nodesource.com started intermittently 403ing on both its setup_*.x
|
||||
# scripts *and* its GPG key (a live NodeSource-side S3/CDN issue,
|
||||
# confirmed 2026-07-27 by hitting deb.nodesource.com directly -- some
|
||||
# setup_NN.x paths 403, others 200, no consistent pattern, so no
|
||||
# NodeSource-hosted install path could be trusted not to silently break
|
||||
# again). This base image now tracks Debian trixie, whose own `nodejs`
|
||||
# package is 20.19.2 -- inside jsdom 29's stated engines range
|
||||
# (`^20.19.0 || ^22.13.0 || >=24.0.0`) and well above express/resvg-js's
|
||||
# much lower floors -- so there's no longer a version gap to route
|
||||
# around NodeSource for. One less external dependency, and no more
|
||||
# curl-piped-into-bash (that pattern is also what let the NodeSource
|
||||
# failure go undetected here in the first place: `curl -f ... | bash -`
|
||||
# on a 403 hands bash an empty, "successful" script instead of failing
|
||||
# the RUN outright).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata fontconfig fonts-dejavu-core nodejs npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
+11
-9
@@ -121,9 +121,11 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
this response may grow.
|
||||
- `GET /frame/photo-info` -- location/date overlay text for the manage
|
||||
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
|
||||
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
|
||||
share link and 302s to it; scoped to the photo currently showing or
|
||||
queued on *this* frame only.
|
||||
- `GET /frame/share/{manage_token}` -- creates a 30-minute public Immich
|
||||
share link covering every photo widget's currently-showing photo on
|
||||
*this* frame and 302s to it. Authenticated by the frame's own
|
||||
`manage_token` (see the manage QR below), not device credentials -- a
|
||||
phone scanning the QR has no way to supply `?id=`/`?token=`.
|
||||
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
|
||||
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
||||
flat-scalar parser.
|
||||
@@ -200,12 +202,12 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
in the web UI (or using "Show next") only rearranges what's already in
|
||||
that lookahead; it doesn't add or remove photos from the album.
|
||||
- Auth in one breath: browsers use sessions (+CSRF), devices use
|
||||
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
|
||||
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
|
||||
credential for pre-multi-frame firmware. `/frame/share` stays scoped
|
||||
to photos this frame is actually showing or has queued, not any
|
||||
Immich asset ID someone might guess -- a second layer a leaked device
|
||||
token alone wouldn't bypass.
|
||||
per-frame tokens (`?id=` + `?token=`), the manage QR and the
|
||||
scan-to-download QR both use the frame's own `manage_token` (device
|
||||
tokens don't work for either -- neither is ever called by firmware,
|
||||
both are opened by a phone that has no way to supply `?id=`/`?token=`),
|
||||
and `MANAGEMENT_TOKEN` survives only as the migration credential for
|
||||
pre-multi-frame firmware.
|
||||
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
|
||||
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
|
||||
which is LGPL-3.0-or-later -- the only non-permissively-licensed
|
||||
|
||||
@@ -35,6 +35,7 @@ from .image_pipeline import (
|
||||
logical_render_size,
|
||||
)
|
||||
from .weather import weather_category
|
||||
from .weather_render import draw_weather_row
|
||||
|
||||
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
||||
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
||||
@@ -389,98 +390,6 @@ def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]
|
||||
return entries
|
||||
|
||||
|
||||
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
|
||||
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
|
||||
with a clean outline -- drawn as one black pass slightly larger than
|
||||
the shapes, then the same shapes again in white on top. Overlapping
|
||||
ellipses each drawn with their own `outline=` would leave visible
|
||||
seams where they cross; this double-draw trick sidesteps that
|
||||
entirely regardless of how the lobes overlap."""
|
||||
stroke = 2
|
||||
lobes = [
|
||||
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
|
||||
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
|
||||
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
|
||||
]
|
||||
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
|
||||
for x0, y0, x1, y1 in lobes:
|
||||
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
|
||||
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
|
||||
for x0, y0, x1, y1 in lobes:
|
||||
draw.ellipse([x0, y0, x1, y1], fill=BG)
|
||||
draw.rectangle(base, fill=BG)
|
||||
|
||||
|
||||
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
|
||||
"""A small hand-drawn glyph for one weather category -- no custom
|
||||
font/icon asset, same hand-primitives-only approach the rest of this
|
||||
module uses (colored rectangles for owner indicators, density dots
|
||||
for month view)."""
|
||||
if category == "clear":
|
||||
# Kept within a ~1.1r visual radius overall (rays included) to
|
||||
# match _draw_cloud's own footprint -- _draw_weather_row lays
|
||||
# icons out assuming each one stays roughly within icon_r of its
|
||||
# center, and the first entry in a row sits flush against the
|
||||
# region's own left margin, so any icon that draws wider than
|
||||
# that pokes out past it with nothing to visually connect to.
|
||||
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
|
||||
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
|
||||
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
|
||||
fill=FG, width=3)
|
||||
return
|
||||
|
||||
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
|
||||
if category == "partly_cloudy":
|
||||
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
|
||||
_draw_cloud(draw, cx, cloud_cy, r)
|
||||
|
||||
if category == "fog":
|
||||
for i in range(3):
|
||||
y = cy + r * 0.5 + i * (r * 0.45)
|
||||
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
|
||||
elif category == "rain":
|
||||
for dx in (-0.6, 0, 0.6):
|
||||
x = cx + dx * r
|
||||
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
|
||||
elif category == "snow":
|
||||
for dx in (-0.6, 0, 0.6):
|
||||
x, y = cx + dx * r, cloud_cy + r * 0.9
|
||||
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
|
||||
elif category == "thunderstorm":
|
||||
x, y = cx, cloud_cy + r * 0.5
|
||||
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
|
||||
fill=FG, width=2)
|
||||
|
||||
|
||||
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
|
||||
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
|
||||
show_labels: bool = True) -> int:
|
||||
"""Draws one or more cities' weather side by side starting at
|
||||
(x0, y0), stopping once another entry wouldn't fit within max_w
|
||||
(narrow views like week columns just end up showing fewer cities --
|
||||
same graceful-degradation approach month view takes with density
|
||||
dots). Returns the row height consumed (0 if there was nothing to
|
||||
draw, so callers can skip reserving space entirely)."""
|
||||
if not entries:
|
||||
return 0
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
row_h = icon_r * 2 + 8
|
||||
x = x0
|
||||
drew_any = False
|
||||
for entry in entries:
|
||||
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
|
||||
label = f"{entry['label']} {temps}" if show_labels else temps
|
||||
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
|
||||
if drew_any and x + entry_w > x0 + max_w:
|
||||
break
|
||||
cx, cy = x + icon_r, y0 + icon_r
|
||||
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
|
||||
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
|
||||
x += entry_w
|
||||
drew_any = True
|
||||
return row_h + 6
|
||||
|
||||
|
||||
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
|
||||
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
|
||||
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
||||
@@ -504,8 +413,9 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
||||
|
||||
weather_entries = _weather_for_day(weather_cities, day)
|
||||
if weather_entries:
|
||||
y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
||||
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)
|
||||
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
||||
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
|
||||
palette_rgb=palette_rgb)
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
row_h = body_font.size + 14
|
||||
@@ -720,13 +630,15 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
|
||||
y = MARGIN + header_h
|
||||
# Columns are narrow, so only what actually fits gets drawn (see
|
||||
# _draw_weather_row) -- typically one city, no label (the column
|
||||
# itself makes which day it's for obvious; a city name wouldn't fit
|
||||
# anyway). Never more than that -- this is already the tight view.
|
||||
# weather_render.draw_weather_row) -- typically one city, no label
|
||||
# (the column itself makes which day it's for obvious; a city name
|
||||
# wouldn't fit anyway). Never more than that -- this is already
|
||||
# the tight view.
|
||||
weather_entries = _weather_for_day(weather_cities, day)
|
||||
if weather_entries:
|
||||
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
||||
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
|
||||
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
||||
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
||||
palette_rgb=palette_rgb)
|
||||
row_h = chip_font.size + 10
|
||||
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
|
||||
@@ -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
@@ -24,7 +24,16 @@ GRID_SHORT = 5
|
||||
# scaling (see calendar_render.py); whiteboard needs enough room to be
|
||||
# worth looking at; photos can go as small as a single cell; tasks needs
|
||||
# enough width for a due-date prefix plus a couple words of summary
|
||||
# without truncating on every row.
|
||||
# without truncating on every row; weather needs enough room for its
|
||||
# hourly/daily strips to stay legible (its current/multi_city modes
|
||||
# would tolerate smaller, but every mode shares one footprint value).
|
||||
# battery is just an icon + a percent (+ two optional small lines in
|
||||
# "detailed" mode) -- legible even at a single cell, like photos/static.
|
||||
# NOTE: a 1x1 widget-box on a narrow mobile canvas can clip its own
|
||||
# gear/remove buttons behind theme.css's overflow: hidden (their fixed
|
||||
# pixel offsets overflow the box's clipped width) -- a pre-existing
|
||||
# layout gap that already affects photos/static at 1x1 too, not fixed
|
||||
# here; see the finding called out where this was discovered.
|
||||
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||
"photos": (1, 1),
|
||||
"calendar": (3, 2),
|
||||
@@ -32,6 +41,8 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||
"tasks": (2, 2),
|
||||
"static": (1, 1),
|
||||
"text": (2, 1),
|
||||
"weather": (2, 2),
|
||||
"battery": (1, 1),
|
||||
}
|
||||
|
||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
|
||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
||||
|
||||
@@ -32,6 +33,83 @@ def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.
|
||||
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
|
||||
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
|
||||
|
||||
|
||||
def _dashed_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
|
||||
width: int, color: tuple[int, int, int], dash: float, gap: float) -> None:
|
||||
length = math.hypot(x1 - x0, y1 - y0)
|
||||
if length <= 0:
|
||||
return
|
||||
ux, uy = (x1 - x0) / length, (y1 - y0) / length
|
||||
pos = 0.0
|
||||
while pos < length:
|
||||
end = min(pos + dash, length)
|
||||
draw.line([(x0 + ux * pos, y0 + uy * pos), (x0 + ux * end, y0 + uy * end)], fill=color, width=width)
|
||||
pos += dash + gap
|
||||
|
||||
|
||||
def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
|
||||
width: int, color: tuple[int, int, int], spacing: float) -> None:
|
||||
length = math.hypot(x1 - x0, y1 - y0)
|
||||
if length <= 0:
|
||||
return
|
||||
ux, uy = (x1 - x0) / length, (y1 - y0) / length
|
||||
r = max(1, width / 2)
|
||||
pos = 0.0
|
||||
while pos <= length:
|
||||
cx, cy = x0 + ux * pos, y0 + uy * pos
|
||||
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
|
||||
pos += spacing
|
||||
|
||||
|
||||
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int]) -> None:
|
||||
"""Draws a border inset within img's own bounds, mutating it in
|
||||
place -- called once per widget's own region (routers/device.py's
|
||||
_render_widgets, and each widget type's own dialog preview) before
|
||||
that region's image is pasted onto the shared canvas, so a border
|
||||
never straddles the boundary between two adjacent widgets. `color`
|
||||
should already be an exact palette RGB (see resolve_border_color) so
|
||||
the stroke quantizes with zero dithering error, same reasoning as
|
||||
the weather/battery icons' exact-panel-ink-RGB fills.
|
||||
|
||||
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
|
||||
just inside the image's edge; "fancy" is two thinner concentric
|
||||
strokes with a gap between them, picture-frame-mat style. "none" (or
|
||||
a non-positive thickness) draws nothing."""
|
||||
if style == "none" or thickness <= 0:
|
||||
return
|
||||
w, h = img.size
|
||||
t = max(1, min(int(thickness), min(w, h) // 2))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if style == "fancy":
|
||||
line_t = max(1, t // 3)
|
||||
gap = max(2, t - 2 * line_t)
|
||||
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
|
||||
inset = line_t + gap
|
||||
if w - 2 * inset > 1 and h - 2 * inset > 1:
|
||||
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
|
||||
return
|
||||
|
||||
if style == "solid":
|
||||
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
|
||||
return
|
||||
|
||||
# dashed/dotted trace the same centered-on-the-edge path solid/
|
||||
# fancy's rectangle outline draws, so all four styles sit at the
|
||||
# same inset regardless of which is chosen.
|
||||
half = t / 2
|
||||
x0, y0, x1, y1 = half, half, w - 1 - half, h - 1 - half
|
||||
edges = [(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)]
|
||||
if style == "dashed":
|
||||
dash, gap = t * 3, t * 2
|
||||
for ex0, ey0, ex1, ey1 in edges:
|
||||
_dashed_edge(draw, ex0, ey0, ex1, ey1, t, color, dash, gap)
|
||||
elif style == "dotted":
|
||||
spacing = max(t * 2, t + 4)
|
||||
for ex0, ey0, ex1, ey1 in edges:
|
||||
_dotted_edge(draw, ex0, ey0, ex1, ey1, t, color, spacing)
|
||||
|
||||
|
||||
# How each orientation maps the logically-composed image onto the native
|
||||
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
||||
# crop ratio matches how the frame actually hangs) and rotate into native
|
||||
@@ -94,6 +172,21 @@ PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||
# upstream.
|
||||
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
||||
|
||||
# Per-widget optional border (models.Widget.border_style, see
|
||||
# draw_widget_border below). "none" is the default/no-op; the rest are
|
||||
# thickness-px strokes inset within the widget's own region.
|
||||
BORDER_STYLES = ["none", "solid", "dashed", "dotted", "fancy"]
|
||||
BORDER_STYLE_LABELS = {
|
||||
"none": "None",
|
||||
"solid": "Solid",
|
||||
"dashed": "Dashed",
|
||||
"dotted": "Dotted",
|
||||
"fancy": "Fancy (double line)",
|
||||
}
|
||||
MIN_BORDER_THICKNESS = 1
|
||||
MAX_BORDER_THICKNESS = 8
|
||||
DEFAULT_BORDER_THICKNESS = 3
|
||||
|
||||
|
||||
def palette_to_hex(palette_rgb: list) -> list[str]:
|
||||
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
|
||||
@@ -101,6 +194,21 @@ def palette_to_hex(palette_rgb: list) -> list[str]:
|
||||
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
|
||||
|
||||
|
||||
def resolve_border_color(color_index: int, palette_rgb: list | None) -> tuple[int, int, int]:
|
||||
"""Widget.border_color_index -> an actual RGB tuple, against this
|
||||
frame's tuned palette if it has one (falls back to
|
||||
DEFAULT_PALETTE_RGB) -- so a border always renders as one of the
|
||||
panel's real 6 ink colors and never needs to be dithered, same
|
||||
reasoning as the weather/battery icons' exact-panel-ink-RGB fills
|
||||
(see docs/widgets.md). Out-of-range indexes (a stale value from a
|
||||
frame that used to have more colors, though that never happens
|
||||
today) fall back to Black rather than raising."""
|
||||
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
||||
if 0 <= color_index < len(palette):
|
||||
return tuple(palette[color_index])
|
||||
return tuple(palette[0])
|
||||
|
||||
|
||||
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
||||
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
|
||||
a 6-hex-digit color (what <input type="color"> always sends, but a
|
||||
|
||||
@@ -80,10 +80,10 @@ class ImmichClient:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def create_share_link(self, asset_id: str, expires_in_s: int) -> str:
|
||||
"""Creates a public, view-only Immich share link for a single
|
||||
asset, expiring expires_in_s seconds from now, and returns its
|
||||
public URL. Used by the manage-button overlay's share QR --
|
||||
def create_share_link(self, asset_ids: list[str], expires_in_s: int) -> str:
|
||||
"""Creates a public, view-only Immich share link covering one or
|
||||
more assets, expiring expires_in_s seconds from now, and returns
|
||||
its public URL. Used by the manage-button overlay's share QR --
|
||||
created lazily (only when someone actually scans it), not when
|
||||
the button's pressed, so the expiry clock starts when it's
|
||||
actually used."""
|
||||
@@ -93,7 +93,7 @@ class ImmichClient:
|
||||
headers=self._headers,
|
||||
json={
|
||||
"type": "INDIVIDUAL",
|
||||
"assetIds": [asset_id],
|
||||
"assetIds": list(asset_ids),
|
||||
"expiresAt": expires_at,
|
||||
"allowUpload": False,
|
||||
"allowDownload": True,
|
||||
|
||||
+161
-25
@@ -24,7 +24,6 @@ from .models import (
|
||||
BatteryLog,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
ServerSettings,
|
||||
@@ -32,6 +31,7 @@ from .models import (
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
from .widgets import default_button_actions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -385,8 +385,9 @@ def _migration_17(conn) -> None:
|
||||
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
||||
), {"frame_id": row["frame_id"]}).scalar()
|
||||
result = conn.execute(text(
|
||||
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
|
||||
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
|
||||
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
|
||||
"border_style, border_thickness, border_color_index) "
|
||||
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at, 'none', 3, 0)"
|
||||
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
||||
"sort_order": max_sort + 1, "created_at": now})
|
||||
new_widget_id = result.lastrowid
|
||||
@@ -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 = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -648,6 +798,12 @@ MIGRATIONS = [
|
||||
(21, _migration_21),
|
||||
(22, _migration_22),
|
||||
(23, _migration_23),
|
||||
(24, _migration_24),
|
||||
(25, _migration_25),
|
||||
(26, _migration_26),
|
||||
(27, _migration_27),
|
||||
(28, _migration_28),
|
||||
(29, _migration_29),
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""Only relevant for a database jumping straight from before the
|
||||
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.add(_calendar_config_from_frame(frame, cal_widget.id))
|
||||
db.add(_photo_config_from_frame(frame, photo_widget.id))
|
||||
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
|
||||
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
|
||||
_maybe_add_legacy_tasks_widget(
|
||||
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
|
||||
)
|
||||
@@ -943,7 +1079,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
|
||||
db.add(_calendar_config_from_frame(frame, widget.id))
|
||||
elif mode == "whiteboard":
|
||||
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
||||
db.add_all(_default_button_actions(frame.id, widget.id, mode))
|
||||
db.add_all(default_button_actions(frame.id, widget.id, mode))
|
||||
if mode == "calendar":
|
||||
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
|
||||
|
||||
|
||||
+96
-9
@@ -311,6 +311,23 @@ class Frame(Base):
|
||||
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# -- hold-for-global-action (see app/global_actions.py) -- holding
|
||||
# NEXT/BACK past hold_duration_ms triggers a global action instead of
|
||||
# the per-widget one that a short press runs (models.FrameButtonAction).
|
||||
# Not scoped to any widget, e.g. cycling saved layouts -- hence its
|
||||
# own pair of frame-level columns rather than living in that table.
|
||||
hold_duration_ms: Mapped[int] = mapped_column(Integer, default=3000)
|
||||
next_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
back_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
# Where "cycle saved layouts" resumes from -- the last SavedLayout id
|
||||
# it applied, so repeated holds advance through the list instead of
|
||||
# re-applying the same one every time. Deliberately not a real FK:
|
||||
# this is just a resume cursor, not a relationship needing cascade/
|
||||
# referential integrity -- if that layout's since been deleted or
|
||||
# renamed away, global_actions.cycle_layout just doesn't find it and
|
||||
# starts over from the first one, same as an unset value.
|
||||
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# -- stats (flattened from the old nested FrameStats) --
|
||||
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -433,7 +450,7 @@ class Widget(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
|
||||
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather" | "battery"
|
||||
x: Mapped[int] = mapped_column(Integer)
|
||||
y: Mapped[int] = mapped_column(Integer)
|
||||
w: Mapped[int] = mapped_column(Integer)
|
||||
@@ -445,6 +462,20 @@ class Widget(Base):
|
||||
# table needs to match a specific attribute name here.
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
# Optional decorative border, drawn once around this widget's own
|
||||
# region (routers/device.py's _render_widgets) regardless of
|
||||
# widget_type -- a Widget-level property, not a per-type config
|
||||
# column, since every widget type can have one. See
|
||||
# image_pipeline.BORDER_STYLES/draw_widget_border. "none" (the
|
||||
# default) draws nothing, so existing widgets don't suddenly grow a
|
||||
# border. border_color_index indexes into the frame's palette_rgb
|
||||
# (0-5, Black/White/Yellow/Red/Blue/Green) rather than storing an
|
||||
# arbitrary hex -- an exact palette color quantizes with zero
|
||||
# dithering error, same reasoning as the weather/battery icons'
|
||||
# exact-panel-ink-RGB fills (see docs/widgets.md).
|
||||
border_style: Mapped[str] = mapped_column(String, default="none")
|
||||
border_thickness: Mapped[int] = mapped_column(Integer, default=3)
|
||||
border_color_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
|
||||
|
||||
@@ -469,6 +500,7 @@ class PhotoWidgetConfig(Base):
|
||||
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
locked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class CalendarWidgetConfig(Base):
|
||||
@@ -553,6 +585,41 @@ class WhiteboardWidgetConfig(Base):
|
||||
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
|
||||
class WeatherWidgetConfig(Base):
|
||||
"""One weather widget's settings + cached-fetch state. Four display
|
||||
modes (see app/widgets/weather.py): "current" (one city, current
|
||||
temp + icon), "hourly" (one city, a row of ticks across the day),
|
||||
"daily" (one city, a multi-day strip), "multi_city" (several cities'
|
||||
current-day high/low/icon side by side -- the calendar widget's
|
||||
embedded weather strip, lifted out into its own widget type).
|
||||
`provider` selects which of app/weather/'s PROVIDERS actually fetches
|
||||
("open_meteo" | "nws" -- see that package's own module docstring).
|
||||
`cached`'s shape depends on `mode`: {"temp","category"} for current,
|
||||
a list of {"time","temp","category"} for hourly, a
|
||||
{"YYYY-MM-DD": {...}} dict for daily, or a list of
|
||||
{"label","high","low","category"} for multi_city."""
|
||||
|
||||
__tablename__ = "weather_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
|
||||
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
|
||||
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
|
||||
# Single-location modes only (current/hourly/daily) -- geocoded once
|
||||
# via weather.geocode_city() when set, same idiom as
|
||||
# CalendarWidgetConfig.weather_cities' per-entry shape.
|
||||
city_label: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
city_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
city_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
hourly_interval_hours: Mapped[int] = mapped_column(Integer, default=4)
|
||||
daily_days: Mapped[int] = mapped_column(Integer, default=5)
|
||||
# multi_city mode only -- [{"label", "latitude", "longitude"}, ...],
|
||||
# same shape as CalendarWidgetConfig.weather_cities.
|
||||
cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
cached: Mapped[dict | list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
|
||||
|
||||
class TextWidgetConfig(Base):
|
||||
"""One text widget's authored content + display settings -- another
|
||||
no-live-upstream type like StaticWidgetConfig, just parsed rich text
|
||||
@@ -608,6 +675,22 @@ class StaticWidgetConfig(Base):
|
||||
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
|
||||
|
||||
|
||||
class BatteryWidgetConfig(Base):
|
||||
"""One battery widget's display settings -- another no-live-upstream
|
||||
type like StaticWidgetConfig/TextWidgetConfig, just showing existing
|
||||
frame-level state (Frame.battery_percent/battery_as_of, already set
|
||||
by routers/device.py's frame_battery on every device report) instead
|
||||
of anything the widget itself fetches or the user authors. `mode`
|
||||
"compact" is icon + percent only; "detailed" (default) adds the
|
||||
routers.common.battery_estimate_s time-remaining estimate and the
|
||||
last report's age."""
|
||||
|
||||
__tablename__ = "battery_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed
|
||||
|
||||
|
||||
# widget_type -> its per-type extension table, keyed by widget_id. Used
|
||||
# by db.widget_locked() to resolve the right config row without importing
|
||||
# app/widgets/'s heavier render/action registry just for this lookup.
|
||||
@@ -617,21 +700,24 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
||||
"whiteboard": WhiteboardWidgetConfig,
|
||||
"tasks": TaskWidgetConfig,
|
||||
"static": StaticWidgetConfig,
|
||||
"battery": BatteryWidgetConfig,
|
||||
"text": TextWidgetConfig,
|
||||
"weather": WeatherWidgetConfig,
|
||||
}
|
||||
|
||||
|
||||
class FrameButtonAction(Base):
|
||||
"""One (widget, action) binding for one of a frame's two physical
|
||||
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
|
||||
"advance"}. A button can have several of these (sort_order gives
|
||||
execution order); on a press, every row for that (frame, button) runs
|
||||
-- see routers/device.py's frame_advance/frame_back. Deliberately
|
||||
unconstrained about which widget/action pairs with which button (the
|
||||
user's own idea for resolving "what does NEXT even mean with several
|
||||
widgets on screen": let them assign literally anything to either
|
||||
button, including mismatched combinations, rather than the server
|
||||
guessing a sensible default)."""
|
||||
"advance"}. At most one binding per (widget, button) -- edited from
|
||||
that widget's own config dialog (routers/api_widgets.py's
|
||||
api_widget_config_save), prefilled with a sane default at widget
|
||||
creation (app/widgets/default_button_actions). On a press, every
|
||||
widget's row for that (frame, button) runs -- see routers/device.py's
|
||||
frame_advance/frame_back. sort_order is unused (which widget's action
|
||||
runs first never matters: each only touches its own state, and one
|
||||
shared re-render happens after all of them finish) but kept around so
|
||||
dispatch has a stable, deterministic query order."""
|
||||
|
||||
__tablename__ = "frame_button_actions"
|
||||
|
||||
@@ -645,6 +731,7 @@ class FrameButtonAction(Base):
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
|
||||
Index("ix_frame_button_actions_widget_button", "widget_id", "button", unique=True),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -211,11 +211,18 @@ def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bo
|
||||
/api/queue), so without this an open browser tab polling overnight
|
||||
would silently advance the current photo on raw elapsed time alone,
|
||||
even though the device itself is correctly asleep through the
|
||||
window (see main.py's _effective_refresh_interval_s)."""
|
||||
window (see main.py's _effective_refresh_interval_s).
|
||||
|
||||
cfg.locked suppresses the elapsed-time trigger the same way
|
||||
in_quiet_hours does -- a locked widget still needs an initial pick
|
||||
if it somehow has none (an unconfigured widget just locked, or a
|
||||
changed album), but once it has a current photo the whole point of
|
||||
locking is that it stops moving on its own until explicitly
|
||||
unlocked."""
|
||||
valid_ids = {a["id"] for a in assets}
|
||||
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
|
||||
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
|
||||
stale = needs_pick or (time_elapsed and not in_quiet_hours)
|
||||
stale = needs_pick or (time_elapsed and not in_quiet_hours and not cfg.locked)
|
||||
if not stale:
|
||||
return False
|
||||
advance_forced(cfg, assets, frame)
|
||||
|
||||
@@ -22,17 +22,16 @@ import time
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import gitea_releases, grid, quiet_hours
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db
|
||||
from ..global_actions import GLOBAL_ACTIONS
|
||||
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from ..models import BatteryLog, Frame, Widget
|
||||
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
||||
from .device import render_frame_preview_png
|
||||
|
||||
@@ -45,6 +44,11 @@ MAX_REFRESH_INTERVAL_S = 86400
|
||||
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
# See app/global_actions.py -- how long NEXT/BACK must be held before the
|
||||
# device treats it as a hold instead of a short press.
|
||||
MIN_HOLD_DURATION_MS = 3000
|
||||
MAX_HOLD_DURATION_MS = 10000
|
||||
|
||||
|
||||
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
|
||||
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
|
||||
@@ -101,6 +105,9 @@ def api_config_save(
|
||||
color_boost: float | None = Form(None),
|
||||
contrast_boost: float | None = Form(None),
|
||||
dither_strength: float | None = Form(None),
|
||||
hold_duration_ms: int | None = Form(None),
|
||||
next_hold_action: str | None = Form(None),
|
||||
back_hold_action: str | None = Form(None),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -120,7 +127,14 @@ def api_config_save(
|
||||
_reset_widget_layout_for_new_orientation) -- widget placement is
|
||||
grid-cell-relative to the panel's long/short axis, which swaps on a
|
||||
landscape<->portrait change, so an old placement is usually not just
|
||||
visually wrong but literally out of bounds on the new grid."""
|
||||
visually wrong but literally out of bounds on the new grid.
|
||||
|
||||
hold_duration_ms/next_hold_action/back_hold_action configure hold-
|
||||
for-global-action (see app/global_actions.py) -- a frame-wide
|
||||
setting, not per-widget, hence living here rather than on
|
||||
api_widgets.py's per-widget button-actions endpoint. An unrecognized
|
||||
action value clears the binding rather than erroring, same posture
|
||||
as this endpoint's other enum-ish fields (orientation, timezone)."""
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if name is not None:
|
||||
cfg.name = name.strip()[:64] or cfg.name
|
||||
@@ -168,6 +182,12 @@ def api_config_save(
|
||||
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||
if dither_strength is not None:
|
||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||
if hold_duration_ms is not None:
|
||||
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
|
||||
if next_hold_action is not None:
|
||||
cfg.next_hold_action = next_hold_action if next_hold_action in GLOBAL_ACTIONS else None
|
||||
if back_hold_action is not None:
|
||||
cfg.back_hold_action = back_hold_action if back_hold_action in GLOBAL_ACTIONS else None
|
||||
cfg.stats_config_saves += 1
|
||||
|
||||
return {"status": "saved"}
|
||||
@@ -251,95 +271,6 @@ def api_frame_preview(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
BUTTONS = ("next", "back")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/buttons")
|
||||
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""Everything the button-assignment UI needs in one call: every
|
||||
widget on the frame with the actions its type supports (see
|
||||
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
|
||||
ordered list of (widget, action) bindings.
|
||||
|
||||
Includes each widget's placement (x/y/w/h) and the frame's grid
|
||||
dimensions -- two widgets of the same type otherwise look identical
|
||||
in the assignment UI's dropdowns (both just say "Photos"); the
|
||||
client derives a position label ("top-left" etc.) from this to tell
|
||||
them apart, the same way you'd tell them apart by eye on the Layout
|
||||
canvas."""
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
widget_options = [
|
||||
{
|
||||
"id": w.id,
|
||||
"widget_type": w.widget_type,
|
||||
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
||||
"actions": [
|
||||
{"action": action, "label": label}
|
||||
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
|
||||
],
|
||||
}
|
||||
for w in widgets
|
||||
]
|
||||
result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
|
||||
for button in BUTTONS:
|
||||
rows = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
|
||||
return result
|
||||
|
||||
|
||||
class ButtonActionItem(BaseModel):
|
||||
widget_id: int
|
||||
action: str
|
||||
|
||||
|
||||
class ButtonActionsRequest(BaseModel):
|
||||
actions: list[ButtonActionItem]
|
||||
|
||||
|
||||
@router.put("/api/frames/{frame_id}/buttons/{button}")
|
||||
def api_buttons_save(
|
||||
button: str, body: ButtonActionsRequest,
|
||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replaces the whole ordered action list for one button in a single
|
||||
call -- simpler and more atomic than separate add/remove/reorder
|
||||
endpoints for what's normally a list of one to a handful of entries,
|
||||
and the UI always has the full list in hand anyway (see
|
||||
static/frame_config.js)."""
|
||||
if button not in BUTTONS:
|
||||
raise HTTPException(404, "No such button")
|
||||
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
|
||||
for item in body.actions:
|
||||
widget = widgets_by_id.get(item.widget_id)
|
||||
if widget is None:
|
||||
raise HTTPException(400, f"No such widget: {item.widget_id}")
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None or item.action not in module.ACTIONS:
|
||||
raise HTTPException(
|
||||
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
|
||||
)
|
||||
with frame_locked(db, frame.id):
|
||||
db.execute(
|
||||
delete(FrameButtonAction).where(
|
||||
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
|
||||
)
|
||||
)
|
||||
for i, item in enumerate(body.actions):
|
||||
db.add(FrameButtonAction(
|
||||
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
|
||||
sort_order=i, created_at=time.time(),
|
||||
))
|
||||
db.commit()
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/battery-log")
|
||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
|
||||
@@ -62,6 +62,7 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"static": ("display_mode", "original_filename"),
|
||||
"text": ("content", "font_size", "font_family", "align", "background_color"),
|
||||
"whiteboard": ("user_id", "url"),
|
||||
"battery": ("mode",),
|
||||
}
|
||||
|
||||
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
||||
@@ -236,25 +237,19 @@ def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(ge
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@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 -- 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
|
||||
def apply_layout_to_frame(db: Session, frame: Frame, layout: SavedLayout) -> int:
|
||||
"""Replaces frame's entire widget arrangement with layout's snapshot
|
||||
-- every current widget (and its own config/sources/button actions,
|
||||
all ondelete="CASCADE") is deleted first, same "act unconditionally
|
||||
on the server, confirm on the client" posture as
|
||||
api_widgets.api_widgets_clear. A source whose owning user account
|
||||
(or a whiteboard's user_id) no longer exists is silently dropped
|
||||
rather than left dangling -- config is JSON, not FK-checked, so
|
||||
nothing enforces that at the storage layer."""
|
||||
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")
|
||||
|
||||
nothing enforces that at the storage layer. Shared by api_layout_apply
|
||||
(explicit user action) and global_actions.cycle_layout (a hold-
|
||||
triggered global action, see app/global_actions.py) -- caller is
|
||||
responsible for checking the grid-size match first. Returns the
|
||||
number of widgets applied."""
|
||||
snapshots = db.scalars(
|
||||
select(SavedLayoutWidget)
|
||||
.where(SavedLayoutWidget.saved_layout_id == layout.id)
|
||||
@@ -317,4 +312,22 @@ def api_layout_apply(
|
||||
sort_order=action.sort_order, created_at=time.time(),
|
||||
))
|
||||
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}
|
||||
|
||||
@@ -26,14 +26,18 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client
|
||||
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db, widget_locked
|
||||
from ..image_pipeline import (
|
||||
BORDER_STYLES,
|
||||
DEFAULT_DISPLAY_MODE,
|
||||
DEFAULT_STATIC_DISPLAY_MODE,
|
||||
DISPLAY_MODES,
|
||||
hex_to_rgb,
|
||||
MAX_BORDER_THICKNESS,
|
||||
MIN_BORDER_THICKNESS,
|
||||
PALETTE_LABELS,
|
||||
STATIC_DISPLAY_MODES,
|
||||
render_preview_png,
|
||||
)
|
||||
@@ -41,18 +45,21 @@ from ..image_upload import decode_upload
|
||||
from ..models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
WeatherWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
WIDGET_CONFIG_MODELS,
|
||||
Widget,
|
||||
)
|
||||
from ..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 .common import (
|
||||
calendar_sources_for_widget,
|
||||
@@ -60,6 +67,7 @@ from .common import (
|
||||
get_or_refresh_calendar_events_for_widget,
|
||||
get_or_refresh_tasks_for_widget,
|
||||
get_or_refresh_weather_for_widget,
|
||||
get_or_refresh_weather_widget_data,
|
||||
get_or_refresh_whiteboard_for_widget,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
@@ -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
|
||||
|
||||
|
||||
def _widget_dict(w: Widget) -> dict:
|
||||
def _widget_dict(w: Widget, locked: bool = False) -> dict:
|
||||
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
||||
"sort_order": w.sort_order}
|
||||
"sort_order": w.sort_order, "border_style": w.border_style,
|
||||
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
|
||||
"locked": locked}
|
||||
|
||||
|
||||
def require_widget_view(
|
||||
@@ -133,12 +143,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
# Layout canvas needs to know which photo widgets are locked (to draw
|
||||
# the lock badge) -- a per-type config field, not on Widget itself,
|
||||
# so it's a separate lookup rather than something _widget_dict can
|
||||
# read straight off the row it's given.
|
||||
photo_widget_ids = [w.id for w in widgets if w.widget_type == "photos"]
|
||||
locked_by_widget_id = dict(db.execute(
|
||||
select(PhotoWidgetConfig.widget_id, PhotoWidgetConfig.locked)
|
||||
.where(PhotoWidgetConfig.widget_id.in_(photo_widget_ids))
|
||||
).all()) if photo_widget_ids else {}
|
||||
return {
|
||||
"orientation": frame.orientation,
|
||||
"grid": {"cols": cols, "rows": rows},
|
||||
"widget_types": list(WIDGET_TYPES.keys()),
|
||||
"min_footprint": grid.MIN_FOOTPRINT,
|
||||
"widgets": [_widget_dict(w) for w in widgets],
|
||||
"widgets": [_widget_dict(w, locked_by_widget_id.get(w.id, False)) for w in widgets],
|
||||
"control": {
|
||||
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
||||
"you": frame.controlled_by_user_id == user.id,
|
||||
@@ -196,6 +215,7 @@ def api_widget_create(
|
||||
db.add(widget)
|
||||
db.flush()
|
||||
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
|
||||
db.add_all(default_button_actions(frame.id, widget.id, body.widget_type))
|
||||
db.commit()
|
||||
return _widget_dict(widget)
|
||||
|
||||
@@ -223,6 +243,38 @@ def api_widget_move(
|
||||
return _widget_dict(widget)
|
||||
|
||||
|
||||
class WidgetBorderRequest(BaseModel):
|
||||
border_style: str
|
||||
border_thickness: int
|
||||
border_color_index: int
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/border")
|
||||
def api_widget_border(
|
||||
body: WidgetBorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Sets this widget's optional border -- a shared Widget-level
|
||||
property (see models.Widget), not a per-type config field, since
|
||||
every widget type can have one regardless of widget_type. Its own
|
||||
endpoint (not folded into api_widget_config_save) for the same
|
||||
reason: that endpoint's per-type dispatch is keyed on a config row
|
||||
via widget_locked, and border fields live on Widget itself, not any
|
||||
per-type config table."""
|
||||
frame, widget = frame_widget
|
||||
if body.border_style not in BORDER_STYLES:
|
||||
raise HTTPException(400, f"border_style must be one of {BORDER_STYLES}")
|
||||
if not (0 <= body.border_color_index < len(PALETTE_LABELS)):
|
||||
raise HTTPException(400, "border_color_index must be 0-5 (a panel palette color)")
|
||||
thickness = max(MIN_BORDER_THICKNESS, min(MAX_BORDER_THICKNESS, body.border_thickness))
|
||||
with frame_locked(db, frame.id):
|
||||
widget.border_style = body.border_style
|
||||
widget.border_thickness = thickness
|
||||
widget.border_color_index = body.border_color_index
|
||||
db.commit()
|
||||
return _widget_dict(widget)
|
||||
|
||||
|
||||
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
|
||||
def api_widget_delete(
|
||||
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
||||
@@ -283,6 +335,14 @@ def api_widget_config_save(
|
||||
text_font_family: str | None = Form(None),
|
||||
text_align: str | None = Form(None),
|
||||
text_background_color: str | None = Form(None),
|
||||
# weather
|
||||
weather_mode: str | None = Form(None),
|
||||
weather_provider: str | None = Form(None),
|
||||
weather_units: str | None = Form(None),
|
||||
weather_hourly_interval_hours: int | None = Form(None),
|
||||
weather_daily_days: int | None = Form(None),
|
||||
# battery
|
||||
battery_mode: str | None = Form(None),
|
||||
):
|
||||
"""Every field optional -- same partial-update, form-urlencoded
|
||||
convention as the old frame-level api_config_save, now scoped to one
|
||||
@@ -379,11 +439,93 @@ def api_widget_config_save(
|
||||
xcfg.background_color = (
|
||||
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
||||
)
|
||||
elif widget.widget_type == "weather":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
|
||||
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
|
||||
if weather_mode != wcfg.mode:
|
||||
# A stale cache is a different shape under a
|
||||
# different mode (a single-temp dict vs. an hourly
|
||||
# list vs. a daily dict vs. a city list) -- clear it
|
||||
# outright (not just force a refetch attempt) so a
|
||||
# get_or_refresh_weather_widget_data call that happens
|
||||
# to fail on the very first fetch under the new mode
|
||||
# doesn't fall back to the old mode's incompatible
|
||||
# cached shape.
|
||||
wcfg.checked_at = 0.0
|
||||
wcfg.cached = None
|
||||
wcfg.mode = weather_mode
|
||||
if weather_provider is not None and weather_provider in weather.PROVIDERS:
|
||||
if weather_provider != wcfg.provider:
|
||||
wcfg.checked_at = 0.0
|
||||
wcfg.provider = weather_provider
|
||||
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
|
||||
if weather_units != wcfg.units:
|
||||
# Cached temps are in the old unit -- force a refetch
|
||||
# rather than showing stale numbers under a new unit
|
||||
# label (same idiom as calendar_weather_units above).
|
||||
wcfg.checked_at = 0.0
|
||||
wcfg.units = weather_units
|
||||
if weather_hourly_interval_hours is not None:
|
||||
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
|
||||
if weather_daily_days is not None:
|
||||
wcfg.daily_days = max(1, min(14, weather_daily_days))
|
||||
elif widget.widget_type == "battery":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
|
||||
if battery_mode is not None:
|
||||
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
class WidgetButtonActionsRequest(BaseModel):
|
||||
next_button_action: str
|
||||
back_button_action: str
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/button-actions")
|
||||
def api_widget_button_actions(
|
||||
body: WidgetButtonActionsRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Sets this widget's NEXT/BACK button bindings (models.FrameButtonAction)
|
||||
-- its own endpoint, not folded into api_widget_config_save, same
|
||||
reasoning as api_widget_border above: these rows live in their own
|
||||
table, not this widget's per-type config table. Replaces the old
|
||||
frame-level "Button assignments" card (routers/api_frames.py's
|
||||
api_buttons_get/api_buttons_save, now removed) -- each widget's own
|
||||
dialog edits its own binding directly, prefilled at widget-creation
|
||||
time with a sane default (see widgets.default_button_actions).
|
||||
|
||||
An empty string clears the binding for that button. Unlike
|
||||
api_widget_config_save's silent-ignore-unrecognized-value posture,
|
||||
a value outside this widget type's own ACTIONS is a 400 -- this
|
||||
request body is specifically about button actions, so a bad value
|
||||
here is a real client bug worth surfacing, not a stray field to
|
||||
shrug off."""
|
||||
frame, widget = frame_widget
|
||||
valid_actions = set(WIDGET_TYPES[widget.widget_type].ACTIONS)
|
||||
for value in (body.next_button_action, body.back_button_action):
|
||||
if value != "" and value not in valid_actions:
|
||||
raise HTTPException(400, f"{widget.widget_type} widgets don't support the {value!r} action")
|
||||
with frame_locked(db, frame.id):
|
||||
for button, value in (("next", body.next_button_action), ("back", body.back_button_action)):
|
||||
existing = db.scalars(
|
||||
select(FrameButtonAction).where(
|
||||
FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == button
|
||||
)
|
||||
).first()
|
||||
if value == "":
|
||||
if existing is not None:
|
||||
db.delete(existing)
|
||||
elif existing is not None:
|
||||
existing.action = value
|
||||
else:
|
||||
db.add(FrameButtonAction(frame_id=frame.id, button=button, widget_id=widget.id, action=value))
|
||||
db.commit()
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
# --- Photos: queue/thumbnail/preview ------------------------------------
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
|
||||
@@ -404,6 +546,7 @@ def api_widget_queue(
|
||||
photo_queue.sync_queue_length(locked_pcfg, assets)
|
||||
current_asset_id = locked_pcfg.current_asset_id
|
||||
queue = list(locked_pcfg.queue)
|
||||
locked = locked_pcfg.locked
|
||||
controller_id = locked_frame.controlled_by_user_id
|
||||
controller = (
|
||||
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
|
||||
@@ -416,6 +559,7 @@ def api_widget_queue(
|
||||
return {
|
||||
"current": entry(current_asset_id) if current_asset_id else None,
|
||||
"upcoming": [entry(asset_id) for asset_id in queue],
|
||||
"locked": locked,
|
||||
"control": {"controller": controller, "you": controller_id == user.id},
|
||||
}
|
||||
|
||||
@@ -485,6 +629,26 @@ def api_widget_queue_remove(
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
class QueueLockRequest(BaseModel):
|
||||
locked: bool
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/lock")
|
||||
def api_widget_queue_lock(
|
||||
body: QueueLockRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Freezes/unfreezes current_asset_id (models.PhotoWidgetConfig.locked)
|
||||
-- while locked, neither the timer-elapsed auto-advance
|
||||
(photo_queue.get_current) nor the advance/back button actions
|
||||
(app/widgets/photos.py) change which photo is showing."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "photos")
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.locked = body.locked
|
||||
return {"status": "saved", "locked": body.locked}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
|
||||
def api_widget_thumbnail(
|
||||
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||
@@ -493,8 +657,8 @@ def api_widget_thumbnail(
|
||||
"""Scoped to what this widget is actually showing/queuing -- a user
|
||||
merely linked to view this frame shouldn't be able to pull thumbnails
|
||||
for arbitrary asset ids in the owner's Immich library, only this
|
||||
widget's own curated album. Same rule device.frame_share and
|
||||
manage.manage_thumbnail already enforce."""
|
||||
widget's own curated album. Same rule manage.manage_thumbnail
|
||||
already enforces."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "photos")
|
||||
pcfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
@@ -833,6 +997,124 @@ def api_widget_weather_city_remove(
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
# --- Weather widget: location/cities/preview -------------------------------
|
||||
#
|
||||
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
|
||||
# "weather-cities") specifically to avoid colliding with the calendar
|
||||
# widget's own /weather-cities/add|remove route *patterns* above -- both
|
||||
# are registered against the same {widget_id}-parameterized path shape,
|
||||
# so a literal name clash there would silently shadow one of them
|
||||
# regardless of each handler's own _require_widget_type check.
|
||||
|
||||
class WeatherLocationRequest(BaseModel):
|
||||
name: str | None # None clears the location; else a free-text city name to geocode
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
|
||||
def api_widget_weather_location(
|
||||
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Sets (or clears) this weather widget's single configured location
|
||||
-- the current/hourly/daily modes' one city. A widget-wide display
|
||||
setting like calendar_view/weather_units, not personal data, hence
|
||||
require_widget_control rather than the calendar/tasks owner-adds/
|
||||
anyone-mutes split (there's only ever one location and no per-person
|
||||
ownership of it)."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "weather")
|
||||
if body.name is None:
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.city_label = None
|
||||
cfg.city_latitude = None
|
||||
cfg.city_longitude = None
|
||||
cfg.cached = None
|
||||
cfg.checked_at = 0.0
|
||||
return {"status": "saved", "city": None}
|
||||
try:
|
||||
city = weather.geocode_city(body.name)
|
||||
except weather.WeatherFetchError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.city_label = city["label"]
|
||||
cfg.city_latitude = city["latitude"]
|
||||
cfg.city_longitude = city["longitude"]
|
||||
cfg.cached = None
|
||||
cfg.checked_at = 0.0 # pick up the new location promptly
|
||||
return {"status": "saved", "city": city}
|
||||
|
||||
|
||||
class WeatherWidgetCityAddRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
|
||||
def api_widget_weather_widget_city_add(
|
||||
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""multi_city mode's city list -- same shape/gating as the calendar
|
||||
widget's own weather-cities/add above, just scoped to this widget's
|
||||
own WeatherWidgetConfig.cities."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "weather")
|
||||
try:
|
||||
city = weather.geocode_city(body.name)
|
||||
except weather.WeatherFetchError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cities = list(cfg.cities or [])
|
||||
if any(c["label"] == city["label"] for c in cities):
|
||||
raise HTTPException(400, f"{city['label']} is already on this widget's list")
|
||||
cities.append(city)
|
||||
cfg.cities = cities
|
||||
cfg.checked_at = 0.0 # pick up the new city promptly
|
||||
return {"status": "saved", "city": city}
|
||||
|
||||
|
||||
class WeatherWidgetCityRemoveRequest(BaseModel):
|
||||
label: str
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
|
||||
def api_widget_weather_widget_city_remove(
|
||||
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "weather")
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
|
||||
cfg.cities = cities
|
||||
if cfg.cached:
|
||||
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
|
||||
def api_widget_preview_weather(
|
||||
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""The same throttled fetch cache a live device render would use, run
|
||||
through the panel composition/quantization pipeline -- "how it will
|
||||
look on the frame", same convention as the other preview endpoints.
|
||||
force=True (the "Refresh now" button) bypasses the fetch throttle."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "weather")
|
||||
wcfg = db.get(WeatherWidgetConfig, widget.id)
|
||||
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
|
||||
if data is None:
|
||||
if wcfg.mode == "multi_city":
|
||||
raise HTTPException(400, "No cities added to this widget yet")
|
||||
raise HTTPException(400, "No location set on this widget yet")
|
||||
png = weather_render.render_weather_preview_png(
|
||||
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Static image: upload/preview -----------------------------------------
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
|
||||
@@ -903,6 +1185,25 @@ def api_widget_preview_text(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Battery: preview --------------------------------------------------------
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/battery")
|
||||
def api_widget_preview_battery(
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Unlike every other preview endpoint, there's no "not configured
|
||||
yet" 400 case -- the content is frame-level state (battery_percent)
|
||||
that either exists or doesn't, and render() already degrades to a
|
||||
"No reports yet" placeholder either way, same as a live device
|
||||
render would."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "battery")
|
||||
png = battery_widget.render_preview_png(
|
||||
db, frame, widget, orientation=frame.orientation, palette_rgb=frame.palette_rgb
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Whiteboard: source/preview ------------------------------------------
|
||||
|
||||
class WhiteboardSourceRequest(BaseModel):
|
||||
|
||||
@@ -31,6 +31,7 @@ from ..models import (
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
WeatherWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
)
|
||||
|
||||
@@ -449,7 +450,15 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
exif = asset.get("exifInfo") or {}
|
||||
content["location_lines"] = _format_location(exif)
|
||||
content["taken_at"] = _format_taken_at(exif)
|
||||
content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}"
|
||||
|
||||
# Unlike location/date-taken (a single fixed corner, so tied to the
|
||||
# one "primary" widget above), the share link covers every photo
|
||||
# widget's current photo (see manage.manage_share) -- so it only
|
||||
# needs *some* photo widget to have a current photo, not specifically
|
||||
# the primary one, and doesn't depend on the EXIF fetch above
|
||||
# succeeding.
|
||||
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
|
||||
content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
|
||||
|
||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||
face_labels: list[dict] = []
|
||||
@@ -566,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
|
||||
return result
|
||||
|
||||
|
||||
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
|
||||
|
||||
|
||||
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
|
||||
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
|
||||
weather widget (see app/widgets/weather.py) -- reads/writes
|
||||
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
|
||||
hourly/daily need a single configured location (city_latitude/
|
||||
city_longitude); multi_city needs cfg.cities. None if not configured
|
||||
yet, so render() falls back to a placeholder -- same convention as
|
||||
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
|
||||
button) bypasses the throttle entirely.
|
||||
|
||||
A single-location mode's fetch failure keeps the last-known cached
|
||||
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
|
||||
multi_city fetch fails per-city (like get_or_refresh_weather_for_
|
||||
widget's calendar-strip counterpart) so one broken city doesn't blank
|
||||
the others."""
|
||||
cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||
if cfg.mode == "multi_city":
|
||||
if not cfg.cities:
|
||||
return None
|
||||
elif cfg.city_latitude is None or cfg.city_longitude is None:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
|
||||
return cfg.cached
|
||||
|
||||
if cfg.mode == "multi_city":
|
||||
previous = {c["label"]: c for c in (cfg.cached or [])}
|
||||
result = []
|
||||
for city in cfg.cities:
|
||||
try:
|
||||
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
|
||||
d = next(iter(today.values())) if today else previous.get(city["label"], {})
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
|
||||
d = previous.get(city["label"], {})
|
||||
result.append({
|
||||
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
|
||||
"category": d.get("category", "cloudy"),
|
||||
})
|
||||
else:
|
||||
try:
|
||||
if cfg.mode == "current":
|
||||
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
|
||||
elif cfg.mode == "hourly":
|
||||
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
||||
hours=HOURLY_FETCH_HOURS)
|
||||
else: # "daily"
|
||||
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
||||
cfg.daily_days)
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
|
||||
return cfg.cached
|
||||
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
locked_cfg.cached = result
|
||||
locked_cfg.checked_at = now
|
||||
return result
|
||||
|
||||
|
||||
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
|
||||
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -26,8 +25,9 @@ from .. import grid, mail, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import logical_render_size, render_panel, render_placeholder
|
||||
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget
|
||||
from ..global_actions import GLOBAL_ACTIONS
|
||||
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
|
||||
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from .common import (
|
||||
BATTERY_HISTORY_MAX,
|
||||
@@ -35,9 +35,6 @@ from .common import (
|
||||
RECHARGE_JUMP_PCT,
|
||||
RECHARGE_LOOKBACK,
|
||||
build_manage_content,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
photo_widgets_for_frame,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -85,11 +82,14 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
||||
as_png: bool = False) -> bytes:
|
||||
"""The widget-system compositor: renders every widget on this frame
|
||||
into its own region (see app/grid.py for grid-cell -> pixel math) and
|
||||
hands the results to image_pipeline.render_panel for the single
|
||||
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
|
||||
per-mode RENDERERS dict -- a frame can now show several widgets at
|
||||
once instead of exactly one mode owning the whole panel."""
|
||||
into its own region (see app/grid.py for grid-cell -> pixel math),
|
||||
draws that widget's own optional border directly onto its region
|
||||
(models.Widget.border_style, a shared per-widget property no
|
||||
widget_type module needs to know about) and hands the results to
|
||||
image_pipeline.render_panel for the single shared paste/enhance/
|
||||
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
|
||||
dict -- a frame can now show several widgets at once instead of
|
||||
exactly one mode owning the whole panel."""
|
||||
all_widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
@@ -103,6 +103,10 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
|
||||
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
||||
)
|
||||
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
||||
draw_widget_border(
|
||||
img, widget.border_style, widget.border_thickness,
|
||||
resolve_border_color(widget.border_color_index, frame.palette_rgb),
|
||||
)
|
||||
regions.append(((px, py, pw, ph), img))
|
||||
return render_panel(
|
||||
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
@@ -181,6 +185,22 @@ def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _run_global_action(db: Session, frame: Frame, button: str) -> None:
|
||||
"""The hold-triggered counterpart to _run_button_actions -- runs
|
||||
whichever entry in app/global_actions.GLOBAL_ACTIONS this button's
|
||||
Frame.next_hold_action/back_hold_action points to, if any (unset or
|
||||
unrecognized is a silent no-op, same posture as an unbound short-
|
||||
press button). See routers/device.py's frame_global_next/back."""
|
||||
action = frame.next_hold_action if button == "next" else frame.back_hold_action
|
||||
action_fn = GLOBAL_ACTIONS.get(action) if action else None
|
||||
if action_fn is None:
|
||||
return
|
||||
try:
|
||||
action_fn(db, frame)
|
||||
except Exception:
|
||||
logger.exception("Global hold action %r failed for frame %d", action, frame.id)
|
||||
|
||||
|
||||
@router.get("/frame/config")
|
||||
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Device-facing settings, polled by the frame alongside its
|
||||
@@ -206,6 +226,11 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
|
||||
response = {
|
||||
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
|
||||
"firmware_version": locked.firmware_available_version or None,
|
||||
# Additive key -- old firmware's hand-rolled parser only ever
|
||||
# extracts the keys it knows about, so this is safe for
|
||||
# firmware that predates hold-for-global-action (see
|
||||
# firmware/main/next_button.c, app/global_actions.py).
|
||||
"hold_duration_ms": locked.hold_duration_ms,
|
||||
}
|
||||
# Per-frame token push: only once the device has introduced itself
|
||||
# by id (so the response to pure-legacy firmware stays byte-
|
||||
@@ -267,6 +292,30 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/global-next")
|
||||
def frame_global_next(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Fires when the device detects NEXT held past Frame.hold_duration_ms
|
||||
instead of a short press -- runs Frame.next_hold_action (see
|
||||
app/global_actions.GLOBAL_ACTIONS) if one is set, then re-renders and
|
||||
returns the whole panel same as /frame/advance. A separate endpoint
|
||||
from /frame/advance (not a query flag on it) so the frozen short-press
|
||||
path's behavior never has to account for the long-press case -- see
|
||||
firmware/main/next_button.c for the short/long split."""
|
||||
_run_global_action(db, frame, "next")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/global-back")
|
||||
def frame_global_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""The mirror of /frame/global-next, for a held BACK button."""
|
||||
_run_global_action(db, frame, "back")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
class BatteryReport(BaseModel):
|
||||
percent: int
|
||||
|
||||
@@ -356,34 +405,3 @@ def frame_firmware(frame: Frame = Depends(require_device)):
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "No firmware uploaded")
|
||||
return FileResponse(path, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/frame/share/{asset_id}")
|
||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Creates a 30-minute public Immich share link for asset_id and
|
||||
redirects to it -- what the manage overlay's bottom-left QR code
|
||||
points to. The link is created lazily, when this actually gets hit
|
||||
(i.e. when someone scans it), not when the manage button was
|
||||
pressed, so the 30-minute window starts when it's actually used.
|
||||
Also scoped to the photo currently showing or queued on one of THIS
|
||||
frame's own photo widgets -- not any arbitrary Immich asset id -- as
|
||||
a second layer even a leaked token wouldn't bypass."""
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
|
||||
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||
showing_or_queued = any(
|
||||
asset_id == cfg.current_asset_id or asset_id in cfg.queue
|
||||
for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets)
|
||||
)
|
||||
if not showing_or_queued:
|
||||
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
||||
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
share_url = client.create_share_link(asset_id, expires_in_s=1800)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||
|
||||
return RedirectResponse(share_url)
|
||||
|
||||
@@ -17,19 +17,27 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import weather
|
||||
from ..auth import can_view_frame, current_user
|
||||
from ..calendar_render import CALENDAR_VIEW_LABELS
|
||||
from ..db import get_db
|
||||
from ..global_actions import GLOBAL_ACTION_LABELS
|
||||
from ..image_pipeline import (
|
||||
BORDER_STYLES,
|
||||
BORDER_STYLE_LABELS,
|
||||
DEFAULT_PALETTE_RGB,
|
||||
DISPLAY_MODE_LABELS,
|
||||
MAX_BORDER_THICKNESS,
|
||||
MIN_BORDER_THICKNESS,
|
||||
PALETTE_LABELS,
|
||||
STATIC_DISPLAY_MODES,
|
||||
palette_to_hex,
|
||||
)
|
||||
from ..models import (
|
||||
BatteryWidgetConfig,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
@@ -38,10 +46,12 @@ from ..models import (
|
||||
TextWidgetConfig,
|
||||
User,
|
||||
UserFrame,
|
||||
WeatherWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
from ..quiet_hours import ALL_TIMEZONES
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from ..widgets import text as text_widget
|
||||
from .common import shell_context, widget_of_type
|
||||
|
||||
@@ -81,6 +91,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
|
||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||
palette_to_hex=palette_to_hex,
|
||||
photo_widget_id=photo_widget_id,
|
||||
global_action_labels=GLOBAL_ACTION_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
raise HTTPException(404, "No such widget")
|
||||
|
||||
# Every dialog includes the shared "Border" card (_widget_border_fields.html,
|
||||
# models.Widget.border_style/border_thickness/border_color_index) --
|
||||
# a Widget-level property, not a per-type config field, so this
|
||||
# context is the same regardless of widget_type.
|
||||
border_ctx = {
|
||||
"border_styles": BORDER_STYLES,
|
||||
"border_style_labels": BORDER_STYLE_LABELS,
|
||||
"border_color_labels": PALETTE_LABELS,
|
||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
||||
"palette_to_hex": palette_to_hex,
|
||||
"min_border_thickness": MIN_BORDER_THICKNESS,
|
||||
"max_border_thickness": MAX_BORDER_THICKNESS,
|
||||
}
|
||||
|
||||
# Every dialog also includes the shared "Button actions" card
|
||||
# (_widget_button_fields.html) if this widget type supports any --
|
||||
# empty for tasks/static/text/battery, so the card renders nothing
|
||||
# for those. Bindings, not the type's own config, so this lives in
|
||||
# FrameButtonAction (see models.py), same reasoning as border_ctx
|
||||
# above for why it's a separate card/endpoint from the type-specific
|
||||
# form.
|
||||
bindings = {
|
||||
row.button: row.action
|
||||
for row in db.scalars(
|
||||
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id)
|
||||
)
|
||||
}
|
||||
button_ctx = {
|
||||
"button_action_labels": WIDGET_TYPES[widget.widget_type].ACTION_LABELS,
|
||||
"next_button_action": bindings.get("next", ""),
|
||||
"back_button_action": bindings.get("back", ""),
|
||||
}
|
||||
|
||||
if widget.widget_type == "photos":
|
||||
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_photos.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
|
||||
"display_mode_labels": DISPLAY_MODE_LABELS,
|
||||
"display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx, **button_ctx,
|
||||
})
|
||||
|
||||
if widget.widget_type == "calendar":
|
||||
@@ -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),
|
||||
"week_start_labels": WEEK_START_LABELS,
|
||||
"calendar_color_labels": PALETTE_LABELS,
|
||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
||||
"palette_to_hex": palette_to_hex,
|
||||
**border_ctx, **button_ctx,
|
||||
})
|
||||
|
||||
if widget.widget_type == "tasks":
|
||||
@@ -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,
|
||||
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
|
||||
"task_color_labels": PALETTE_LABELS,
|
||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
||||
"palette_to_hex": palette_to_hex,
|
||||
**border_ctx, **button_ctx,
|
||||
})
|
||||
|
||||
if widget.widget_type == "static":
|
||||
@@ -245,13 +287,14 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
||||
return templates.TemplateResponse("_widget_dialog_static.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
|
||||
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
|
||||
**border_ctx, **button_ctx,
|
||||
})
|
||||
|
||||
if widget.widget_type == "text":
|
||||
text_cfg = db.get(TextWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_text.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
|
||||
"text_font_families": text_widget.FONT_FAMILIES,
|
||||
"text_font_families": text_widget.FONT_FAMILIES, **border_ctx, **button_ctx,
|
||||
})
|
||||
|
||||
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", {
|
||||
"request": request, "frame": frame, "widget": widget, "user": user,
|
||||
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
|
||||
"viewer_has_webdav_creds": viewer_has_webdav_creds,
|
||||
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
|
||||
})
|
||||
|
||||
if widget.widget_type == "weather":
|
||||
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_weather.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
|
||||
"weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx, **button_ctx,
|
||||
})
|
||||
|
||||
if widget.widget_type == "battery":
|
||||
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_battery.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx, **button_ctx,
|
||||
})
|
||||
|
||||
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
@@ -19,8 +19,14 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..db import get_db, widget_locked
|
||||
from ..models import Frame
|
||||
from .common import immich_client_for, list_assets, photo_widget_config_or_404
|
||||
from ..models import Frame, PhotoWidgetConfig
|
||||
from .common import (
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
photo_widget_config_or_404,
|
||||
photo_widgets_for_frame,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,3 +126,37 @@ def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db:
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
@router.get("/frame/share/{manage_token}")
|
||||
def manage_share(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
"""Creates a 30-minute public Immich share link covering every photo
|
||||
widget's currently-displayed asset on this frame, and redirects to it
|
||||
-- what the manage overlay's bottom-left QR code points to. Lazily
|
||||
created (only when someone actually scans it, not when the manage
|
||||
button was pressed), so the 30-minute window starts at actual use.
|
||||
Keyed on this frame's own manage_token, like the rest of this router,
|
||||
rather than device credentials -- a phone scanning a QR code has no
|
||||
way to supply the device's ?id=/?token=, which is why this used to
|
||||
silently fall back to whichever frame happened to still carry the
|
||||
legacy migration token instead of the frame that was actually
|
||||
scanned."""
|
||||
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||
asset_ids: list[str] = []
|
||||
for widget in photo_widgets:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if cfg.current_asset_id and cfg.current_asset_id not in asset_ids:
|
||||
asset_ids.append(cfg.current_asset_id)
|
||||
if not asset_ids:
|
||||
raise HTTPException(404, "No photos currently showing on this frame")
|
||||
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
share_url = client.create_share_link(asset_ids, expires_in_s=1800)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||
return RedirectResponse(share_url)
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = {
|
||||
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
||||
static: 'Static image', text: 'Text',
|
||||
static: 'Static image', text: 'Text', weather: 'Weather', battery: 'Battery',
|
||||
};
|
||||
|
||||
function showStatus(ok, message) {
|
||||
|
||||
@@ -59,6 +59,31 @@ document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Hold-for-global-action (see app/global_actions.py) -- a frame-wide
|
||||
// setting, not per-widget, so it shares api_config_save/the /config
|
||||
// endpoint rather than getting its own -- just a separate card/form on
|
||||
// this page for a distinct-enough concern.
|
||||
document.getElementById('hold-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3;
|
||||
const body = new URLSearchParams({
|
||||
hold_duration_ms: String(seconds * 1000),
|
||||
next_hold_action: document.getElementById('next_hold_action').value,
|
||||
back_hold_action: document.getElementById('back_hold_action').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (err) {
|
||||
showStatus(false, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function takeControl() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||
@@ -363,203 +388,3 @@ loadFirmwareCheck();
|
||||
// cheap either way.
|
||||
setInterval(loadFirmwareCheck, 60000);
|
||||
|
||||
// --- Button assignments -----------------------------------------------
|
||||
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
|
||||
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
|
||||
// api_buttons_get. Each button's list is edited client-side (add/
|
||||
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
|
||||
// add/remove endpoints for what's normally a handful of entries, and
|
||||
// this file already has the full list in hand after any edit.
|
||||
let buttonsData = null;
|
||||
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
|
||||
const BUTTONS = ['next', 'back'];
|
||||
|
||||
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
|
||||
// frame's grid dims -- the same rough position you'd read off the
|
||||
// Layout canvas by eye, used to tell apart two widgets of the same type
|
||||
// that would otherwise both just say "Photos".
|
||||
function widgetPositionLabel(w, grid) {
|
||||
const cx = w.x + w.w / 2;
|
||||
const cy = w.y + w.h / 2;
|
||||
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
|
||||
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
|
||||
if (!horiz && !vert) return 'center';
|
||||
if (!vert) return horiz;
|
||||
if (!horiz) return vert;
|
||||
return `${vert}-${horiz}`;
|
||||
}
|
||||
|
||||
// A single widget of a given type keeps the plain type name ("Photos")
|
||||
// -- the common case, no need to clutter it. Only widgets sharing a
|
||||
// type with another widget on the same frame get a number + position
|
||||
// suffix, numbered in reading order (top-to-bottom, left-to-right).
|
||||
function buildWidgetNames(widgets, grid) {
|
||||
const byType = {};
|
||||
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
|
||||
const names = {};
|
||||
Object.values(byType).forEach((group) => {
|
||||
if (group.length === 1) {
|
||||
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
|
||||
return;
|
||||
}
|
||||
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
|
||||
ordered.forEach((w, i) => {
|
||||
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
|
||||
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
|
||||
});
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function widgetActionLabel(widgetId, action) {
|
||||
const w = buttonsData.widgets.find((w) => w.id === widgetId);
|
||||
if (!w) return `(deleted widget): ${action}`;
|
||||
const found = w.actions.find((a) => a.action === action);
|
||||
const actionLabel = found ? found.label : action;
|
||||
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
|
||||
}
|
||||
|
||||
function renderButtonList(button) {
|
||||
const list = document.getElementById(`button-actions-${button}`);
|
||||
const rows = buttonsData[button];
|
||||
list.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<li class="sub">Nothing assigned -- this button won’t 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();
|
||||
|
||||
@@ -210,6 +210,14 @@ function renderCanvas() {
|
||||
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
|
||||
box.appendChild(label);
|
||||
|
||||
if (widget.locked) {
|
||||
const lockBadge = document.createElement('span');
|
||||
lockBadge.className = 'widget-box-lock-badge';
|
||||
lockBadge.textContent = '\u{1F512}'; // lock emoji -- open the gear icon to unlock
|
||||
lockBadge.title = 'Locked -- won\'t change until unlocked in this widget\'s settings';
|
||||
box.appendChild(lockBadge);
|
||||
}
|
||||
|
||||
const settingsBtn = document.createElement('button');
|
||||
settingsBtn.type = 'button';
|
||||
settingsBtn.className = 'widget-box-settings';
|
||||
@@ -286,11 +294,13 @@ window.addEventListener('resize', () => {
|
||||
// icon was clicked).
|
||||
const DIALOG_INIT = {
|
||||
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
|
||||
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
|
||||
battery: initBatteryDialog,
|
||||
};
|
||||
const DIALOG_CLOSE = {
|
||||
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;
|
||||
@@ -341,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => {
|
||||
openDialogWidgetType = null;
|
||||
window.FRAME_API = window.FRAME_BASE_API;
|
||||
document.getElementById('widget-dialog-body').innerHTML = '';
|
||||
loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge)
|
||||
});
|
||||
|
||||
loadWidgets();
|
||||
|
||||
+15
-18
@@ -265,22 +265,6 @@ input:focus, select:focus {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
|
||||
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.button-action-row {
|
||||
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 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; }
|
||||
@@ -308,8 +292,6 @@ input:focus, select:focus {
|
||||
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.saved-layout-controls .btn-inline { margin: 0; }
|
||||
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
|
||||
.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 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-row { flex-wrap: wrap; }
|
||||
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
|
||||
.border-color-picker { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.color-swatch {
|
||||
width: 20px; height: 20px; padding: 0; margin: 0;
|
||||
border: 2px solid var(--border); border-radius: 5px;
|
||||
@@ -476,6 +459,20 @@ button.secondary:hover { background: var(--surface-alt); }
|
||||
.widget-box-remove { right: 4px; }
|
||||
.widget-box-settings { right: 28px; }
|
||||
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
|
||||
.widget-box-lock-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--overlay);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
pointer-events: none; /* passive indicator, not a control -- toggled from the widget's own dialog */
|
||||
}
|
||||
.widget-box-resize-handle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Battery widget dialog: display-mode setting and the rendered preview.
|
||||
// Not a page-load script -- frame_layout.js fetches this widget's dialog
|
||||
// HTML fragment, injects it into the shared <dialog>, points
|
||||
// window.FRAME_API at this specific widget
|
||||
// (/api/frames/{id}/widgets/{widget_id}), then calls initBatteryDialog().
|
||||
|
||||
function loadBatteryPreview() {
|
||||
document.getElementById('battery-preview').src = `${window.FRAME_API}/preview/battery?_=${Date.now()}`;
|
||||
}
|
||||
|
||||
function initBatteryDialog() {
|
||||
document.getElementById('battery-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
battery_mode: document.getElementById('battery_mode').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
loadBatteryPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
|
||||
loadBatteryPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeBatteryDialog() {
|
||||
// Nothing to tear down -- no poll interval, no upload state.
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Shared "Border" card (models.Widget.border_style/border_thickness/
|
||||
// border_color_index, _widget_border_fields.html) -- present on every
|
||||
// widget type's dialog regardless of widget_type, so this is one shared
|
||||
// init function each widget_dialog_<type>.js's init<Type>Dialog() calls,
|
||||
// rather than 8 copies of the same slider/swatch/save wiring. Not a
|
||||
// page-load script by itself -- frame_layout.js loads it unconditionally
|
||||
// (like every other widget_dialog_*.js) since which dialog is open, and
|
||||
// therefore which init<Type>Dialog() calls initBorderFields(), varies.
|
||||
|
||||
function initBorderFields() {
|
||||
const styleSelect = document.getElementById('border_style');
|
||||
if (!styleSelect) return; // dialog fragment didn't render the border card -- shouldn't happen
|
||||
|
||||
const thickness = document.getElementById('border_thickness');
|
||||
const thicknessValue = document.getElementById('border_thickness_value');
|
||||
thickness.addEventListener('input', (e) => {
|
||||
thicknessValue.textContent = `${e.target.value}px`;
|
||||
});
|
||||
|
||||
const colorIndexInput = document.getElementById('border_color_index');
|
||||
document.querySelectorAll('#border-color-picker .color-swatch').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('#border-color-picker .color-swatch').forEach((el) => el.classList.remove('selected'));
|
||||
btn.classList.add('selected');
|
||||
colorIndexInput.value = btn.dataset.index;
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('border-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = JSON.stringify({
|
||||
border_style: styleSelect.value,
|
||||
border_thickness: Number(thickness.value),
|
||||
border_color_index: Number(colorIndexInput.value),
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/border`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Border saved.');
|
||||
} catch (err) {
|
||||
showStatus(false, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Shared "Button actions" card (models.FrameButtonAction,
|
||||
// _widget_button_fields.html) -- present on every widget type's dialog
|
||||
// that supports any actions at all (the card renders nothing for
|
||||
// tasks/static/text/battery, whose ACTIONS is empty), so this is one
|
||||
// shared init function each widget_dialog_<type>.js's init<Type>Dialog()
|
||||
// calls, rather than N copies of the same save wiring -- same pattern as
|
||||
// widget_dialog_border.js. Not a page-load script by itself --
|
||||
// frame_layout.js loads it unconditionally (like every other
|
||||
// widget_dialog_*.js) since which dialog is open varies.
|
||||
|
||||
function initButtonActionFields() {
|
||||
const form = document.getElementById('button-actions-form');
|
||||
if (!form) return; // this widget type has no actions -- card didn't render
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = JSON.stringify({
|
||||
next_button_action: document.getElementById('next_button_action').value,
|
||||
back_button_action: document.getElementById('back_button_action').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/button-actions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Button actions saved.');
|
||||
} catch (err) {
|
||||
showStatus(false, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -197,6 +197,8 @@ function initCalendarDialog() {
|
||||
|
||||
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
||||
loadCalendarPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeCalendarDialog() {
|
||||
|
||||
@@ -8,6 +8,33 @@
|
||||
// FRAME_API + a global loadQueue()" contract queue.js has always had.
|
||||
|
||||
let photosPollTimer = null;
|
||||
let photoLocked = false;
|
||||
let photoHasCurrent = false;
|
||||
|
||||
function renderLockButton() {
|
||||
const btn = document.getElementById('lock-photo-btn');
|
||||
if (!btn) return;
|
||||
btn.textContent = photoLocked ? 'Unlock this photo' : 'Lock this photo';
|
||||
btn.classList.toggle('active', photoLocked);
|
||||
btn.disabled = !photoHasCurrent && !photoLocked; // nothing displayed yet to lock
|
||||
}
|
||||
|
||||
async function toggleLock() {
|
||||
const next = !photoLocked;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/lock`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locked: next }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
photoLocked = next;
|
||||
renderLockButton();
|
||||
showStatus(true, photoLocked ? 'Locked -- this photo will stay put.' : 'Unlocked.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
if (dragState) {
|
||||
@@ -21,9 +48,14 @@ async function loadQueue() {
|
||||
currentEl.innerHTML =
|
||||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
||||
renderUpcoming([]);
|
||||
photoHasCurrent = false;
|
||||
renderLockButton();
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
photoLocked = !!data.locked;
|
||||
photoHasCurrent = !!data.current;
|
||||
renderLockButton();
|
||||
currentEl.innerHTML = '';
|
||||
if (data.current) {
|
||||
const wrap = document.createElement('div');
|
||||
@@ -109,10 +141,14 @@ function initPhotosDialog() {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('lock-photo-btn').addEventListener('click', toggleLock);
|
||||
|
||||
loadQueue();
|
||||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||||
photosPollTimer = setInterval(loadQueue, 10000);
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closePhotosDialog() {
|
||||
|
||||
@@ -55,6 +55,8 @@ function initStaticDialog() {
|
||||
|
||||
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
|
||||
loadStaticPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeStaticDialog() {
|
||||
|
||||
@@ -88,6 +88,8 @@ function initTasksDialog() {
|
||||
|
||||
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
||||
loadTasksPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeTasksDialog() {
|
||||
|
||||
@@ -130,6 +130,8 @@ function initTextDialog() {
|
||||
|
||||
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
||||
loadTextPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeTextDialog() {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Weather widget dialog: mode/provider/units settings, location (single-
|
||||
// city modes) or a city list (multi_city mode), and the rendered
|
||||
// preview. Not a page-load script -- frame_layout.js fetches this
|
||||
// widget's dialog HTML fragment, injects it into the shared <dialog>,
|
||||
// points window.FRAME_API at this specific widget
|
||||
// (/api/frames/{id}/widgets/{widget_id}), then calls initWeatherDialog().
|
||||
|
||||
// Only one of "Location" (current/hourly/daily -- one city) or "Cities"
|
||||
// (multi_city -- a list) is ever relevant at a time; the interval/days
|
||||
// rows are each specific to one mode too.
|
||||
function updateWeatherFieldVisibility() {
|
||||
const mode = document.getElementById('weather_mode').value;
|
||||
document.getElementById('weather-hourly-interval-row').style.display = mode === 'hourly' ? '' : 'none';
|
||||
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
|
||||
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
|
||||
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
|
||||
}
|
||||
|
||||
function addWeatherWidgetCityRow(label) {
|
||||
const list = document.getElementById('weather-widget-city-list');
|
||||
const empty = document.getElementById('weather-widget-city-empty');
|
||||
if (empty) empty.remove();
|
||||
const li = document.createElement('li');
|
||||
li.className = 'checkbox-row';
|
||||
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn-inline secondary weather-widget-city-remove';
|
||||
btn.dataset.label = label;
|
||||
btn.textContent = 'Remove';
|
||||
btn.addEventListener('click', removeWeatherWidgetCity);
|
||||
li.appendChild(span);
|
||||
li.appendChild(btn);
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
async function removeWeatherWidgetCity(e) {
|
||||
const label = e.target.dataset.label;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
e.target.closest('li').remove();
|
||||
const list = document.getElementById('weather-widget-city-list');
|
||||
if (!list.querySelector('li')) {
|
||||
list.innerHTML = '<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>';
|
||||
}
|
||||
showStatus(true, `${label} removed.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadWeatherPreview(force) {
|
||||
const suffix = force ? '&force=1' : '';
|
||||
document.getElementById('weather-preview').src = `${window.FRAME_API}/preview/weather?_=${Date.now()}${suffix}`;
|
||||
}
|
||||
|
||||
function initWeatherDialog() {
|
||||
document.getElementById('weather_mode').addEventListener('change', updateWeatherFieldVisibility);
|
||||
updateWeatherFieldVisibility();
|
||||
|
||||
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
weather_mode: document.getElementById('weather_mode').value,
|
||||
weather_provider: document.getElementById('weather_provider').value,
|
||||
weather_units: document.getElementById('weather_units').value,
|
||||
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
|
||||
weather_daily_days: document.getElementById('weather_daily_days').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-location-set').addEventListener('click', async () => {
|
||||
const input = document.getElementById('weather-location-input');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
const data = await resp.json();
|
||||
document.getElementById('weather-location-current').textContent = `Currently: ${data.city.label}`;
|
||||
input.value = '';
|
||||
showStatus(true, `Location set to ${data.city.label}.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-location-clear').addEventListener('click', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: null }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
document.getElementById('weather-location-current').textContent = 'No location set yet.';
|
||||
showStatus(true, 'Location cleared.');
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.weather-widget-city-remove').forEach((el) => el.addEventListener('click', removeWeatherWidgetCity));
|
||||
|
||||
document.getElementById('weather-widget-city-add').addEventListener('click', async () => {
|
||||
const input = document.getElementById('weather-widget-city-input');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
const data = await resp.json();
|
||||
addWeatherWidgetCityRow(data.city.label);
|
||||
input.value = '';
|
||||
showStatus(true, `Added ${data.city.label}.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
|
||||
loadWeatherPreview(false);
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeWeatherDialog() {
|
||||
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||
}
|
||||
@@ -96,6 +96,8 @@ function initWhiteboardDialog() {
|
||||
// whiteboard's `force` param).
|
||||
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
|
||||
loadWhiteboardPreview(false);
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
|
||||
// --- file picker (Browse...) ---
|
||||
const browseToggle = document.getElementById('whiteboard-browse-toggle');
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Border</h2>
|
||||
<p class="sub">An optional border drawn around this widget's own box --
|
||||
"None" (the default) draws nothing. Shows on the frame's actual live
|
||||
view, not in the standalone preview below.</p>
|
||||
<form id="border-config-form">
|
||||
<label>Style
|
||||
<select id="border_style">
|
||||
{% for style in border_styles %}
|
||||
<option value="{{ style }}" {% if widget.border_style == style %}selected{% endif %}>{{ border_style_labels[style] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Thickness
|
||||
<input type="range" id="border_thickness" min="{{ min_border_thickness }}" max="{{ max_border_thickness }}" step="1"
|
||||
value="{{ widget.border_thickness }}">
|
||||
<span class="slider-value" id="border_thickness_value">{{ widget.border_thickness }}px</span>
|
||||
</label>
|
||||
<label>Color</label>
|
||||
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
|
||||
{% set current_hex = palette_to_hex(current_palette) %}
|
||||
<div class="border-color-picker" id="border-color-picker">
|
||||
{% for label in border_color_labels %}
|
||||
<button type="button" class="color-swatch {% if widget.border_color_index == loop.index0 %}selected{% endif %}"
|
||||
data-index="{{ loop.index0 }}" title="{{ label }}"
|
||||
style="background-color: {{ current_hex[loop.index0] }};"></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="hidden" id="border_color_index" value="{{ widget.border_color_index }}">
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
{% if button_action_labels %}
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Button actions</h2>
|
||||
<p class="sub">What the frame's physical NEXT/BACK buttons do to this
|
||||
widget. Every widget on the frame runs its own binding when a
|
||||
button is pressed -- this only affects this one.</p>
|
||||
<form id="button-actions-form">
|
||||
<label>Next button
|
||||
<select id="next_button_action">
|
||||
<option value="" {% if not next_button_action %}selected{% endif %}>(none)</option>
|
||||
{% for action, label in button_action_labels.items() %}
|
||||
<option value="{{ action }}" {% if next_button_action == action %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Back button
|
||||
<select id="back_button_action">
|
||||
<option value="" {% if not back_button_action %}selected{% endif %}>(none)</option>
|
||||
{% for action, label in button_action_labels.items() %}
|
||||
<option value="{{ action }}" {% if back_button_action == action %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,27 @@
|
||||
<h2 class="dialog-title">Battery widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Settings</h2>
|
||||
<p class="sub">Shows this frame's own last-reported battery level --
|
||||
nothing to configure beyond how much detail to show.</p>
|
||||
<form id="battery-config-form">
|
||||
<label>Display mode
|
||||
<select id="battery_mode">
|
||||
<option value="compact" {% if battery_cfg and battery_cfg.mode == 'compact' %}selected{% endif %}>Compact (icon + percent only)</option>
|
||||
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="battery-preview" alt="Battery widget preview">
|
||||
<button type="button" class="secondary" id="battery-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -127,6 +127,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -41,9 +41,17 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
<button type="button" class="secondary" id="lock-photo-btn" style="margin-top: 8px;">Lock this photo</button>
|
||||
<p class="sub" style="margin-top: 4px;">While locked, this photo stays
|
||||
on screen -- the refresh timer and the next/back buttons won't
|
||||
change it until you unlock it.</p>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<h2 class="dialog-title">Weather widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Display</h2>
|
||||
<form id="weather-config-form">
|
||||
<label>Mode
|
||||
<select id="weather_mode">
|
||||
<option value="current" {% if weather_cfg.mode == "current" %}selected{% endif %}>Current conditions</option>
|
||||
<option value="hourly" {% if weather_cfg.mode == "hourly" %}selected{% endif %}>Hourly forecast</option>
|
||||
<option value="daily" {% if weather_cfg.mode == "daily" %}selected{% endif %}>Multi-day forecast</option>
|
||||
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Weather source
|
||||
<select id="weather_provider">
|
||||
{% for value, label in weather_provider_labels.items() %}
|
||||
<option value="{{ value }}" {% if weather_cfg.provider == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations; Environment Canada only covers Canadian locations.</p>
|
||||
<label>Units
|
||||
<select id="weather_units">
|
||||
<option value="fahrenheit" {% if weather_cfg.units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
|
||||
<option value="celsius" {% if weather_cfg.units == "celsius" %}selected{% endif %}>Celsius</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="weather-hourly-interval-row">
|
||||
<label>Hours between ticks
|
||||
<select id="weather_hourly_interval_hours">
|
||||
{% for hours in (3, 4, 6, 12) %}
|
||||
<option value="{{ hours }}" {% if weather_cfg.hourly_interval_hours == hours %}selected{% endif %}>{{ hours }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="weather-daily-days-row">
|
||||
<label>Days to show
|
||||
<input type="number" id="weather_daily_days" min="1" max="14" value="{{ weather_cfg.daily_days }}">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" id="weather-location-section" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Location</h2>
|
||||
<p class="sub" id="weather-location-current">
|
||||
{% if weather_cfg.city_label %}Currently: {{ weather_cfg.city_label }}{% else %}No location set yet.{% endif %}
|
||||
</p>
|
||||
<div class="checkbox-row" style="flex-wrap: wrap;">
|
||||
<input type="text" id="weather-location-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1 1 160px;">
|
||||
<button type="button" class="btn-inline" id="weather-location-set">Set</button>
|
||||
<button type="button" class="btn-inline secondary" id="weather-location-clear">Clear</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="weather-cities-section" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Cities</h2>
|
||||
<ul class="calendar-user-list" id="weather-widget-city-list">
|
||||
{% for c in weather_cfg.cities or [] %}
|
||||
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
|
||||
<span>{{ c.label }}</span>
|
||||
<button type="button" class="btn-inline secondary weather-widget-city-remove" data-label="{{ c.label }}">Remove</button>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="checkbox-row" style="margin-top: 10px;">
|
||||
<input type="text" id="weather-widget-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
|
||||
<button type="button" class="btn-inline" id="weather-widget-city-add">Add</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="weather-preview" alt="Weather preview">
|
||||
<button type="button" class="secondary" id="weather-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -50,6 +50,10 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -55,37 +55,37 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Button assignments</h2>
|
||||
<p class="sub">What the frame's physical NEXT and BACK buttons do --
|
||||
assign one or more widget actions to each, in the order they
|
||||
should run. A button with several actions runs all of them, in
|
||||
order, then the panel redraws once.</p>
|
||||
<p class="sub" id="button-assign-empty-hint" style="display: none;">
|
||||
Add a widget on the Layout tab first -- there's nothing to assign
|
||||
a button to yet.</p>
|
||||
|
||||
<div id="button-assign-groups">
|
||||
<div class="button-assign-group">
|
||||
<h3 class="button-assign-label">NEXT button</h3>
|
||||
<ul class="button-action-list" id="button-actions-next"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-next"></select>
|
||||
<select id="button-add-action-next"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-assign-group" style="margin-top: 20px;">
|
||||
<h3 class="button-assign-label">BACK button</h3>
|
||||
<ul class="button-action-list" id="button-actions-back"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-back"></select>
|
||||
<select id="button-add-action-back"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="card-title">Hold actions</h2>
|
||||
<p class="sub">Holding NEXT or BACK past this duration runs a
|
||||
frame-wide action instead of each widget's normal short-press
|
||||
binding (set per-widget in that widget's own config dialog).
|
||||
Not scoped to any widget -- e.g. cycling through your saved
|
||||
layouts.</p>
|
||||
<form id="hold-config-form">
|
||||
<label>Hold duration (seconds)
|
||||
<input type="number" id="hold_duration_s" min="3" max="10"
|
||||
value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
|
||||
</label>
|
||||
<label>NEXT held action
|
||||
<select id="next_hold_action">
|
||||
<option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
|
||||
{% for action, label in global_action_labels.items() %}
|
||||
<option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>BACK held action
|
||||
<select id="back_hold_action">
|
||||
<option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
|
||||
{% for action, label in global_action_labels.items() %}
|
||||
<option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
|
||||
@@ -74,6 +74,10 @@
|
||||
<script src="/static/widget_dialog_tasks.js"></script>
|
||||
<script src="/static/widget_dialog_static.js"></script>
|
||||
<script src="/static/widget_dialog_text.js"></script>
|
||||
<script src="/static/widget_dialog_weather.js"></script>
|
||||
<script src="/static/widget_dialog_battery.js"></script>
|
||||
<script src="/static/widget_dialog_border.js"></script>
|
||||
<script src="/static/widget_dialog_button_actions.js"></script>
|
||||
<script src="/static/frame_layout.js"></script>
|
||||
<script src="/static/saved_layouts.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
views only -- never month, there's no room, see calendar_render.py's
|
||||
_BUILDERS). A frame can list multiple cities; each is geocoded once via
|
||||
Open-Meteo's free geocoding API (no API key, no signup, no per-request
|
||||
quota to manage) when added from the Calendar tab, then its daily
|
||||
forecast is refreshed on its own throttle -- same shape idiom as
|
||||
calendar_feed.py's merge-fetch cache.
|
||||
"""Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry)
|
||||
-- free geocoding + forecast APIs, no API key, no signup, no per-request
|
||||
quota to manage. Backs both the calendar widget's embedded weather strip
|
||||
(fetch_daily_forecast/weather_category, its original shape, untouched)
|
||||
and the standalone weather widget's per-mode fetches below (fetch_current/
|
||||
fetch_hourly/fetch_daily, which return already-normalized {"category":
|
||||
...} entries from the shared category set instead of a raw WMO code).
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py.
|
||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from . import WeatherFetchError
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
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:
|
||||
"""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
|
||||
@@ -133,3 +129,67 @@ def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[
|
||||
}
|
||||
except (httpx.HTTPError, KeyError) as 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]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -36,7 +36,8 @@ Each module in this package exposes:
|
||||
|
||||
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 = {
|
||||
"photos": photos,
|
||||
@@ -45,4 +46,34 @@ WIDGET_TYPES = {
|
||||
"tasks": tasks,
|
||||
"static": static_image,
|
||||
"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 []
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
if not cfg.album_id or cfg.locked:
|
||||
return
|
||||
try:
|
||||
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:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
if not cfg.album_id or cfg.locked:
|
||||
return
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
|
||||
@@ -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}
|
||||
+131
-119
@@ -1,11 +1,13 @@
|
||||
"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API
|
||||
(see routers/api_frames.py's api_buttons_get/api_buttons_save and
|
||||
static/frame_config.js). Covers the CRUD/validation layer; multi-action
|
||||
execution order and partial-failure-continues on an actual button press
|
||||
are already exercised end-to-end in test_device_widget_dispatch.py and
|
||||
routers/device.py's _run_button_actions -- this file doesn't re-test
|
||||
device.py's dispatch, just that the assignment API stores/serves/
|
||||
validates what the UI edits."""
|
||||
"""POST /api/frames/{id}/widgets/{widget_id}/button-actions -- each
|
||||
widget's own NEXT/BACK binding editor (see routers/api_widgets.py's
|
||||
api_widget_button_actions and models.FrameButtonAction), replacing the
|
||||
old frame-level "Button assignments" card. Covers the CRUD/validation
|
||||
layer; multi-widget dispatch and partial-failure-continues on an actual
|
||||
button press are already exercised end-to-end in
|
||||
test_device_widget_dispatch.py and routers/device.py's
|
||||
_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
|
||||
|
||||
@@ -16,6 +18,19 @@ from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig
|
||||
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:
|
||||
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
@@ -26,153 +41,150 @@ def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
|
||||
return widget
|
||||
|
||||
|
||||
def test_get_buttons_reflects_the_default_migration_mapping(client, db_session):
|
||||
"""Frame #1's auto-migrated photos widget should already have NEXT ->
|
||||
advance, BACK -> back from _default_button_actions (see
|
||||
migration.py) -- the UI just needs to be able to see that default."""
|
||||
def test_new_widget_gets_the_sane_default_binding(client, db_session):
|
||||
"""A brand new photos widget (via the API, not the migration
|
||||
backfill) should already have NEXT -> advance, BACK -> back from
|
||||
widgets.default_button_actions -- no separate save required."""
|
||||
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()
|
||||
_shrink_default_widget(client, db_session)
|
||||
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")
|
||||
assert resp.status_code == 200
|
||||
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"}]
|
||||
rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
|
||||
assert {r.button: r.action for r in rows} == {"next": "advance", "back": "back"}
|
||||
|
||||
|
||||
def test_get_buttons_includes_placement_and_grid_dims(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."""
|
||||
def test_new_whiteboard_widget_gets_check_now_on_both_buttons(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()
|
||||
photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h = 0, 0, 4, 5
|
||||
db_session.commit()
|
||||
second = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
||||
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()
|
||||
_shrink_default_widget(client, db_session)
|
||||
resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
widget_id = resp.json()["id"]
|
||||
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
|
||||
assert {r.button: r.action for r in rows} == {"next": "check_now", "back": "check_now"}
|
||||
|
||||
|
||||
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"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
board_widget = _add_whiteboard_widget(db_session, frame)
|
||||
_shrink_default_widget(client, db_session)
|
||||
resp = client.post("/api/frames/1/widgets", json={"widget_type": "tasks"},
|
||||
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={
|
||||
"actions": [
|
||||
{"widget_id": board_widget.id, "action": "check_now"},
|
||||
{"widget_id": photo_widget.id, "action": "advance"},
|
||||
],
|
||||
}, headers=csrf_headers(client))
|
||||
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).count() == 0
|
||||
|
||||
|
||||
def test_save_updates_an_existing_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", "back_button_action": "advance"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
rows = db_session.query(FrameButtonAction).filter_by(
|
||||
frame_id=frame.id, button="next"
|
||||
).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"
|
||||
rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
|
||||
assert rows == {"next": "back", "back": "advance"}
|
||||
|
||||
|
||||
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"})
|
||||
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"):
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
|
||||
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(frame_id=frame.id, button="next").count() == 0
|
||||
|
||||
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"})
|
||||
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
|
||||
|
||||
|
||||
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"})
|
||||
other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time())
|
||||
db_session.add(other)
|
||||
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()
|
||||
make_user(db_session, "mallory")
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={
|
||||
"actions": [{"widget_id": other_widget.id, "action": "advance"}],
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
# Nothing partially applied -- the whole request is validated before any write.
|
||||
assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
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 == 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):
|
||||
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):
|
||||
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)
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 200
|
||||
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 == 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"})
|
||||
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()
|
||||
login(client, "mallory")
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 404
|
||||
resp = client.post(f"/api/frames/1/widgets/{board.id}/button-actions",
|
||||
json={"next_button_action": "check_now", "back_button_action": ""},
|
||||
headers=csrf_headers(client))
|
||||
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"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""End-to-end HTTP tests for the widget-system cutover in
|
||||
routers/device.py -- /frame/image, /frame/advance, /frame/back, and
|
||||
/frame/share against real widget rows (via the migration-backfilled
|
||||
frame #1, or a purpose-built second frame), a real TestClient, real
|
||||
routers/device.py -- /frame/image, /frame/advance, and /frame/back
|
||||
against real widget rows (via the migration-backfilled frame #1, or a
|
||||
purpose-built second frame), a real TestClient, real
|
||||
render_panel/compose_into. Only Immich itself is mocked (monkeypatched
|
||||
at the app.widgets.photos module boundary, same pattern as
|
||||
test_widgets_photos.py) -- everything else in the pipeline is real.
|
||||
@@ -23,7 +23,6 @@ from app.models import (
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
PhotoWidgetConfig,
|
||||
User,
|
||||
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)
|
||||
assert cal_cfg.browse_offset == 1
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
resp = client.get("/api/m/not-a-real-token/queue")
|
||||
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
@@ -79,6 +79,19 @@ def test_expected_columns_exist_on_current_schema():
|
||||
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")}
|
||||
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) ---
|
||||
@@ -188,22 +201,25 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
|
||||
migrate."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs are migration 20/21
|
||||
# tables, and saved_layouts/saved_layout_widgets/saved_layout_
|
||||
# sources/saved_layout_button_actions are migration 23's (all
|
||||
# post-16, like the rest of this list) -- dropped here too so a
|
||||
# real version-15 database is what's actually being simulated,
|
||||
# not "version 15 plus tables that wouldn't exist yet". Harmless
|
||||
# to omit as long as no migration after the one that creates a
|
||||
# table also ALTERs it (that's what let static_widget_configs go
|
||||
# unlisted safely so far -- Base.metadata.create_all is
|
||||
# idempotent against an already-present table with no later
|
||||
# ALTER to collide with), but text_widget_configs' migration 22
|
||||
# ALTER makes the gap a real "table already exists"/"duplicate
|
||||
# column" collision instead of a silent no-op.
|
||||
# tables, saved_layouts/saved_layout_widgets/saved_layout_
|
||||
# sources/saved_layout_button_actions are migration 23's,
|
||||
# weather_widget_configs is migration 24's, and
|
||||
# battery_widget_configs is migration 25's (all post-16, like the
|
||||
# rest of this list) -- dropped here too so a real version-15
|
||||
# database is what's actually being simulated, not "version 15
|
||||
# plus tables that wouldn't exist yet". Harmless to omit as long
|
||||
# as no migration after the one that creates a table also ALTERs
|
||||
# or re-CREATEs it (that's what let a create_all-based migration
|
||||
# go unlisted safely so far), but static_widget_configs/
|
||||
# text_widget_configs/weather_widget_configs/battery_widget_
|
||||
# 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",
|
||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||
"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("DROP TABLE frame_calendars"))
|
||||
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"]
|
||||
|
||||
|
||||
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):
|
||||
"""Exercises _migration_17 and _migration_18's actual data-extraction
|
||||
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/
|
||||
calendar_key columns either."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
||||
# too -- see the comment on the identical setup in
|
||||
# static_widget_configs/text_widget_configs/saved_layout_*/
|
||||
# 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
|
||||
# 20/21/23's raw CREATE TABLE collides with an already-present table
|
||||
# otherwise, since this test replays 17 through 23 and none of
|
||||
# 20/21/23/24/25's raw CREATE TABLE collides with an already-present table
|
||||
# otherwise, since this test replays 17 through 25 and none of
|
||||
# these tables would really exist yet at a genuine pre-migration-17
|
||||
# schema_version).
|
||||
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_widgets"))
|
||||
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(
|
||||
"CREATE TABLE calendar_widget_configs ("
|
||||
"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
|
||||
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
||||
# too -- see the comment on the identical setup in
|
||||
# static_widget_configs/text_widget_configs/saved_layout_*/
|
||||
# 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.
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||
"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("DROP TABLE frame_calendars"))
|
||||
conn.execute(text(
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 app.models import (
|
||||
BatteryWidgetConfig,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
PhotoWidgetConfig,
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
WeatherWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
|
||||
@@ -80,6 +82,30 @@ def _add_text_widget(db_session) -> 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:
|
||||
buf = io.BytesIO()
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
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)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -145,8 +145,9 @@ def test_move_unknown_widget_404s(client, db_session):
|
||||
def test_delete_widget_and_cascades_button_actions(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=widget_id, action="advance", sort_order=0))
|
||||
db_session.commit()
|
||||
# Already has a default next->advance binding from creation (see
|
||||
# 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))
|
||||
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"},
|
||||
headers=csrf_headers(client))
|
||||
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))
|
||||
db_session.commit()
|
||||
# photos_id already has a default next->advance binding from creation
|
||||
# (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))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@@ -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 == {}
|
||||
@@ -109,6 +109,41 @@ def test_advance_action_is_a_no_op_when_unconfigured(db_session):
|
||||
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():
|
||||
"""advance_forced's frame/stats split (see app/photo_queue.py) means
|
||||
stats_photos_displayed should land on the Frame row, never on
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user