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")