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

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

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

New WeatherWidgetConfig table (migration 24), grid footprint, widget
module, common.py fetch/cache helper, router endpoints (location set/
clear, city add/remove, preview), dialog template + JS, and full test
coverage (providers, widget render, HTTP endpoints, migration replay).
docs/widgets.md and CLAUDE.md's TODO updated accordingly.
This commit is contained in:
2026-07-27 16:16:43 +00:00
parent 6118705c37
commit 52ebafab78
25 changed files with 2036 additions and 152 deletions
+157 -1
View File
@@ -26,7 +26,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
@@ -47,6 +47,7 @@ from ..models import (
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS,
Widget,
@@ -60,6 +61,7 @@ from .common import (
get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget,
get_or_refresh_weather_widget_data,
get_or_refresh_whiteboard_for_widget,
immich_client_for,
immich_creds,
@@ -283,6 +285,12 @@ def api_widget_config_save(
text_font_family: str | None = Form(None),
text_align: str | None = Form(None),
text_background_color: str | None = Form(None),
# weather
weather_mode: str | None = Form(None),
weather_provider: str | None = Form(None),
weather_units: str | None = Form(None),
weather_hourly_interval_hours: int | None = Form(None),
weather_daily_days: int | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
@@ -379,6 +387,36 @@ def api_widget_config_save(
xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
)
elif widget.widget_type == "weather":
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
if weather_mode != wcfg.mode:
# A stale cache is a different shape under a
# different mode (a single-temp dict vs. an hourly
# list vs. a daily dict vs. a city list) -- clear it
# outright (not just force a refetch attempt) so a
# get_or_refresh_weather_widget_data call that happens
# to fail on the very first fetch under the new mode
# doesn't fall back to the old mode's incompatible
# cached shape.
wcfg.checked_at = 0.0
wcfg.cached = None
wcfg.mode = weather_mode
if weather_provider is not None and weather_provider in weather.PROVIDERS:
if weather_provider != wcfg.provider:
wcfg.checked_at = 0.0
wcfg.provider = weather_provider
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
if weather_units != wcfg.units:
# Cached temps are in the old unit -- force a refetch
# rather than showing stale numbers under a new unit
# label (same idiom as calendar_weather_units above).
wcfg.checked_at = 0.0
wcfg.units = weather_units
if weather_hourly_interval_hours is not None:
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
if weather_daily_days is not None:
wcfg.daily_days = max(1, min(14, weather_daily_days))
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -833,6 +871,124 @@ def api_widget_weather_city_remove(
return {"status": "saved"}
# --- Weather widget: location/cities/preview -------------------------------
#
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
# "weather-cities") specifically to avoid colliding with the calendar
# widget's own /weather-cities/add|remove route *patterns* above -- both
# are registered against the same {widget_id}-parameterized path shape,
# so a literal name clash there would silently shadow one of them
# regardless of each handler's own _require_widget_type check.
class WeatherLocationRequest(BaseModel):
name: str | None # None clears the location; else a free-text city name to geocode
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
def api_widget_weather_location(
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets (or clears) this weather widget's single configured location
-- the current/hourly/daily modes' one city. A widget-wide display
setting like calendar_view/weather_units, not personal data, hence
require_widget_control rather than the calendar/tasks owner-adds/
anyone-mutes split (there's only ever one location and no per-person
ownership of it)."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
if body.name is None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = None
cfg.city_latitude = None
cfg.city_longitude = None
cfg.cached = None
cfg.checked_at = 0.0
return {"status": "saved", "city": None}
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = city["label"]
cfg.city_latitude = city["latitude"]
cfg.city_longitude = city["longitude"]
cfg.cached = None
cfg.checked_at = 0.0 # pick up the new location promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
def api_widget_weather_widget_city_add(
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""multi_city mode's city list -- same shape/gating as the calendar
widget's own weather-cities/add above, just scoped to this widget's
own WeatherWidgetConfig.cities."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = list(cfg.cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this widget's list")
cities.append(city)
cfg.cities = cities
cfg.checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
def api_widget_weather_widget_city_remove(
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "weather")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
cfg.cities = cities
if cfg.cached:
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
return {"status": "saved"}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
def api_widget_preview_weather(
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""The same throttled fetch cache a live device render would use, run
through the panel composition/quantization pipeline -- "how it will
look on the frame", same convention as the other preview endpoints.
force=True (the "Refresh now" button) bypasses the fetch throttle."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
wcfg = db.get(WeatherWidgetConfig, widget.id)
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
if data is None:
if wcfg.mode == "multi_city":
raise HTTPException(400, "No cities added to this widget yet")
raise HTTPException(400, "No location set on this widget yet")
png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
)
return Response(content=png, media_type="image/png")
# --- Static image: upload/preview -----------------------------------------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
+64
View File
@@ -31,6 +31,7 @@ from ..models import (
TaskWidgetConfig,
User,
Widget,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
)
@@ -574,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
return result
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
weather widget (see app/widgets/weather.py) -- reads/writes
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
hourly/daily need a single configured location (city_latitude/
city_longitude); multi_city needs cfg.cities. None if not configured
yet, so render() falls back to a placeholder -- same convention as
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
button) bypasses the throttle entirely.
A single-location mode's fetch failure keeps the last-known cached
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
multi_city fetch fails per-city (like get_or_refresh_weather_for_
widget's calendar-strip counterpart) so one broken city doesn't blank
the others."""
cfg = db.get(WeatherWidgetConfig, widget.id)
if cfg.mode == "multi_city":
if not cfg.cities:
return None
elif cfg.city_latitude is None or cfg.city_longitude is None:
return None
now = time.time()
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
return cfg.cached
if cfg.mode == "multi_city":
previous = {c["label"]: c for c in (cfg.cached or [])}
result = []
for city in cfg.cities:
try:
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
d = next(iter(today.values())) if today else previous.get(city["label"], {})
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
d = previous.get(city["label"], {})
result.append({
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
"category": d.get("category", "cloudy"),
})
else:
try:
if cfg.mode == "current":
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
elif cfg.mode == "hourly":
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
hours=HOURLY_FETCH_HOURS)
else: # "daily"
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
cfg.daily_days)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
return cfg.cached
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached = result
locked_cfg.checked_at = now
return result
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
+9
View File
@@ -17,6 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import weather
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
@@ -38,6 +39,7 @@ from ..models import (
TextWidgetConfig,
User,
UserFrame,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
@@ -265,4 +267,11 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"viewer_has_webdav_creds": viewer_has_webdav_creds,
})
if widget.widget_type == "weather":
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_weather.html", {
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
"weather_provider_labels": weather.PROVIDER_LABELS,
})
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")