diff --git a/CLAUDE.md b/CLAUDE.md index 4086bca..722e994 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/widgets.md b/docs/widgets.md index e683ba4..35d8af6 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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: diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index 942c854..dccb7f7 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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) diff --git a/server/app/grid.py b/server/app/grid.py index 193527c..6a172d5 100644 --- a/server/app/grid.py +++ b/server/app/grid.py @@ -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) diff --git a/server/app/migration.py b/server/app/migration.py index a2b13f3..d1f51b3 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -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), ] diff --git a/server/app/models.py b/server/app/models.py index 0453558..7253f2d 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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, } diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 0e134e2..b206f1f 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -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") diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 32a6e77..3b3450d 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -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 diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 7b067df..bcb7819 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -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}") diff --git a/server/app/static/common.js b/server/app/static/common.js index c60225f..c2cde42 100644 --- a/server/app/static/common.js +++ b/server/app/static/common.js @@ -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) { diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index 8b67911..0d1fab6 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -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; diff --git a/server/app/static/widget_dialog_weather.js b/server/app/static/widget_dialog_weather.js new file mode 100644 index 0000000..430bb25 --- /dev/null +++ b/server/app/static/widget_dialog_weather.js @@ -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 , +// 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 = '
  • No cities added yet.
  • '; + } + 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. +} diff --git a/server/app/templates/_widget_dialog_weather.html b/server/app/templates/_widget_dialog_weather.html new file mode 100644 index 0000000..75f7364 --- /dev/null +++ b/server/app/templates/_widget_dialog_weather.html @@ -0,0 +1,81 @@ +

    Weather widget

    + +
    +

    Display

    +
    + + +

    National Weather Service only covers US locations.

    + +
    + +
    +
    + +
    + +
    +
    + +
    +

    Location

    +

    + {% if weather_cfg.city_label %}Currently: {{ weather_cfg.city_label }}{% else %}No location set yet.{% endif %} +

    +
    + + + +
    +
    + +
    +

    Cities

    + +
    + + +
    +
    + +
    +

    Preview

    +

    How this widget currently renders.

    + Weather preview + +
    diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index dfb3947..b2af519 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -74,6 +74,7 @@ + {% endblock %} diff --git a/server/app/weather/__init__.py b/server/app/weather/__init__.py new file mode 100644 index 0000000..9143156 --- /dev/null +++ b/server/app/weather/__init__.py @@ -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) diff --git a/server/app/weather/nws.py b/server/app/weather/nws.py new file mode 100644 index 0000000..36a3433 --- /dev/null +++ b/server/app/weather/nws.py @@ -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 diff --git a/server/app/weather.py b/server/app/weather/open_meteo.py similarity index 61% rename from server/app/weather.py rename to server/app/weather/open_meteo.py index 5f1734d..ff820bb 100644 --- a/server/app/weather.py +++ b/server/app/weather/open_meteo.py @@ -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] + } diff --git a/server/app/weather_render.py b/server/app/weather_render.py new file mode 100644 index 0000000..f163cd6 --- /dev/null +++ b/server/app/weather_render.py @@ -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() diff --git a/server/app/widgets/__init__.py b/server/app/widgets/__init__.py index 0b7c95f..946b1e5 100644 --- a/server/app/widgets/__init__.py +++ b/server/app/widgets/__init__.py @@ -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, } diff --git a/server/app/widgets/weather.py b/server/app/widgets/weather.py new file mode 100644 index 0000000..fb7573e --- /dev/null +++ b/server/app/widgets/weather.py @@ -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} diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index ba6e43c..5bbae5b 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -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( diff --git a/server/tests/test_weather_providers.py b/server/tests/test_weather_providers.py new file mode 100644 index 0000000..99f244e --- /dev/null +++ b/server/tests/test_weather_providers.py @@ -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") diff --git a/server/tests/test_weather_render.py b/server/tests/test_weather_render.py new file mode 100644 index 0000000..bb77476 --- /dev/null +++ b/server/tests/test_weather_render.py @@ -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) diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py index 2c01cc5..92a6f54 100644 --- a/server/tests/test_widget_config_and_queue_endpoints.py +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -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 diff --git a/server/tests/test_widgets_weather.py b/server/tests/test_widgets_weather.py new file mode 100644 index 0000000..bf220c8 --- /dev/null +++ b/server/tests/test_widgets_weather.py @@ -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"}