Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
Build and push server image / test (push) Successful in 1m11s
Build and push server image / build-and-push (push) Successful in 2m3s
Build and push server image / deploy (push) Successful in 52s

New widget type with four display modes -- current conditions, an
hourly forecast strip, a multi-day forecast, and several cities' current
day side by side -- backed by a pluggable provider registry (app/weather/,
mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and
NWS (US-only) both wired up now, Environment Canada documented as the
next one to add given its more involved station/grid-lookup API.

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

New WeatherWidgetConfig table (migration 24), grid footprint, widget
module, common.py fetch/cache helper, router endpoints (location set/
clear, city add/remove, preview), dialog template + JS, and full test
coverage (providers, widget render, HTTP endpoints, migration replay).
docs/widgets.md and CLAUDE.md's TODO updated accordingly.
This commit is contained in:
2026-07-27 16:16:43 +00:00
parent 6118705c37
commit 52ebafab78
25 changed files with 2036 additions and 152 deletions
+2 -2
View File
@@ -4,12 +4,12 @@ A DIY e-ink photo frame: an ESP32-C6 (`firmware/`, ESP-IDF) driving a
Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a
self-hosted FastAPI server (`server/`) that pulls from Immich, does all self-hosted FastAPI server (`server/`) that pulls from Immich, does all
image processing (crop/dither/quantize/pack), and serves a placeable image processing (crop/dither/quantize/pack), and serves a placeable
photos/calendar/whiteboard widget system to the device. photos/calendar/whiteboard/weather widget system to the device.
CURRENT TODO CURRENT TODO
-add more actions for buttons (i.e. change widget/layout) -add more actions for buttons (i.e. change widget/layout)
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults) -Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
-make a weather widget -add Environment Canada as a weather widget provider (app/weather/ -- station/grid-lookup API, more involved than Open-Meteo/NWS)
-widget border option -widget border option
-battery life widget -battery life widget
-sharing layouts with linked users -sharing layouts with linked users
+72 -11
View File
@@ -1,10 +1,10 @@
# Widget system # Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
arranging icons on an Android home screen. A frame can hold several widgets of the weather), like arranging icons on an Android home screen. A frame can hold
same type (e.g. two photo widgets pointed at different Immich albums side several widgets of the same type (e.g. two photo widgets pointed at
by side). different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame` full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
@@ -20,14 +20,16 @@ a button press does.
## Data model ## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type` - `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h` (`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` |
`"text"` | `"weather"`), `x`/`y`/`w`/`h`
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in (grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client `routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order, already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas. no blending, just N independent regions pasted onto one shared canvas.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`, - Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with `StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`, each
keyed by `widget_id` with
`ondelete="CASCADE"` -- rather than one wide table with every type's `ondelete="CASCADE"` -- rather than one wide table with every type's
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
text (paragraphs of styled runs), never raw HTML -- see text (paragraphs of styled runs), never raw HTML -- see
@@ -41,6 +43,10 @@ a button press does.
into its own widget type (migration 17) so a task list can be placed into its own widget type (migration 17) so a task list can be placed
and sized independent of any calendar's view/footprint, then (migration and sized independent of any calendar's view/footprint, then (migration
18) given the same multi-source shape a calendar widget already has. 18) given the same multi-source shape a calendar widget already has.
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
weather strip (still present and unchanged, `weather_*` columns) out
into its own placeable widget type (migration 24) -- see "Weather
widget" below.
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not - `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`frame_id`) since a frame can now have more than one independent `frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical calendar/tasks widget, each with its own included set. Identical
@@ -65,7 +71,9 @@ orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`): Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1. size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
weather 2x2 (its hourly/daily strips need the room; current/multi_city
modes would tolerate smaller, but every mode shares one footprint value).
Enforced both client-side Enforced both client-side
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`) (UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
and server-side (`routers/api_widgets.py`) -- the client is never trusted and server-side (`routers/api_widgets.py`) -- the client is never trusted
@@ -75,7 +83,7 @@ alone.
`app/widgets/` is the render/action registry -- one module per `app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`, `widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
`static_image.py`, `text.py`), each exposing: `static_image.py`, `text.py`, `weather.py`), each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`: - `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's an unquantized RGB image exactly `target_w x target_h`, the widget's
@@ -85,9 +93,10 @@ alone.
bad moment doesn't blank the whole panel. bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type - `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard). Empty for tasks, static image, and text -- nothing to for whiteboard and weather -- both throttled external fetches with a
advance/back/force for a passive checklist, a fixed uploaded image, or forced-refetch action). Empty for tasks, static image, and text --
a fixed block of authored text. nothing to advance/back/force for a passive checklist, a fixed
uploaded image, or a fixed block of authored text.
- `ACTION_LABELS: dict[str, str]` -- human labels for the button- - `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI. assignment UI.
@@ -190,6 +199,58 @@ The web UI lives in the Layout tab's "Saved layouts" card
saved layouts each with Apply/rename/delete, incompatible ones shown saved layouts each with Apply/rename/delete, incompatible ones shown
greyed-out with a "different orientation" badge rather than hidden. greyed-out with a "different orientation" badge rather than hidden.
## Weather widget
A standalone widget type (`models.WeatherWidgetConfig`, `app/widgets/
weather.py`) -- distinct from, and unrelated in code to,
`CalendarWidgetConfig`'s own embedded weather strip (still present,
still Open-Meteo-only, still working exactly as before). Four display
modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
`calendar_view`):
- `current` -- one city's current temp + a condition icon.
- `hourly` -- one city, a row of ticks across the day at a configurable
interval (`hourly_interval_hours`: 3/4/6/12).
- `daily` -- one city, a multi-day strip (`daily_days`, 1-14).
- `multi_city` -- several cities' current-day high/low/icon side by
side -- the calendar widget's embedded strip, as a standalone
widget's whole content instead of a strip above an agenda day.
`current`/`hourly`/`daily` share one configured location
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
has its own list (`cities`, add/remove via `POST .../weather-widget-
cities/add`|`remove` -- named to avoid colliding with the calendar
widget's own, differently-scoped `weather-cities/add`|`remove` routes,
which share the same `{widget_id}`-parameterized path shape).
**Providers** (`app/weather/`, a dispatch registry over pluggable
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
provider` selects which of `app/weather.PROVIDERS` actually fetches --
`"open_meteo"` (worldwide, no API key) or `"nws"` (api.weather.gov, US
only, no API key, approximates "current" with the first hourly forecast
period rather than a real station observation). 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.
**Environment Canada is a deliberate gap, not an oversight** -- its free
API (the MSC GeoMet OGC service) is built around station/grid lookups,
not simple lat/lon REST like the two providers above, and would have
meaningfully expanded the initial pass. Next provider to add if this
gets revisited.
`app/weather_render.py` holds every weather-related drawing primitive:
`draw_cloud`/`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.
## Known gaps (Phase 6, not yet done) ## Known gaps (Phase 6, not yet done)
The original 8-phase rollout plan's last phase is still open: The original 8-phase rollout plan's last phase is still open:
+11 -99
View File
@@ -35,6 +35,7 @@ from .image_pipeline import (
logical_render_size, logical_render_size,
) )
from .weather import weather_category from .weather import weather_category
from .weather_render import draw_weather_row
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"] CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)", CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
@@ -389,98 +390,6 @@ def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]
return entries return entries
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
with a clean outline -- drawn as one black pass slightly larger than
the shapes, then the same shapes again in white on top. Overlapping
ellipses each drawn with their own `outline=` would leave visible
seams where they cross; this double-draw trick sidesteps that
entirely regardless of how the lobes overlap."""
stroke = 2
lobes = [
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
]
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0, y0, x1, y1], fill=BG)
draw.rectangle(base, fill=BG)
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
"""A small hand-drawn glyph for one weather category -- no custom
font/icon asset, same hand-primitives-only approach the rest of this
module uses (colored rectangles for owner indicators, density dots
for month view)."""
if category == "clear":
# Kept within a ~1.1r visual radius overall (rays included) to
# match _draw_cloud's own footprint -- _draw_weather_row lays
# icons out assuming each one stays roughly within icon_r of its
# center, and the first entry in a row sits flush against the
# region's own left margin, so any icon that draws wider than
# that pokes out past it with nothing to visually connect to.
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
fill=FG, width=3)
return
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
if category == "partly_cloudy":
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
_draw_cloud(draw, cx, cloud_cy, r)
if category == "fog":
for i in range(3):
y = cy + r * 0.5 + i * (r * 0.45)
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
elif category == "rain":
for dx in (-0.6, 0, 0.6):
x = cx + dx * r
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
elif category == "snow":
for dx in (-0.6, 0, 0.6):
x, y = cx + dx * r, cloud_cy + r * 0.9
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
elif category == "thunderstorm":
x, y = cx, cloud_cy + r * 0.5
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
fill=FG, width=2)
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
show_labels: bool = True) -> int:
"""Draws one or more cities' weather side by side starting at
(x0, y0), stopping once another entry wouldn't fit within max_w
(narrow views like week columns just end up showing fewer cities --
same graceful-degradation approach month view takes with density
dots). Returns the row height consumed (0 if there was nothing to
draw, so callers can skip reserving space entirely)."""
if not entries:
return 0
unit_suffix = "F" if units == "fahrenheit" else "C"
row_h = icon_r * 2 + 8
x = x0
drew_any = False
for entry in entries:
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
label = f"{entry['label']} {temps}" if show_labels else temps
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
if drew_any and x + entry_w > x0 + max_w:
break
cx, cy = x + icon_r, y0 + icon_r
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
x += entry_w
drew_any = True
return row_h + 6
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo, def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont, region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None, body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
@@ -504,8 +413,9 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries, y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units) icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
palette_rgb=palette_rgb)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14 row_h = body_font.size + 14
@@ -720,13 +630,15 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
y = MARGIN + header_h y = MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see # Columns are narrow, so only what actually fits gets drawn (see
# _draw_weather_row) -- typically one city, no label (the column # weather_render.draw_weather_row) -- typically one city, no label
# itself makes which day it's for obvious; a city name wouldn't fit # (the column itself makes which day it's for obvious; a city name
# anyway). Never more than that -- this is already the tight view. # wouldn't fit anyway). Never more than that -- this is already
# the tight view.
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries, y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False) icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb)
row_h = chip_font.size + 10 row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h) max_rows = max(0, (target_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
+4 -1
View File
@@ -24,7 +24,9 @@ GRID_SHORT = 5
# scaling (see calendar_render.py); whiteboard needs enough room to be # scaling (see calendar_render.py); whiteboard needs enough room to be
# worth looking at; photos can go as small as a single cell; tasks needs # worth looking at; photos can go as small as a single cell; tasks needs
# enough width for a due-date prefix plus a couple words of summary # enough width for a due-date prefix plus a couple words of summary
# without truncating on every row. # without truncating on every row; weather needs enough room for its
# hourly/daily strips to stay legible (its current/multi_city modes
# would tolerate smaller, but every mode shares one footprint value).
MIN_FOOTPRINT: dict[str, tuple[int, int]] = { MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1), "photos": (1, 1),
"calendar": (3, 2), "calendar": (3, 2),
@@ -32,6 +34,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"tasks": (2, 2), "tasks": (2, 2),
"static": (1, 1), "static": (1, 1),
"text": (2, 1), "text": (2, 1),
"weather": (2, 2),
} }
Rect = tuple[int, int, int, int] # (x, y, w, h) Rect = tuple[int, int, int, int] # (x, y, w, h)
+33
View File
@@ -624,6 +624,38 @@ 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)"
))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -648,6 +680,7 @@ MIGRATIONS = [
(21, _migration_21), (21, _migration_21),
(22, _migration_22), (22, _migration_22),
(23, _migration_23), (23, _migration_23),
(24, _migration_24),
] ]
+37 -1
View File
@@ -433,7 +433,7 @@ class Widget(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather"
x: Mapped[int] = mapped_column(Integer) x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer) y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer) w: Mapped[int] = mapped_column(Integer)
@@ -553,6 +553,41 @@ class WhiteboardWidgetConfig(Base):
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
class WeatherWidgetConfig(Base):
"""One weather widget's settings + cached-fetch state. Four display
modes (see app/widgets/weather.py): "current" (one city, current
temp + icon), "hourly" (one city, a row of ticks across the day),
"daily" (one city, a multi-day strip), "multi_city" (several cities'
current-day high/low/icon side by side -- the calendar widget's
embedded weather strip, lifted out into its own widget type).
`provider` selects which of app/weather/'s PROVIDERS actually fetches
("open_meteo" | "nws" -- see that package's own module docstring).
`cached`'s shape depends on `mode`: {"temp","category"} for current,
a list of {"time","temp","category"} for hourly, a
{"YYYY-MM-DD": {...}} dict for daily, or a list of
{"label","high","low","category"} for multi_city."""
__tablename__ = "weather_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
# Single-location modes only (current/hourly/daily) -- geocoded once
# via weather.geocode_city() when set, same idiom as
# CalendarWidgetConfig.weather_cities' per-entry shape.
city_label: Mapped[str | None] = mapped_column(String, nullable=True)
city_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
city_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
hourly_interval_hours: Mapped[int] = mapped_column(Integer, default=4)
daily_days: Mapped[int] = mapped_column(Integer, default=5)
# multi_city mode only -- [{"label", "latitude", "longitude"}, ...],
# same shape as CalendarWidgetConfig.weather_cities.
cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached: Mapped[dict | list | None] = mapped_column(JSON, nullable=True, default=None)
class TextWidgetConfig(Base): class TextWidgetConfig(Base):
"""One text widget's authored content + display settings -- another """One text widget's authored content + display settings -- another
no-live-upstream type like StaticWidgetConfig, just parsed rich text no-live-upstream type like StaticWidgetConfig, just parsed rich text
@@ -618,6 +653,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"tasks": TaskWidgetConfig, "tasks": TaskWidgetConfig,
"static": StaticWidgetConfig, "static": StaticWidgetConfig,
"text": TextWidgetConfig, "text": TextWidgetConfig,
"weather": WeatherWidgetConfig,
} }
+157 -1
View File
@@ -26,7 +26,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import ( from ..image_pipeline import (
@@ -47,6 +47,7 @@ from ..models import (
StaticWidgetConfig, StaticWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
TextWidgetConfig, TextWidgetConfig,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS, WIDGET_CONFIG_MODELS,
Widget, Widget,
@@ -60,6 +61,7 @@ from .common import (
get_or_refresh_calendar_events_for_widget, get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget, get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget, get_or_refresh_weather_for_widget,
get_or_refresh_weather_widget_data,
get_or_refresh_whiteboard_for_widget, get_or_refresh_whiteboard_for_widget,
immich_client_for, immich_client_for,
immich_creds, immich_creds,
@@ -283,6 +285,12 @@ def api_widget_config_save(
text_font_family: str | None = Form(None), text_font_family: str | None = Form(None),
text_align: str | None = Form(None), text_align: str | None = Form(None),
text_background_color: str | None = Form(None), text_background_color: str | None = Form(None),
# weather
weather_mode: str | None = Form(None),
weather_provider: str | None = Form(None),
weather_units: str | None = Form(None),
weather_hourly_interval_hours: int | None = Form(None),
weather_daily_days: int | None = Form(None),
): ):
"""Every field optional -- same partial-update, form-urlencoded """Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one convention as the old frame-level api_config_save, now scoped to one
@@ -379,6 +387,36 @@ def api_widget_config_save(
xcfg.background_color = ( xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff" text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
) )
elif widget.widget_type == "weather":
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
if weather_mode != wcfg.mode:
# A stale cache is a different shape under a
# different mode (a single-temp dict vs. an hourly
# list vs. a daily dict vs. a city list) -- clear it
# outright (not just force a refetch attempt) so a
# get_or_refresh_weather_widget_data call that happens
# to fail on the very first fetch under the new mode
# doesn't fall back to the old mode's incompatible
# cached shape.
wcfg.checked_at = 0.0
wcfg.cached = None
wcfg.mode = weather_mode
if weather_provider is not None and weather_provider in weather.PROVIDERS:
if weather_provider != wcfg.provider:
wcfg.checked_at = 0.0
wcfg.provider = weather_provider
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
if weather_units != wcfg.units:
# Cached temps are in the old unit -- force a refetch
# rather than showing stale numbers under a new unit
# label (same idiom as calendar_weather_units above).
wcfg.checked_at = 0.0
wcfg.units = weather_units
if weather_hourly_interval_hours is not None:
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
if weather_daily_days is not None:
wcfg.daily_days = max(1, min(14, weather_daily_days))
with frame_locked(db, frame.id) as cfg: with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -833,6 +871,124 @@ def api_widget_weather_city_remove(
return {"status": "saved"} return {"status": "saved"}
# --- Weather widget: location/cities/preview -------------------------------
#
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
# "weather-cities") specifically to avoid colliding with the calendar
# widget's own /weather-cities/add|remove route *patterns* above -- both
# are registered against the same {widget_id}-parameterized path shape,
# so a literal name clash there would silently shadow one of them
# regardless of each handler's own _require_widget_type check.
class WeatherLocationRequest(BaseModel):
name: str | None # None clears the location; else a free-text city name to geocode
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
def api_widget_weather_location(
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets (or clears) this weather widget's single configured location
-- the current/hourly/daily modes' one city. A widget-wide display
setting like calendar_view/weather_units, not personal data, hence
require_widget_control rather than the calendar/tasks owner-adds/
anyone-mutes split (there's only ever one location and no per-person
ownership of it)."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
if body.name is None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = None
cfg.city_latitude = None
cfg.city_longitude = None
cfg.cached = None
cfg.checked_at = 0.0
return {"status": "saved", "city": None}
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = city["label"]
cfg.city_latitude = city["latitude"]
cfg.city_longitude = city["longitude"]
cfg.cached = None
cfg.checked_at = 0.0 # pick up the new location promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
def api_widget_weather_widget_city_add(
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""multi_city mode's city list -- same shape/gating as the calendar
widget's own weather-cities/add above, just scoped to this widget's
own WeatherWidgetConfig.cities."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = list(cfg.cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this widget's list")
cities.append(city)
cfg.cities = cities
cfg.checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
def api_widget_weather_widget_city_remove(
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "weather")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
cfg.cities = cities
if cfg.cached:
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
return {"status": "saved"}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
def api_widget_preview_weather(
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""The same throttled fetch cache a live device render would use, run
through the panel composition/quantization pipeline -- "how it will
look on the frame", same convention as the other preview endpoints.
force=True (the "Refresh now" button) bypasses the fetch throttle."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
wcfg = db.get(WeatherWidgetConfig, widget.id)
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
if data is None:
if wcfg.mode == "multi_city":
raise HTTPException(400, "No cities added to this widget yet")
raise HTTPException(400, "No location set on this widget yet")
png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
)
return Response(content=png, media_type="image/png")
# --- Static image: upload/preview ----------------------------------------- # --- Static image: upload/preview -----------------------------------------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload") @router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
+64
View File
@@ -31,6 +31,7 @@ from ..models import (
TaskWidgetConfig, TaskWidgetConfig,
User, User,
Widget, Widget,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
) )
@@ -574,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
return result return result
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
weather widget (see app/widgets/weather.py) -- reads/writes
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
hourly/daily need a single configured location (city_latitude/
city_longitude); multi_city needs cfg.cities. None if not configured
yet, so render() falls back to a placeholder -- same convention as
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
button) bypasses the throttle entirely.
A single-location mode's fetch failure keeps the last-known cached
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
multi_city fetch fails per-city (like get_or_refresh_weather_for_
widget's calendar-strip counterpart) so one broken city doesn't blank
the others."""
cfg = db.get(WeatherWidgetConfig, widget.id)
if cfg.mode == "multi_city":
if not cfg.cities:
return None
elif cfg.city_latitude is None or cfg.city_longitude is None:
return None
now = time.time()
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
return cfg.cached
if cfg.mode == "multi_city":
previous = {c["label"]: c for c in (cfg.cached or [])}
result = []
for city in cfg.cities:
try:
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
d = next(iter(today.values())) if today else previous.get(city["label"], {})
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
d = previous.get(city["label"], {})
result.append({
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
"category": d.get("category", "cloudy"),
})
else:
try:
if cfg.mode == "current":
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
elif cfg.mode == "hourly":
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
hours=HOURLY_FETCH_HOURS)
else: # "daily"
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
cfg.daily_days)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
return cfg.cached
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached = result
locked_cfg.checked_at = now
return result
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
+9
View File
@@ -17,6 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import weather
from ..auth import can_view_frame, current_user from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db from ..db import get_db
@@ -38,6 +39,7 @@ from ..models import (
TextWidgetConfig, TextWidgetConfig,
User, User,
UserFrame, UserFrame,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
Widget, Widget,
) )
@@ -265,4 +267,11 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"viewer_has_webdav_creds": viewer_has_webdav_creds, "viewer_has_webdav_creds": viewer_has_webdav_creds,
}) })
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,
})
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}") raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
+1 -1
View File
@@ -62,7 +62,7 @@
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns). // UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = { const WIDGET_LABELS = {
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks', photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
static: 'Static image', text: 'Text', static: 'Static image', text: 'Text', weather: 'Weather',
}; };
function showStatus(ok, message) { function showStatus(ok, message) {
+2 -2
View File
@@ -286,11 +286,11 @@ window.addEventListener('resize', () => {
// icon was clicked). // icon was clicked).
const DIALOG_INIT = { const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
}; };
const DIALOG_CLOSE = { const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
}; };
let openDialogWidgetType = null; let openDialogWidgetType = null;
+158
View File
@@ -0,0 +1,158 @@
// 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);
}
function closeWeatherDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
@@ -0,0 +1,81 @@
<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.</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>
<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>
+1
View File
@@ -74,6 +74,7 @@
<script src="/static/widget_dialog_tasks.js"></script> <script src="/static/widget_dialog_tasks.js"></script>
<script src="/static/widget_dialog_static.js"></script> <script src="/static/widget_dialog_static.js"></script>
<script src="/static/widget_dialog_text.js"></script> <script src="/static/widget_dialog_text.js"></script>
<script src="/static/widget_dialog_weather.js"></script>
<script src="/static/frame_layout.js"></script> <script src="/static/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script> <script src="/static/saved_layouts.js"></script>
{% endblock %} {% endblock %}
+61
View File
@@ -0,0 +1,61 @@
"""Weather provider registry -- the app/widgets/ "dispatch registry over
pluggable implementations" pattern applied to weather data sources
instead of widget types. Open-Meteo (app/weather/open_meteo.py, no API
key, worldwide) and NWS (app/weather/nws.py, no API key, US-only) are the
two providers wired up now; Environment Canada is a documented next step
(docs/widgets.md), not included yet -- its free API is built around
station/grid lookups (the MSC GeoMet OGC service), not simple lat/lon
REST like these two, and would have meaningfully expanded this pass.
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 nws, open_meteo # noqa: E402 -- after WeatherFetchError, which both 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}
PROVIDER_LABELS = {"open_meteo": "Open-Meteo", "nws": "National Weather Service (US)"}
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)
+196
View File
@@ -0,0 +1,196 @@
"""NWS (National Weather Service, api.weather.gov) provider -- US
locations only, free, no API key, but requires a `User-Agent` header
identifying the calling app (NWS API policy: unidentified traffic gets
throttled/blocked). No geocoder of its own; app/weather/__init__.py's
geocode_city (Open-Meteo's) resolves a place name to lat/lon regardless
of which provider is then chosen to fetch with it.
Unlike Open-Meteo's numeric WMO codes, NWS periods carry a `shortForecast`
text description and an `icon` URL encoding a condition code (e.g.
".../icons/land/day/tsra,40?size=medium") -- _category_from_period below
normalizes either into the same shared category set
(clear/partly_cloudy/cloudy/fog/rain/snow/thunderstorm) Open-Meteo's
weather_category() already produces, so app/weather_render.py's build_*
functions never need to know which provider supplied an entry.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py.
"""
from __future__ import annotations
from datetime import datetime
import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0
BASE_URL = "https://api.weather.gov"
# NWS blocks/deprioritizes traffic with no identifying User-Agent -- a
# contact-ish string is their documented convention, not a real account.
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
# NWS's icon condition codes (https://api.weather.gov/icons), grouped into
# the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO
# codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same
# sky condition plus wind -- stripped before lookup, since this project's
# hand-drawn icons (app/weather_render.py) don't have a separate windy
# glyph.
_ICON_CODE_CATEGORIES = {
"skc": "clear", "clear": "clear",
"few": "partly_cloudy", "sct": "partly_cloudy",
"bkn": "cloudy", "ovc": "cloudy",
"fog": "fog", "haze": "fog", "smoke": "fog", "dust": "fog",
"rain": "rain", "rain_showers": "rain", "rain_showers_hi": "rain",
"showers": "rain", "drizzle": "rain",
"snow": "snow", "rain_snow": "snow", "sleet": "snow",
"fzra": "snow", "rain_fzra": "snow", "snow_fzra": "snow",
"tsra": "thunderstorm", "tsra_sct": "thunderstorm", "tsra_hi": "thunderstorm",
}
# Fallback keyword match against shortForecast text, in priority order --
# used when the icon URL can't be parsed at all (unexpected shape) or its
# condition code isn't in the table above (NWS occasionally adds new
# icon variants).
_TEXT_CATEGORY_KEYWORDS = [
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"),
("snow", "snow"), ("sleet", "snow"), ("ice", "snow"),
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
("overcast", "cloudy"), ("cloudy", "cloudy"),
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
]
def _category_from_icon(icon_url: str) -> str | None:
"""The first condition code segment out of an icon URL path like
".../icons/land/day/tsra,40/skc,20?size=medium" -- only the first
(the dominant/nearest-term condition) is used, same "one glyph per
entry" simplicity as Open-Meteo's single-WMO-code-per-day shape."""
path = icon_url.split("?")[0]
segments = [s for s in path.split("/") if s]
for i, seg in enumerate(segments):
if seg in ("day", "night") and i + 1 < len(segments):
code = segments[i + 1].split(",")[0]
code = code.removeprefix("wind_")
return _ICON_CODE_CATEGORIES.get(code)
return None
def _category_from_text(text: str) -> str:
lowered = text.lower()
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
if keyword in lowered:
return category
return "cloudy" # same generic-icon fallback Open-Meteo's weather_category uses
def _category_from_period(period: dict) -> str:
icon = period.get("icon") or ""
category = _category_from_icon(icon) if icon else None
return category or _category_from_text(period.get("shortForecast") or "")
def _convert_temp(value: float, from_unit: str, to_units: str) -> float:
"""NWS periods report temperatureUnit per-period (almost always "F"
for US points) -- converts to the widget's own requested `units`
("fahrenheit"/"celsius") only if they actually differ, so this is a
no-op in the common case."""
to_unit = "F" if to_units == "fahrenheit" else "C"
if from_unit == to_unit:
return value
if from_unit == "F" and to_unit == "C":
return (value - 32) * 5 / 9
return value * 9 / 5 + 32
def _points(latitude: float, longitude: float) -> dict:
try:
resp = httpx.get(
f"{BASE_URL}/points/{latitude:.4f},{longitude:.4f}", headers=HEADERS, timeout=HTTP_TIMEOUT_S
)
resp.raise_for_status()
return resp.json()["properties"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(f"NWS points lookup failed (is this location in the US?): {e}") from e
def _periods(url: str) -> list[dict]:
try:
resp = httpx.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
return resp.json()["properties"]["periods"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
"""{"temp": float, "category": str} -- NWS has no simple by-
coordinate "current conditions" endpoint (that needs a second
stations-list + latest-observation lookup); this approximates
"current" with the first hourly forecast period instead, which is
plenty for a frame that only refreshes every few hours."""
points = _points(latitude, longitude)
periods = _periods(points["forecastHourly"])
if not periods:
raise WeatherFetchError("NWS returned no hourly forecast periods")
period = periods[0]
temp = _convert_temp(period["temperature"], period.get("temperatureUnit", "F"), units)
return {"temp": temp, "category": _category_from_period(period)}
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
one entry per hour -- NWS's forecastHourly is already 1-hour
resolution, same as Open-Meteo's."""
points = _points(latitude, longitude)
periods = _periods(points["forecastHourly"])
return [
{
"time": p["startTime"],
"temp": _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units),
"category": _category_from_period(p),
}
for p in periods[:hours]
]
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
-- NWS's /forecast returns day/night period pairs (12h each, ~7 days
/ 14 periods), not one row per day; this pairs them by calendar date
(a daytime period's high, the following night's low) and clamps to
however many full dates actually came back once `days` is asked for
more than that -- no error, just fewer days, same graceful-
degradation idiom as app/weather_render.py's draw_weather_row."""
points = _points(latitude, longitude)
periods = _periods(points["forecast"])
by_date: dict[str, dict] = {}
order: list[str] = []
for p in periods:
day = datetime.fromisoformat(p["startTime"]).date().isoformat()
entry = by_date.setdefault(day, {"category": None})
if day not in order:
order.append(day)
temp = _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units)
if p["isDaytime"]:
entry["high"] = temp
entry["category"] = _category_from_period(p)
else:
entry["low"] = temp
if entry["category"] is None:
entry["category"] = _category_from_period(p)
result = {}
for day in order[:max(1, days)]:
entry = by_date[day]
if "high" not in entry and "low" not in entry:
continue
result[day] = {
"high": entry.get("high", entry.get("low")),
"low": entry.get("low", entry.get("high")),
"category": entry["category"] or "cloudy",
}
return result
@@ -1,10 +1,10 @@
"""Weather strip for calendar frame mode (agenda/today & tomorrow/week """Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry)
views only -- never month, there's no room, see calendar_render.py's -- free geocoding + forecast APIs, no API key, no signup, no per-request
_BUILDERS). A frame can list multiple cities; each is geocoded once via quota to manage. Backs both the calendar widget's embedded weather strip
Open-Meteo's free geocoding API (no API key, no signup, no per-request (fetch_daily_forecast/weather_category, its original shape, untouched)
quota to manage) when added from the Calendar tab, then its daily and the standalone weather widget's per-mode fetches below (fetch_current/
forecast is refreshed on its own throttle -- same shape idiom as fetch_hourly/fetch_daily, which return already-normalized {"category":
calendar_feed.py's merge-fetch cache. ...} entries from the shared category set instead of a raw WMO code).
Pure functions -- no ORM, no FastAPI Depends -- same testability Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py. philosophy as calendar_feed.py/caldav_client.py.
@@ -14,6 +14,8 @@ from __future__ import annotations
import httpx import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0 HTTP_TIMEOUT_S = 15.0
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast" FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
@@ -36,12 +38,6 @@ _CODE_CATEGORIES = {
} }
class WeatherFetchError(Exception):
"""Geocoding or forecast fetch failed -- network, no match, or an
unexpected response shape. Raised loudly; callers (the Calendar
tab's add-city endpoint, get_or_refresh_weather) decide what to do."""
def weather_category(code: int) -> str: def weather_category(code: int) -> str:
"""Falls back to "cloudy" for any WMO code Open-Meteo might add later """Falls back to "cloudy" for any WMO code Open-Meteo might add later
that isn't in the table above -- an unrecognized code shouldn't drop that isn't in the table above -- an unrecognized code shouldn't drop
@@ -133,3 +129,67 @@ def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[
} }
except (httpx.HTTPError, KeyError) as e: except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e raise WeatherFetchError(str(e)) from e
# --- Standalone weather widget fetches (app/widgets/weather.py) --------
#
# Unlike fetch_daily_forecast above (kept as-is for the calendar widget's
# embedded strip, which does its own weather_category(code) lookup),
# these return already-normalized {"category": ...} entries so
# app/weather_render.py's build_* functions never need to know which
# provider supplied the data (see app/weather/nws.py, which normalizes
# its own icon/text shape to the same category set).
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
"""{"temp": float, "category": str} for right now."""
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"current": "temperature_2m,weathercode",
"temperature_unit": units,
"timezone": "auto",
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
current = resp.json()["current"]
return {"temp": current["temperature_2m"], "category": weather_category(current["weathercode"])}
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
one entry per hour, for the next `hours` hours (Open-Meteo's hourly
data is always 1-hour resolution regardless of how far apart the
ticks a caller actually wants to display are -- see
app/weather_render.py's build_hourly, which samples every Nth
entry)."""
forecast_days = max(1, -(-hours // 24)) # ceil division -- enough days to cover `hours`
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"hourly": "temperature_2m,weathercode",
"temperature_unit": units,
"timezone": "auto",
"forecast_days": forecast_days,
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
hourly = resp.json()["hourly"]
return [
{"time": t, "temp": temp, "category": weather_category(code)}
for t, temp, code in zip(hourly["time"], hourly["temperature_2m"], hourly["weathercode"])
][:hours]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
-- same request as fetch_daily_forecast, just normalized to a
category instead of a raw WMO code, and clamped to `days` (Open-
Meteo's own real max is FORECAST_DAYS)."""
clamped = max(1, min(days, FORECAST_DAYS))
raw = fetch_daily_forecast(latitude, longitude, units)
return {
day: {"high": d["high"], "low": d["low"], "category": weather_category(d["code"])}
for day, d in list(raw.items())[:clamped]
}
+399
View File
@@ -0,0 +1,399 @@
"""Weather icon-drawing primitives (draw_cloud/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.
"""
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, not a
hardcoded RGB triple."""
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 thick radiating rays -- the standard weather-app
"sun" glyph, in the panel's actual yellow ink rather than a bare
outline (a plain circle-with-4-ticks at small sizes read as a
crosshair/target, not a sun)."""
draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
for i in range(8):
angle = i * (math.pi / 4)
dx, dy = math.cos(angle), math.sin(angle)
draw.line([(cx + dx * r * 0.68, cy + dy * r * 0.68), (cx + dx * r * 1.05, cy + dy * r * 1.05)],
fill=color, width=max(2, round(r * 0.14)))
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 asterisk -- the standard snowflake glyph."""
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.3)))
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 -- no custom font/icon
asset (hand-primitives only, same approach calendar_render.py uses
elsewhere for e.g. month view's density dots), but drawn in the
panel's own ink colors (yellow sun/bolt, blue rain/snow) rather than
flat black -- a plain monochrome outline at small sizes read as
abstract shapes (a "sun" that looked like a crosshair), not
recognizable weather icons."""
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()
+2 -1
View File
@@ -36,7 +36,7 @@ Each module in this package exposes:
from __future__ import annotations from __future__ import annotations
from . import calendar, photos, static_image, tasks, text, whiteboard from . import calendar, photos, static_image, tasks, text, weather, whiteboard
WIDGET_TYPES = { WIDGET_TYPES = {
"photos": photos, "photos": photos,
@@ -45,4 +45,5 @@ WIDGET_TYPES = {
"tasks": tasks, "tasks": tasks,
"static": static_image, "static": static_image,
"text": text, "text": text,
"weather": weather,
} }
+40
View File
@@ -0,0 +1,40 @@
"""Weather widget: one of four display modes (see models.
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
py's build() dispatch. No real "next"/"back" concept (same as
whiteboard) -- a single "check now" action forces a re-fetch bypassing
the normal throttle."""
from __future__ import annotations
from PIL import Image
from sqlalchemy.orm import Session
from .. import weather_render
from ..models import Frame, WeatherWidgetConfig, Widget
from ..routers.common import get_or_refresh_weather_widget_data
from ._shared import placeholder_image
ACTION_LABELS = {"check_now": "Check for updates"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused here -- see app/widgets/photos.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
cfg = db.get(WeatherWidgetConfig, widget.id)
data = get_or_refresh_weather_widget_data(db, frame, widget)
if data is None:
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"])
return weather_render.build(
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours,
)
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
get_or_refresh_weather_widget_data(db, frame, widget, force=True)
ACTIONS = {"check_now": _check_now}
+27 -20
View File
@@ -79,6 +79,9 @@ def test_expected_columns_exist_on_current_schema():
assert "text_widget_configs" in inspector.get_table_names() # migration 21 assert "text_widget_configs" in inspector.get_table_names() # migration 21
text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")} text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")}
assert "font_family" in text_widget_columns # migration 22 assert "font_family" in text_widget_columns # migration 22
assert "weather_widget_configs" in inspector.get_table_names() # migration 24
weather_widget_columns = {c["name"] for c in inspector.get_columns("weather_widget_configs")}
assert {"mode", "provider", "city_latitude", "cities"} <= weather_widget_columns
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
@@ -188,22 +191,23 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
migrate.""" migrate."""
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs are migration 20/21 # static_widget_configs/text_widget_configs are migration 20/21
# tables, and saved_layouts/saved_layout_widgets/saved_layout_ # tables, saved_layouts/saved_layout_widgets/saved_layout_
# sources/saved_layout_button_actions are migration 23's (all # sources/saved_layout_button_actions are migration 23's, and
# post-16, like the rest of this list) -- dropped here too so a # weather_widget_configs is migration 24's (all post-16, like the
# real version-15 database is what's actually being simulated, # rest of this list) -- dropped here too so a real version-15
# not "version 15 plus tables that wouldn't exist yet". Harmless # database is what's actually being simulated, not "version 15
# to omit as long as no migration after the one that creates a # plus tables that wouldn't exist yet". Harmless to omit as long
# table also ALTERs it (that's what let static_widget_configs go # as no migration after the one that creates a table also ALTERs
# unlisted safely so far -- Base.metadata.create_all is # or re-CREATEs it (that's what let a create_all-based migration
# idempotent against an already-present table with no later # go unlisted safely so far), but static_widget_configs/
# ALTER to collide with), but text_widget_configs' migration 22 # text_widget_configs/weather_widget_configs all use a raw
# ALTER makes the gap a real "table already exists"/"duplicate # CREATE TABLE (not create_all -- see migration 20's own
# column" collision instead of a silent no-op. # docstring on why), so an already-present one is a real "table
# already exists" collision, not a silent no-op.
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists", for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs", "calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources", "text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
"saved_layout_widgets", "saved_layouts", "widgets"): "saved_layout_widgets", "saved_layouts", "weather_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}")) conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text( conn.execute(text(
@@ -270,11 +274,12 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d
tasks_* columns and TaskWidgetConfig no longer having user_id/ tasks_* columns and TaskWidgetConfig no longer having user_id/
calendar_key columns either.""" calendar_key columns either."""
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs/saved_layout_* dropped # static_widget_configs/text_widget_configs/saved_layout_*/
# too -- see the comment on the identical setup in # weather_widget_configs dropped too -- see the comment on the
# identical setup in
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations # test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
# 20/21/23's raw CREATE TABLE collides with an already-present table # 20/21/23/24's raw CREATE TABLE collides with an already-present table
# otherwise, since this test replays 17 through 23 and none of # otherwise, since this test replays 17 through 24 and none of
# these tables would really exist yet at a genuine pre-migration-17 # these tables would really exist yet at a genuine pre-migration-17
# schema_version). # schema_version).
conn.execute(text("DROP TABLE task_widget_configs")) conn.execute(text("DROP TABLE task_widget_configs"))
@@ -286,6 +291,7 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d
conn.execute(text("DROP TABLE saved_layout_sources")) conn.execute(text("DROP TABLE saved_layout_sources"))
conn.execute(text("DROP TABLE saved_layout_widgets")) conn.execute(text("DROP TABLE saved_layout_widgets"))
conn.execute(text("DROP TABLE saved_layouts")) conn.execute(text("DROP TABLE saved_layouts"))
conn.execute(text("DROP TABLE weather_widget_configs"))
conn.execute(text( conn.execute(text(
"CREATE TABLE calendar_widget_configs (" "CREATE TABLE calendar_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
@@ -380,13 +386,14 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
the widget backfill, not as a numbered migration racing ahead of the widget backfill, not as a numbered migration racing ahead of
it (see _ensure_frame_calendars_rekeyed's own docstring).""" it (see _ensure_frame_calendars_rekeyed's own docstring)."""
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs/saved_layout_* dropped # static_widget_configs/text_widget_configs/saved_layout_*/
# too -- see the comment on the identical setup in # weather_widget_configs dropped too -- see the comment on the
# identical setup in
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above. # test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists", for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs", "calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources", "text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
"saved_layout_widgets", "saved_layouts", "widgets"): "saved_layout_widgets", "saved_layouts", "weather_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}")) conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text( conn.execute(text(
+202
View File
@@ -0,0 +1,202 @@
"""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) landing on the same shared
category set, and each provider's fetch_current/fetch_hourly/fetch_daily
shape."""
from __future__ import annotations
import httpx
import pytest
from app.weather import WeatherFetchError, 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")
+86
View File
@@ -0,0 +1,86 @@
"""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 actually use the panel's ink colors (not flat black --
caught via browser verification that a monochrome sun read as an
unrecognizable crosshair glyph, not a sun)."""
from __future__ import annotations
from PIL import Image, ImageDraw
from app import weather_render
from app.image_pipeline import DEFAULT_PALETTE_RGB
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_panel_yellow_not_flat_black():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear")
colors = _colors_present(img)
assert tuple(DEFAULT_PALETTE_RGB[2]) in colors # yellow
assert (0, 0, 0) not in colors # no flat-black sun
def test_draw_weather_icon_rain_uses_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_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)
@@ -20,6 +20,7 @@ from app.models import (
StaticWidgetConfig, StaticWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
TextWidgetConfig, TextWidgetConfig,
WeatherWidgetConfig,
Widget, Widget,
) )
@@ -80,6 +81,18 @@ def _add_text_widget(db_session) -> Widget:
return widget return widget
def _add_weather_widget(db_session, **cfg_kwargs) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="weather", x=0, y=0, w=2, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return widget
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes: def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
buf = io.BytesIO() buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG") Image.new("RGB", size, color).save(buf, format="PNG")
@@ -150,6 +163,73 @@ def test_config_save_updates_a_tasks_widget(client, db_session):
assert cfg.show_completed is True assert cfg.show_completed is True
def test_config_save_updates_a_weather_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "hourly", "weather_provider": "nws", "weather_units": "celsius",
"weather_hourly_interval_hours": "6", "weather_daily_days": "3"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.mode == "hourly"
assert cfg.provider == "nws"
assert cfg.units == "celsius"
assert cfg.hourly_interval_hours == 6
assert cfg.daily_days == 3
def test_config_save_ignores_an_invalid_weather_mode(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "not_a_real_mode"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.mode == "current" # unchanged -- invalid value silently ignored, same as calendar_view's own validation
def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db_session, monkeypatch):
"""Regression test: a widget's `cached` shape depends on its mode (a
single-temp dict for current, a list for hourly/multi_city, a dict
for daily). Switching modes without clearing the old cache used to
crash the very next preview -- get_or_refresh_weather_widget_data's
multi_city branch tried `c["label"]` against a leftover "current"-
mode dict, TypeError: string indices must be integers -- rather than
just triggering a fresh fetch shaped for the new mode."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current", cached={"temp": 70.0, "category": "clear"},
checked_at=1e15) # far in the future -- would still be "fresh" if not cleared
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "multi_city"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.cached is None
cfg.cities = [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
db_session.commit()
monkeypatch.setattr(
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
lambda db, frame, widget, force=False: [
{"label": "Portland", "high": 75, "low": 55, "category": "clear"},
],
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 200, resp.text
def test_config_save_truncates_an_overlong_tasks_name(client, db_session): def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session) widget = _add_tasks_widget(db_session)
@@ -420,3 +500,130 @@ def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
widget = _add_static_widget(db_session) widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text") resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 400 assert resp.status_code == 400
# --- 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
+111
View File
@@ -0,0 +1,111 @@
"""app.widgets.weather -- unit-level, no HTTP: constructs Widget/
WeatherWidgetConfig rows directly and monkeypatches the underlying fetch
call (get_or_refresh_weather_widget_data, whose own throttle/provider-
dispatch logic is covered by test_weather_providers.py and exercised at
the HTTP layer in test_widget_config_and_queue_endpoints.py) -- this file
is about the widget wiring itself: does render() produce a correctly-
sized image for each of the four modes, does it fall back to a
placeholder when unconfigured, does check_now force a refetch."""
from __future__ import annotations
import time
from app import grid, widgets
from app.models import Frame, WeatherWidgetConfig, Widget
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="weather", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return frame, widget
def test_render_falls_back_to_placeholder_when_not_configured(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: None)
img = widgets.weather.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_current_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current", city_label="Portland")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
img = widgets.weather.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_hourly_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="hourly", city_label="Seattle", hourly_interval_hours=4)
hourly = [{"time": f"2026-07-27T{h:02d}:00", "temp": 60 + h, "category": "rain"} for h in range(24)]
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: hourly)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
def test_render_daily_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", daily_days=5)
daily = {f"2026-07-{27 + i}": {"high": 70 + i, "low": 50 + i, "category": "clear"} for i in range(5)}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
def test_render_multi_city_mode(db_session, monkeypatch):
"""cached entries carry the full disambiguated geocoder label (see
get_or_refresh_weather_widget_data/weather.geocode_city) -- render()
must still produce a correctly-sized image (weather_render.
build_multi_city shortens to just the city name for display, same as
calendar_render.py's _weather_for_day)."""
frame, widget = _make_widget(db_session, mode="multi_city")
cities = [
{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"},
{"label": "Seattle, Washington, United States", "high": 65, "low": 50, "category": "rain"},
]
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: cities)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
def test_render_at_minimum_grid_footprint(db_session, monkeypatch):
"""grid.MIN_FOOTPRINT["weather"] is (2, 2) cells -- on an 8x5 grid
against a full 800x480 panel that's a 200x192 box, the smallest a
weather widget can actually be placed at."""
assert grid.MIN_FOOTPRINT["weather"] == (2, 2)
frame, widget = _make_widget(db_session, mode="current")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
img = widgets.weather.render(db_session, frame, widget, 200, 192)
assert img.size == (200, 192)
def test_check_now_forces_a_refetch(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current")
calls = []
def _fake_refresh(db, frame, widget, force=False):
calls.append(force)
return {"temp": 70.0, "category": "clear"}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", _fake_refresh)
widgets.weather.ACTIONS["check_now"](db_session, frame, widget)
assert calls == [True]
def test_action_labels():
assert set(widgets.weather.ACTIONS.keys()) == {"check_now"}
assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"}