Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user