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
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
image processing (crop/dither/quantize/pack), and serves a placeable
photos/calendar/whiteboard widget system to the device.
photos/calendar/whiteboard/weather widget system to the device.
CURRENT TODO
-add more actions for buttons (i.e. change widget/layout)
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
-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
-battery life widget
-sharing layouts with linked users
+72 -11
View File
@@ -1,10 +1,10 @@
# Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
arranging icons on an Android home screen. A frame can hold several widgets of the
same type (e.g. two photo widgets pointed at different Immich albums side
by side).
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
weather), like arranging icons on an Android home screen. A frame can hold
several widgets of the same type (e.g. two photo widgets pointed at
different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
@@ -20,14 +20,16 @@ a button press does.
## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` |
`"text"` | `"weather"`), `x`/`y`/`w`/`h`
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`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
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
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
and sized independent of any calendar's view/footprint, then (migration
18) given the same multi-source shape a calendar widget already has.
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
weather strip (still present and unchanged, `weather_*` columns) out
into its own placeable widget type (migration 24) -- see "Weather
widget" below.
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical
@@ -65,7 +71,9 @@ orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
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
(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
@@ -75,7 +83,7 @@ alone.
`app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
`static_image.py`, `text.py`), each exposing:
`static_image.py`, `text.py`, `weather.py`), each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's
@@ -85,9 +93,10 @@ alone.
bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard). Empty for tasks, static image, and text -- nothing to
advance/back/force for a passive checklist, a fixed uploaded image, or
a fixed block of authored text.
for whiteboard and weather -- both throttled external fetches with a
forced-refetch action). Empty for tasks, static image, and 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-
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
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)
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,
)
from .weather import weather_category
from .weather_render import draw_weather_row
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
@@ -389,98 +390,6 @@ def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]
return entries
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
with a clean outline -- drawn as one black pass slightly larger than
the shapes, then the same shapes again in white on top. Overlapping
ellipses each drawn with their own `outline=` would leave visible
seams where they cross; this double-draw trick sidesteps that
entirely regardless of how the lobes overlap."""
stroke = 2
lobes = [
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
]
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0, y0, x1, y1], fill=BG)
draw.rectangle(base, fill=BG)
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
"""A small hand-drawn glyph for one weather category -- no custom
font/icon asset, same hand-primitives-only approach the rest of this
module uses (colored rectangles for owner indicators, density dots
for month view)."""
if category == "clear":
# Kept within a ~1.1r visual radius overall (rays included) to
# match _draw_cloud's own footprint -- _draw_weather_row lays
# icons out assuming each one stays roughly within icon_r of its
# center, and the first entry in a row sits flush against the
# region's own left margin, so any icon that draws wider than
# that pokes out past it with nothing to visually connect to.
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
fill=FG, width=3)
return
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
if category == "partly_cloudy":
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
_draw_cloud(draw, cx, cloud_cy, r)
if category == "fog":
for i in range(3):
y = cy + r * 0.5 + i * (r * 0.45)
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
elif category == "rain":
for dx in (-0.6, 0, 0.6):
x = cx + dx * r
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
elif category == "snow":
for dx in (-0.6, 0, 0.6):
x, y = cx + dx * r, cloud_cy + r * 0.9
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
elif category == "thunderstorm":
x, y = cx, cloud_cy + r * 0.5
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
fill=FG, width=2)
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
show_labels: bool = True) -> int:
"""Draws one or more cities' weather side by side starting at
(x0, y0), stopping once another entry wouldn't fit within max_w
(narrow views like week columns just end up showing fewer cities --
same graceful-degradation approach month view takes with density
dots). Returns the row height consumed (0 if there was nothing to
draw, so callers can skip reserving space entirely)."""
if not entries:
return 0
unit_suffix = "F" if units == "fahrenheit" else "C"
row_h = icon_r * 2 + 8
x = x0
drew_any = False
for entry in entries:
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
label = f"{entry['label']} {temps}" if show_labels else temps
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
if drew_any and x + entry_w > x0 + max_w:
break
cx, cy = x + icon_r, y0 + icon_r
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
x += entry_w
drew_any = True
return row_h + 6
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
@@ -504,8 +413,9 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
palette_rgb=palette_rgb)
day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14
@@ -720,13 +630,15 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
y = MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see
# _draw_weather_row) -- typically one city, no label (the column
# itself makes which day it's for obvious; a city name wouldn't fit
# anyway). Never more than that -- this is already the tight view.
# weather_render.draw_weather_row) -- typically one city, no label
# (the column itself makes which day it's for obvious; a city name
# wouldn't fit anyway). Never more than that -- this is already
# the tight view.
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb)
row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
+4 -1
View File
@@ -24,7 +24,9 @@ GRID_SHORT = 5
# scaling (see calendar_render.py); whiteboard needs enough room to be
# worth looking at; photos can go as small as a single cell; tasks needs
# enough width for a due-date prefix plus a couple words of summary
# without truncating on every row.
# without truncating on every row; weather needs enough room for its
# hourly/daily strips to stay legible (its current/multi_city modes
# would tolerate smaller, but every mode shares one footprint value).
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1),
"calendar": (3, 2),
@@ -32,6 +34,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"tasks": (2, 2),
"static": (1, 1),
"text": (2, 1),
"weather": (2, 2),
}
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 = [
(1, _migration_1),
(2, _migration_2),
@@ -648,6 +680,7 @@ MIGRATIONS = [
(21, _migration_21),
(22, _migration_22),
(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)
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)
y: 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)
class WeatherWidgetConfig(Base):
"""One weather widget's settings + cached-fetch state. Four display
modes (see app/widgets/weather.py): "current" (one city, current
temp + icon), "hourly" (one city, a row of ticks across the day),
"daily" (one city, a multi-day strip), "multi_city" (several cities'
current-day high/low/icon side by side -- the calendar widget's
embedded weather strip, lifted out into its own widget type).
`provider` selects which of app/weather/'s PROVIDERS actually fetches
("open_meteo" | "nws" -- see that package's own module docstring).
`cached`'s shape depends on `mode`: {"temp","category"} for current,
a list of {"time","temp","category"} for hourly, a
{"YYYY-MM-DD": {...}} dict for daily, or a list of
{"label","high","low","category"} for multi_city."""
__tablename__ = "weather_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
# Single-location modes only (current/hourly/daily) -- geocoded once
# via weather.geocode_city() when set, same idiom as
# CalendarWidgetConfig.weather_cities' per-entry shape.
city_label: Mapped[str | None] = mapped_column(String, nullable=True)
city_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
city_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
hourly_interval_hours: Mapped[int] = mapped_column(Integer, default=4)
daily_days: Mapped[int] = mapped_column(Integer, default=5)
# multi_city mode only -- [{"label", "latitude", "longitude"}, ...],
# same shape as CalendarWidgetConfig.weather_cities.
cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached: Mapped[dict | list | None] = mapped_column(JSON, nullable=True, default=None)
class TextWidgetConfig(Base):
"""One text widget's authored content + display settings -- another
no-live-upstream type like StaticWidgetConfig, just parsed rich text
@@ -618,6 +653,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"tasks": TaskWidgetConfig,
"static": StaticWidgetConfig,
"text": TextWidgetConfig,
"weather": WeatherWidgetConfig,
}
+157 -1
View File
@@ -26,7 +26,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
@@ -47,6 +47,7 @@ from ..models import (
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS,
Widget,
@@ -60,6 +61,7 @@ from .common import (
get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget,
get_or_refresh_weather_widget_data,
get_or_refresh_whiteboard_for_widget,
immich_client_for,
immich_creds,
@@ -283,6 +285,12 @@ def api_widget_config_save(
text_font_family: str | None = Form(None),
text_align: str | None = Form(None),
text_background_color: str | None = Form(None),
# weather
weather_mode: str | None = Form(None),
weather_provider: str | None = Form(None),
weather_units: str | None = Form(None),
weather_hourly_interval_hours: int | None = Form(None),
weather_daily_days: int | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
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 = (
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:
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -833,6 +871,124 @@ def api_widget_weather_city_remove(
return {"status": "saved"}
# --- Weather widget: location/cities/preview -------------------------------
#
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
# "weather-cities") specifically to avoid colliding with the calendar
# widget's own /weather-cities/add|remove route *patterns* above -- both
# are registered against the same {widget_id}-parameterized path shape,
# so a literal name clash there would silently shadow one of them
# regardless of each handler's own _require_widget_type check.
class WeatherLocationRequest(BaseModel):
name: str | None # None clears the location; else a free-text city name to geocode
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
def api_widget_weather_location(
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets (or clears) this weather widget's single configured location
-- the current/hourly/daily modes' one city. A widget-wide display
setting like calendar_view/weather_units, not personal data, hence
require_widget_control rather than the calendar/tasks owner-adds/
anyone-mutes split (there's only ever one location and no per-person
ownership of it)."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
if body.name is None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = None
cfg.city_latitude = None
cfg.city_longitude = None
cfg.cached = None
cfg.checked_at = 0.0
return {"status": "saved", "city": None}
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = city["label"]
cfg.city_latitude = city["latitude"]
cfg.city_longitude = city["longitude"]
cfg.cached = None
cfg.checked_at = 0.0 # pick up the new location promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
def api_widget_weather_widget_city_add(
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""multi_city mode's city list -- same shape/gating as the calendar
widget's own weather-cities/add above, just scoped to this widget's
own WeatherWidgetConfig.cities."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = list(cfg.cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this widget's list")
cities.append(city)
cfg.cities = cities
cfg.checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
def api_widget_weather_widget_city_remove(
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "weather")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
cfg.cities = cities
if cfg.cached:
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
return {"status": "saved"}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
def api_widget_preview_weather(
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""The same throttled fetch cache a live device render would use, run
through the panel composition/quantization pipeline -- "how it will
look on the frame", same convention as the other preview endpoints.
force=True (the "Refresh now" button) bypasses the fetch throttle."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
wcfg = db.get(WeatherWidgetConfig, widget.id)
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
if data is None:
if wcfg.mode == "multi_city":
raise HTTPException(400, "No cities added to this widget yet")
raise HTTPException(400, "No location set on this widget yet")
png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
)
return Response(content=png, media_type="image/png")
# --- Static image: upload/preview -----------------------------------------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
+64
View File
@@ -31,6 +31,7 @@ from ..models import (
TaskWidgetConfig,
User,
Widget,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
)
@@ -574,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
return result
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
weather widget (see app/widgets/weather.py) -- reads/writes
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
hourly/daily need a single configured location (city_latitude/
city_longitude); multi_city needs cfg.cities. None if not configured
yet, so render() falls back to a placeholder -- same convention as
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
button) bypasses the throttle entirely.
A single-location mode's fetch failure keeps the last-known cached
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
multi_city fetch fails per-city (like get_or_refresh_weather_for_
widget's calendar-strip counterpart) so one broken city doesn't blank
the others."""
cfg = db.get(WeatherWidgetConfig, widget.id)
if cfg.mode == "multi_city":
if not cfg.cities:
return None
elif cfg.city_latitude is None or cfg.city_longitude is None:
return None
now = time.time()
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
return cfg.cached
if cfg.mode == "multi_city":
previous = {c["label"]: c for c in (cfg.cached or [])}
result = []
for city in cfg.cities:
try:
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
d = next(iter(today.values())) if today else previous.get(city["label"], {})
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
d = previous.get(city["label"], {})
result.append({
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
"category": d.get("category", "cloudy"),
})
else:
try:
if cfg.mode == "current":
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
elif cfg.mode == "hourly":
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
hours=HOURLY_FETCH_HOURS)
else: # "daily"
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
cfg.daily_days)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
return cfg.cached
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached = result
locked_cfg.checked_at = now
return result
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
+9
View File
@@ -17,6 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import weather
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
@@ -38,6 +39,7 @@ from ..models import (
TextWidgetConfig,
User,
UserFrame,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
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,
})
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}")
+1 -1
View File
@@ -62,7 +62,7 @@
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = {
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
static: 'Static image', text: 'Text',
static: 'Static image', text: 'Text', weather: 'Weather',
};
function showStatus(ok, message) {
+2 -2
View File
@@ -286,11 +286,11 @@ window.addEventListener('resize', () => {
// icon was clicked).
const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
};
const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
};
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_static.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/saved_layouts.js"></script>
{% 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
views only -- never month, there's no room, see calendar_render.py's
_BUILDERS). A frame can list multiple cities; each is geocoded once via
Open-Meteo's free geocoding API (no API key, no signup, no per-request
quota to manage) when added from the Calendar tab, then its daily
forecast is refreshed on its own throttle -- same shape idiom as
calendar_feed.py's merge-fetch cache.
"""Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry)
-- free geocoding + forecast APIs, no API key, no signup, no per-request
quota to manage. Backs both the calendar widget's embedded weather strip
(fetch_daily_forecast/weather_category, its original shape, untouched)
and the standalone weather widget's per-mode fetches below (fetch_current/
fetch_hourly/fetch_daily, which return already-normalized {"category":
...} entries from the shared category set instead of a raw WMO code).
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py.
@@ -14,6 +14,8 @@ from __future__ import annotations
import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
@@ -36,12 +38,6 @@ _CODE_CATEGORIES = {
}
class WeatherFetchError(Exception):
"""Geocoding or forecast fetch failed -- network, no match, or an
unexpected response shape. Raised loudly; callers (the Calendar
tab's add-city endpoint, get_or_refresh_weather) decide what to do."""
def weather_category(code: int) -> str:
"""Falls back to "cloudy" for any WMO code Open-Meteo might add later
that isn't in the table above -- an unrecognized code shouldn't drop
@@ -133,3 +129,67 @@ def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[
}
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
# --- Standalone weather widget fetches (app/widgets/weather.py) --------
#
# Unlike fetch_daily_forecast above (kept as-is for the calendar widget's
# embedded strip, which does its own weather_category(code) lookup),
# these return already-normalized {"category": ...} entries so
# app/weather_render.py's build_* functions never need to know which
# provider supplied the data (see app/weather/nws.py, which normalizes
# its own icon/text shape to the same category set).
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
"""{"temp": float, "category": str} for right now."""
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"current": "temperature_2m,weathercode",
"temperature_unit": units,
"timezone": "auto",
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
current = resp.json()["current"]
return {"temp": current["temperature_2m"], "category": weather_category(current["weathercode"])}
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
one entry per hour, for the next `hours` hours (Open-Meteo's hourly
data is always 1-hour resolution regardless of how far apart the
ticks a caller actually wants to display are -- see
app/weather_render.py's build_hourly, which samples every Nth
entry)."""
forecast_days = max(1, -(-hours // 24)) # ceil division -- enough days to cover `hours`
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"hourly": "temperature_2m,weathercode",
"temperature_unit": units,
"timezone": "auto",
"forecast_days": forecast_days,
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
hourly = resp.json()["hourly"]
return [
{"time": t, "temp": temp, "category": weather_category(code)}
for t, temp, code in zip(hourly["time"], hourly["temperature_2m"], hourly["weathercode"])
][:hours]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
-- same request as fetch_daily_forecast, just normalized to a
category instead of a raw WMO code, and clamped to `days` (Open-
Meteo's own real max is FORECAST_DAYS)."""
clamped = max(1, min(days, FORECAST_DAYS))
raw = fetch_daily_forecast(latitude, longitude, units)
return {
day: {"high": d["high"], "low": d["low"], "category": weather_category(d["code"])}
for day, d in list(raw.items())[:clamped]
}
+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 . import calendar, photos, static_image, tasks, text, whiteboard
from . import calendar, photos, static_image, tasks, text, weather, whiteboard
WIDGET_TYPES = {
"photos": photos,
@@ -45,4 +45,5 @@ WIDGET_TYPES = {
"tasks": tasks,
"static": static_image,
"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
text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")}
assert "font_family" in text_widget_columns # migration 22
assert "weather_widget_configs" in inspector.get_table_names() # migration 24
weather_widget_columns = {c["name"] for c in inspector.get_columns("weather_widget_configs")}
assert {"mode", "provider", "city_latitude", "cities"} <= weather_widget_columns
# --- 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."""
with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs are migration 20/21
# tables, and saved_layouts/saved_layout_widgets/saved_layout_
# sources/saved_layout_button_actions are migration 23's (all
# post-16, like the rest of this list) -- dropped here too so a
# real version-15 database is what's actually being simulated,
# not "version 15 plus tables that wouldn't exist yet". Harmless
# to omit as long as no migration after the one that creates a
# table also ALTERs it (that's what let static_widget_configs go
# unlisted safely so far -- Base.metadata.create_all is
# idempotent against an already-present table with no later
# ALTER to collide with), but text_widget_configs' migration 22
# ALTER makes the gap a real "table already exists"/"duplicate
# column" collision instead of a silent no-op.
# tables, saved_layouts/saved_layout_widgets/saved_layout_
# sources/saved_layout_button_actions are migration 23's, and
# weather_widget_configs is migration 24's (all post-16, like the
# rest of this list) -- dropped here too so a real version-15
# database is what's actually being simulated, not "version 15
# plus tables that wouldn't exist yet". Harmless to omit as long
# as no migration after the one that creates a table also ALTERs
# or re-CREATEs it (that's what let a create_all-based migration
# go unlisted safely so far), but static_widget_configs/
# text_widget_configs/weather_widget_configs all use a raw
# CREATE TABLE (not create_all -- see migration 20's own
# docstring on why), so an already-present one is a real "table
# already exists" collision, not a silent no-op.
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
"saved_layout_widgets", "saved_layouts", "widgets"):
"saved_layout_widgets", "saved_layouts", "weather_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
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/
calendar_key columns either."""
with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs/saved_layout_* dropped
# too -- see the comment on the identical setup in
# static_widget_configs/text_widget_configs/saved_layout_*/
# weather_widget_configs dropped too -- see the comment on the
# identical setup in
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
# 20/21/23's raw CREATE TABLE collides with an already-present table
# otherwise, since this test replays 17 through 23 and none of
# 20/21/23/24's raw CREATE TABLE collides with an already-present table
# otherwise, since this test replays 17 through 24 and none of
# these tables would really exist yet at a genuine pre-migration-17
# schema_version).
conn.execute(text("DROP TABLE task_widget_configs"))
@@ -286,6 +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_widgets"))
conn.execute(text("DROP TABLE saved_layouts"))
conn.execute(text("DROP TABLE weather_widget_configs"))
conn.execute(text(
"CREATE TABLE calendar_widget_configs ("
"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
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs/saved_layout_* dropped
# too -- see the comment on the identical setup in
# static_widget_configs/text_widget_configs/saved_layout_*/
# weather_widget_configs dropped too -- see the comment on the
# identical setup in
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
"saved_layout_widgets", "saved_layouts", "widgets"):
"saved_layout_widgets", "saved_layouts", "weather_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
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,
TaskWidgetConfig,
TextWidgetConfig,
WeatherWidgetConfig,
Widget,
)
@@ -80,6 +81,18 @@ def _add_text_widget(db_session) -> Widget:
return widget
def _add_weather_widget(db_session, **cfg_kwargs) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="weather", x=0, y=0, w=2, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return widget
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
buf = io.BytesIO()
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
def test_config_save_updates_a_weather_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "hourly", "weather_provider": "nws", "weather_units": "celsius",
"weather_hourly_interval_hours": "6", "weather_daily_days": "3"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.mode == "hourly"
assert cfg.provider == "nws"
assert cfg.units == "celsius"
assert cfg.hourly_interval_hours == 6
assert cfg.daily_days == 3
def test_config_save_ignores_an_invalid_weather_mode(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "not_a_real_mode"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.mode == "current" # unchanged -- invalid value silently ignored, same as calendar_view's own validation
def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db_session, monkeypatch):
"""Regression test: a widget's `cached` shape depends on its mode (a
single-temp dict for current, a list for hourly/multi_city, a dict
for daily). Switching modes without clearing the old cache used to
crash the very next preview -- get_or_refresh_weather_widget_data's
multi_city branch tried `c["label"]` against a leftover "current"-
mode dict, TypeError: string indices must be integers -- rather than
just triggering a fresh fetch shaped for the new mode."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current", cached={"temp": 70.0, "category": "clear"},
checked_at=1e15) # far in the future -- would still be "fresh" if not cleared
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "multi_city"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.cached is None
cfg.cities = [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
db_session.commit()
monkeypatch.setattr(
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
lambda db, frame, widget, force=False: [
{"label": "Portland", "high": 75, "low": 55, "category": "clear"},
],
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 200, resp.text
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session)
@@ -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)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
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"}