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:
@@ -35,6 +35,7 @@ from .image_pipeline import (
|
||||
logical_render_size,
|
||||
)
|
||||
from .weather import weather_category
|
||||
from .weather_render import draw_weather_row
|
||||
|
||||
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
||||
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
||||
@@ -389,98 +390,6 @@ def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]
|
||||
return entries
|
||||
|
||||
|
||||
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
|
||||
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
|
||||
with a clean outline -- drawn as one black pass slightly larger than
|
||||
the shapes, then the same shapes again in white on top. Overlapping
|
||||
ellipses each drawn with their own `outline=` would leave visible
|
||||
seams where they cross; this double-draw trick sidesteps that
|
||||
entirely regardless of how the lobes overlap."""
|
||||
stroke = 2
|
||||
lobes = [
|
||||
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
|
||||
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
|
||||
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
|
||||
]
|
||||
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
|
||||
for x0, y0, x1, y1 in lobes:
|
||||
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
|
||||
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
|
||||
for x0, y0, x1, y1 in lobes:
|
||||
draw.ellipse([x0, y0, x1, y1], fill=BG)
|
||||
draw.rectangle(base, fill=BG)
|
||||
|
||||
|
||||
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
|
||||
"""A small hand-drawn glyph for one weather category -- no custom
|
||||
font/icon asset, same hand-primitives-only approach the rest of this
|
||||
module uses (colored rectangles for owner indicators, density dots
|
||||
for month view)."""
|
||||
if category == "clear":
|
||||
# Kept within a ~1.1r visual radius overall (rays included) to
|
||||
# match _draw_cloud's own footprint -- _draw_weather_row lays
|
||||
# icons out assuming each one stays roughly within icon_r of its
|
||||
# center, and the first entry in a row sits flush against the
|
||||
# region's own left margin, so any icon that draws wider than
|
||||
# that pokes out past it with nothing to visually connect to.
|
||||
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
|
||||
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
|
||||
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
|
||||
fill=FG, width=3)
|
||||
return
|
||||
|
||||
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
|
||||
if category == "partly_cloudy":
|
||||
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
|
||||
_draw_cloud(draw, cx, cloud_cy, r)
|
||||
|
||||
if category == "fog":
|
||||
for i in range(3):
|
||||
y = cy + r * 0.5 + i * (r * 0.45)
|
||||
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
|
||||
elif category == "rain":
|
||||
for dx in (-0.6, 0, 0.6):
|
||||
x = cx + dx * r
|
||||
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
|
||||
elif category == "snow":
|
||||
for dx in (-0.6, 0, 0.6):
|
||||
x, y = cx + dx * r, cloud_cy + r * 0.9
|
||||
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
|
||||
elif category == "thunderstorm":
|
||||
x, y = cx, cloud_cy + r * 0.5
|
||||
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
|
||||
fill=FG, width=2)
|
||||
|
||||
|
||||
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
|
||||
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
|
||||
show_labels: bool = True) -> int:
|
||||
"""Draws one or more cities' weather side by side starting at
|
||||
(x0, y0), stopping once another entry wouldn't fit within max_w
|
||||
(narrow views like week columns just end up showing fewer cities --
|
||||
same graceful-degradation approach month view takes with density
|
||||
dots). Returns the row height consumed (0 if there was nothing to
|
||||
draw, so callers can skip reserving space entirely)."""
|
||||
if not entries:
|
||||
return 0
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
row_h = icon_r * 2 + 8
|
||||
x = x0
|
||||
drew_any = False
|
||||
for entry in entries:
|
||||
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
|
||||
label = f"{entry['label']} {temps}" if show_labels else temps
|
||||
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
|
||||
if drew_any and x + entry_w > x0 + max_w:
|
||||
break
|
||||
cx, cy = x + icon_r, y0 + icon_r
|
||||
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
|
||||
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
|
||||
x += entry_w
|
||||
drew_any = True
|
||||
return row_h + 6
|
||||
|
||||
|
||||
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
|
||||
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
|
||||
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
||||
@@ -504,8 +413,9 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
||||
|
||||
weather_entries = _weather_for_day(weather_cities, day)
|
||||
if weather_entries:
|
||||
y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
||||
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)
|
||||
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
||||
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
|
||||
palette_rgb=palette_rgb)
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
row_h = body_font.size + 14
|
||||
@@ -720,13 +630,15 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
|
||||
y = MARGIN + header_h
|
||||
# Columns are narrow, so only what actually fits gets drawn (see
|
||||
# _draw_weather_row) -- typically one city, no label (the column
|
||||
# itself makes which day it's for obvious; a city name wouldn't fit
|
||||
# anyway). Never more than that -- this is already the tight view.
|
||||
# weather_render.draw_weather_row) -- typically one city, no label
|
||||
# (the column itself makes which day it's for obvious; a city name
|
||||
# wouldn't fit anyway). Never more than that -- this is already
|
||||
# the tight view.
|
||||
weather_entries = _weather_for_day(weather_cities, day)
|
||||
if weather_entries:
|
||||
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
||||
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
|
||||
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
||||
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
||||
palette_rgb=palette_rgb)
|
||||
row_h = chip_font.size + 10
|
||||
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
@@ -624,6 +624,38 @@ def _migration_23(conn) -> None:
|
||||
))
|
||||
|
||||
|
||||
def _migration_24(conn) -> None:
|
||||
"""New widget type: standalone weather (current/hourly/daily/
|
||||
multi_city display modes, pluggable Open-Meteo/NWS providers -- see
|
||||
models.WeatherWidgetConfig, app/weather/, app/widgets/weather.py).
|
||||
Lifts the calendar widget's embedded weather strip's underlying
|
||||
fetch/render building blocks (app/weather/open_meteo.py, the icon-
|
||||
drawing primitives now in app/weather_render.py) out into a widget
|
||||
that can be placed/sized on its own -- CalendarWidgetConfig's own
|
||||
weather_* columns are untouched, still working exactly as before.
|
||||
|
||||
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
||||
migration 20/21/23's own comments: create_all always reflects
|
||||
models.py's CURRENT shape, so replaying the full chain on an old
|
||||
database could collide with a later migration's ALTER TABLE on this
|
||||
same table."""
|
||||
conn.execute(text(
|
||||
"CREATE TABLE weather_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"mode TEXT NOT NULL DEFAULT 'current', "
|
||||
"provider TEXT NOT NULL DEFAULT 'open_meteo', "
|
||||
"units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
||||
"city_label TEXT, "
|
||||
"city_latitude REAL, "
|
||||
"city_longitude REAL, "
|
||||
"hourly_interval_hours INTEGER NOT NULL DEFAULT 4, "
|
||||
"daily_days INTEGER NOT NULL DEFAULT 5, "
|
||||
"cities TEXT, "
|
||||
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"cached TEXT)"
|
||||
))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -648,6 +680,7 @@ MIGRATIONS = [
|
||||
(21, _migration_21),
|
||||
(22, _migration_22),
|
||||
(23, _migration_23),
|
||||
(24, _migration_24),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+37
-1
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Weather widget dialog: mode/provider/units settings, location (single-
|
||||
// city modes) or a city list (multi_city mode), and the rendered
|
||||
// preview. Not a page-load script -- frame_layout.js fetches this
|
||||
// widget's dialog HTML fragment, injects it into the shared <dialog>,
|
||||
// points window.FRAME_API at this specific widget
|
||||
// (/api/frames/{id}/widgets/{widget_id}), then calls initWeatherDialog().
|
||||
|
||||
// Only one of "Location" (current/hourly/daily -- one city) or "Cities"
|
||||
// (multi_city -- a list) is ever relevant at a time; the interval/days
|
||||
// rows are each specific to one mode too.
|
||||
function updateWeatherFieldVisibility() {
|
||||
const mode = document.getElementById('weather_mode').value;
|
||||
document.getElementById('weather-hourly-interval-row').style.display = mode === 'hourly' ? '' : 'none';
|
||||
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
|
||||
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
|
||||
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
|
||||
}
|
||||
|
||||
function addWeatherWidgetCityRow(label) {
|
||||
const list = document.getElementById('weather-widget-city-list');
|
||||
const empty = document.getElementById('weather-widget-city-empty');
|
||||
if (empty) empty.remove();
|
||||
const li = document.createElement('li');
|
||||
li.className = 'checkbox-row';
|
||||
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn-inline secondary weather-widget-city-remove';
|
||||
btn.dataset.label = label;
|
||||
btn.textContent = 'Remove';
|
||||
btn.addEventListener('click', removeWeatherWidgetCity);
|
||||
li.appendChild(span);
|
||||
li.appendChild(btn);
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
async function removeWeatherWidgetCity(e) {
|
||||
const label = e.target.dataset.label;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
e.target.closest('li').remove();
|
||||
const list = document.getElementById('weather-widget-city-list');
|
||||
if (!list.querySelector('li')) {
|
||||
list.innerHTML = '<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>';
|
||||
}
|
||||
showStatus(true, `${label} removed.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadWeatherPreview(force) {
|
||||
const suffix = force ? '&force=1' : '';
|
||||
document.getElementById('weather-preview').src = `${window.FRAME_API}/preview/weather?_=${Date.now()}${suffix}`;
|
||||
}
|
||||
|
||||
function initWeatherDialog() {
|
||||
document.getElementById('weather_mode').addEventListener('change', updateWeatherFieldVisibility);
|
||||
updateWeatherFieldVisibility();
|
||||
|
||||
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
weather_mode: document.getElementById('weather_mode').value,
|
||||
weather_provider: document.getElementById('weather_provider').value,
|
||||
weather_units: document.getElementById('weather_units').value,
|
||||
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
|
||||
weather_daily_days: document.getElementById('weather_daily_days').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-location-set').addEventListener('click', async () => {
|
||||
const input = document.getElementById('weather-location-input');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
const data = await resp.json();
|
||||
document.getElementById('weather-location-current').textContent = `Currently: ${data.city.label}`;
|
||||
input.value = '';
|
||||
showStatus(true, `Location set to ${data.city.label}.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-location-clear').addEventListener('click', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: null }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
document.getElementById('weather-location-current').textContent = 'No location set yet.';
|
||||
showStatus(true, 'Location cleared.');
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.weather-widget-city-remove').forEach((el) => el.addEventListener('click', removeWeatherWidgetCity));
|
||||
|
||||
document.getElementById('weather-widget-city-add').addEventListener('click', async () => {
|
||||
const input = document.getElementById('weather-widget-city-input');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
const data = await resp.json();
|
||||
addWeatherWidgetCityRow(data.city.label);
|
||||
input.value = '';
|
||||
showStatus(true, `Added ${data.city.label}.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
|
||||
loadWeatherPreview(false);
|
||||
}
|
||||
|
||||
function closeWeatherDialog() {
|
||||
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<h2 class="dialog-title">Weather widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Display</h2>
|
||||
<form id="weather-config-form">
|
||||
<label>Mode
|
||||
<select id="weather_mode">
|
||||
<option value="current" {% if weather_cfg.mode == "current" %}selected{% endif %}>Current conditions</option>
|
||||
<option value="hourly" {% if weather_cfg.mode == "hourly" %}selected{% endif %}>Hourly forecast</option>
|
||||
<option value="daily" {% if weather_cfg.mode == "daily" %}selected{% endif %}>Multi-day forecast</option>
|
||||
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Weather source
|
||||
<select id="weather_provider">
|
||||
{% for value, label in weather_provider_labels.items() %}
|
||||
<option value="{{ value }}" {% if weather_cfg.provider == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations.</p>
|
||||
<label>Units
|
||||
<select id="weather_units">
|
||||
<option value="fahrenheit" {% if weather_cfg.units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
|
||||
<option value="celsius" {% if weather_cfg.units == "celsius" %}selected{% endif %}>Celsius</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="weather-hourly-interval-row">
|
||||
<label>Hours between ticks
|
||||
<select id="weather_hourly_interval_hours">
|
||||
{% for hours in (3, 4, 6, 12) %}
|
||||
<option value="{{ hours }}" {% if weather_cfg.hourly_interval_hours == hours %}selected{% endif %}>{{ hours }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="weather-daily-days-row">
|
||||
<label>Days to show
|
||||
<input type="number" id="weather_daily_days" min="1" max="14" value="{{ weather_cfg.daily_days }}">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" id="weather-location-section" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Location</h2>
|
||||
<p class="sub" id="weather-location-current">
|
||||
{% if weather_cfg.city_label %}Currently: {{ weather_cfg.city_label }}{% else %}No location set yet.{% endif %}
|
||||
</p>
|
||||
<div class="checkbox-row" style="flex-wrap: wrap;">
|
||||
<input type="text" id="weather-location-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1 1 160px;">
|
||||
<button type="button" class="btn-inline" id="weather-location-set">Set</button>
|
||||
<button type="button" class="btn-inline secondary" id="weather-location-clear">Clear</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="weather-cities-section" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Cities</h2>
|
||||
<ul class="calendar-user-list" id="weather-widget-city-list">
|
||||
{% for c in weather_cfg.cities or [] %}
|
||||
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
|
||||
<span>{{ c.label }}</span>
|
||||
<button type="button" class="btn-inline secondary weather-widget-city-remove" data-label="{{ c.label }}">Remove</button>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="checkbox-row" style="margin-top: 10px;">
|
||||
<input type="text" id="weather-widget-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
|
||||
<button type="button" class="btn-inline" id="weather-widget-city-add">Add</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="weather-preview" alt="Weather preview">
|
||||
<button type="button" class="secondary" id="weather-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -74,6 +74,7 @@
|
||||
<script src="/static/widget_dialog_tasks.js"></script>
|
||||
<script src="/static/widget_dialog_static.js"></script>
|
||||
<script src="/static/widget_dialog_text.js"></script>
|
||||
<script src="/static/widget_dialog_weather.js"></script>
|
||||
<script src="/static/frame_layout.js"></script>
|
||||
<script src="/static/saved_layouts.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""NWS (National Weather Service, api.weather.gov) provider -- US
|
||||
locations only, free, no API key, but requires a `User-Agent` header
|
||||
identifying the calling app (NWS API policy: unidentified traffic gets
|
||||
throttled/blocked). No geocoder of its own; app/weather/__init__.py's
|
||||
geocode_city (Open-Meteo's) resolves a place name to lat/lon regardless
|
||||
of which provider is then chosen to fetch with it.
|
||||
|
||||
Unlike Open-Meteo's numeric WMO codes, NWS periods carry a `shortForecast`
|
||||
text description and an `icon` URL encoding a condition code (e.g.
|
||||
".../icons/land/day/tsra,40?size=medium") -- _category_from_period below
|
||||
normalizes either into the same shared category set
|
||||
(clear/partly_cloudy/cloudy/fog/rain/snow/thunderstorm) Open-Meteo's
|
||||
weather_category() already produces, so app/weather_render.py's build_*
|
||||
functions never need to know which provider supplied an entry.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from . import WeatherFetchError
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
BASE_URL = "https://api.weather.gov"
|
||||
# NWS blocks/deprioritizes traffic with no identifying User-Agent -- a
|
||||
# contact-ish string is their documented convention, not a real account.
|
||||
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
|
||||
|
||||
# NWS's icon condition codes (https://api.weather.gov/icons), grouped into
|
||||
# the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO
|
||||
# codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same
|
||||
# sky condition plus wind -- stripped before lookup, since this project's
|
||||
# hand-drawn icons (app/weather_render.py) don't have a separate windy
|
||||
# glyph.
|
||||
_ICON_CODE_CATEGORIES = {
|
||||
"skc": "clear", "clear": "clear",
|
||||
"few": "partly_cloudy", "sct": "partly_cloudy",
|
||||
"bkn": "cloudy", "ovc": "cloudy",
|
||||
"fog": "fog", "haze": "fog", "smoke": "fog", "dust": "fog",
|
||||
"rain": "rain", "rain_showers": "rain", "rain_showers_hi": "rain",
|
||||
"showers": "rain", "drizzle": "rain",
|
||||
"snow": "snow", "rain_snow": "snow", "sleet": "snow",
|
||||
"fzra": "snow", "rain_fzra": "snow", "snow_fzra": "snow",
|
||||
"tsra": "thunderstorm", "tsra_sct": "thunderstorm", "tsra_hi": "thunderstorm",
|
||||
}
|
||||
|
||||
# Fallback keyword match against shortForecast text, in priority order --
|
||||
# used when the icon URL can't be parsed at all (unexpected shape) or its
|
||||
# condition code isn't in the table above (NWS occasionally adds new
|
||||
# icon variants).
|
||||
_TEXT_CATEGORY_KEYWORDS = [
|
||||
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"),
|
||||
("snow", "snow"), ("sleet", "snow"), ("ice", "snow"),
|
||||
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
|
||||
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
|
||||
("overcast", "cloudy"), ("cloudy", "cloudy"),
|
||||
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
|
||||
]
|
||||
|
||||
|
||||
def _category_from_icon(icon_url: str) -> str | None:
|
||||
"""The first condition code segment out of an icon URL path like
|
||||
".../icons/land/day/tsra,40/skc,20?size=medium" -- only the first
|
||||
(the dominant/nearest-term condition) is used, same "one glyph per
|
||||
entry" simplicity as Open-Meteo's single-WMO-code-per-day shape."""
|
||||
path = icon_url.split("?")[0]
|
||||
segments = [s for s in path.split("/") if s]
|
||||
for i, seg in enumerate(segments):
|
||||
if seg in ("day", "night") and i + 1 < len(segments):
|
||||
code = segments[i + 1].split(",")[0]
|
||||
code = code.removeprefix("wind_")
|
||||
return _ICON_CODE_CATEGORIES.get(code)
|
||||
return None
|
||||
|
||||
|
||||
def _category_from_text(text: str) -> str:
|
||||
lowered = text.lower()
|
||||
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
|
||||
if keyword in lowered:
|
||||
return category
|
||||
return "cloudy" # same generic-icon fallback Open-Meteo's weather_category uses
|
||||
|
||||
|
||||
def _category_from_period(period: dict) -> str:
|
||||
icon = period.get("icon") or ""
|
||||
category = _category_from_icon(icon) if icon else None
|
||||
return category or _category_from_text(period.get("shortForecast") or "")
|
||||
|
||||
|
||||
def _convert_temp(value: float, from_unit: str, to_units: str) -> float:
|
||||
"""NWS periods report temperatureUnit per-period (almost always "F"
|
||||
for US points) -- converts to the widget's own requested `units`
|
||||
("fahrenheit"/"celsius") only if they actually differ, so this is a
|
||||
no-op in the common case."""
|
||||
to_unit = "F" if to_units == "fahrenheit" else "C"
|
||||
if from_unit == to_unit:
|
||||
return value
|
||||
if from_unit == "F" and to_unit == "C":
|
||||
return (value - 32) * 5 / 9
|
||||
return value * 9 / 5 + 32
|
||||
|
||||
|
||||
def _points(latitude: float, longitude: float) -> dict:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{BASE_URL}/points/{latitude:.4f},{longitude:.4f}", headers=HEADERS, timeout=HTTP_TIMEOUT_S
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["properties"]
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(f"NWS points lookup failed (is this location in the US?): {e}") from e
|
||||
|
||||
|
||||
def _periods(url: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["properties"]["periods"]
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
|
||||
"""{"temp": float, "category": str} -- NWS has no simple by-
|
||||
coordinate "current conditions" endpoint (that needs a second
|
||||
stations-list + latest-observation lookup); this approximates
|
||||
"current" with the first hourly forecast period instead, which is
|
||||
plenty for a frame that only refreshes every few hours."""
|
||||
points = _points(latitude, longitude)
|
||||
periods = _periods(points["forecastHourly"])
|
||||
if not periods:
|
||||
raise WeatherFetchError("NWS returned no hourly forecast periods")
|
||||
period = periods[0]
|
||||
temp = _convert_temp(period["temperature"], period.get("temperatureUnit", "F"), units)
|
||||
return {"temp": temp, "category": _category_from_period(period)}
|
||||
|
||||
|
||||
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
|
||||
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
|
||||
one entry per hour -- NWS's forecastHourly is already 1-hour
|
||||
resolution, same as Open-Meteo's."""
|
||||
points = _points(latitude, longitude)
|
||||
periods = _periods(points["forecastHourly"])
|
||||
return [
|
||||
{
|
||||
"time": p["startTime"],
|
||||
"temp": _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units),
|
||||
"category": _category_from_period(p),
|
||||
}
|
||||
for p in periods[:hours]
|
||||
]
|
||||
|
||||
|
||||
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
|
||||
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
|
||||
-- NWS's /forecast returns day/night period pairs (12h each, ~7 days
|
||||
/ 14 periods), not one row per day; this pairs them by calendar date
|
||||
(a daytime period's high, the following night's low) and clamps to
|
||||
however many full dates actually came back once `days` is asked for
|
||||
more than that -- no error, just fewer days, same graceful-
|
||||
degradation idiom as app/weather_render.py's draw_weather_row."""
|
||||
points = _points(latitude, longitude)
|
||||
periods = _periods(points["forecast"])
|
||||
|
||||
by_date: dict[str, dict] = {}
|
||||
order: list[str] = []
|
||||
for p in periods:
|
||||
day = datetime.fromisoformat(p["startTime"]).date().isoformat()
|
||||
entry = by_date.setdefault(day, {"category": None})
|
||||
if day not in order:
|
||||
order.append(day)
|
||||
temp = _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units)
|
||||
if p["isDaytime"]:
|
||||
entry["high"] = temp
|
||||
entry["category"] = _category_from_period(p)
|
||||
else:
|
||||
entry["low"] = temp
|
||||
if entry["category"] is None:
|
||||
entry["category"] = _category_from_period(p)
|
||||
|
||||
result = {}
|
||||
for day in order[:max(1, days)]:
|
||||
entry = by_date[day]
|
||||
if "high" not in entry and "low" not in entry:
|
||||
continue
|
||||
result[day] = {
|
||||
"high": entry.get("high", entry.get("low")),
|
||||
"low": entry.get("low", entry.get("high")),
|
||||
"category": entry["category"] or "cloudy",
|
||||
}
|
||||
return result
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Weather strip for calendar frame mode (agenda/today & tomorrow/week
|
||||
views only -- never month, there's no room, see calendar_render.py's
|
||||
_BUILDERS). A frame can list multiple cities; each is geocoded once via
|
||||
Open-Meteo's free geocoding API (no API key, no signup, no per-request
|
||||
quota to manage) when added from the Calendar tab, then its daily
|
||||
forecast is refreshed on its own throttle -- same shape idiom as
|
||||
calendar_feed.py's merge-fetch cache.
|
||||
"""Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry)
|
||||
-- free geocoding + forecast APIs, no API key, no signup, no per-request
|
||||
quota to manage. Backs both the calendar widget's embedded weather strip
|
||||
(fetch_daily_forecast/weather_category, its original shape, untouched)
|
||||
and the standalone weather widget's per-mode fetches below (fetch_current/
|
||||
fetch_hourly/fetch_daily, which return already-normalized {"category":
|
||||
...} entries from the shared category set instead of a raw WMO code).
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py.
|
||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from . import WeatherFetchError
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
@@ -36,12 +38,6 @@ _CODE_CATEGORIES = {
|
||||
}
|
||||
|
||||
|
||||
class WeatherFetchError(Exception):
|
||||
"""Geocoding or forecast fetch failed -- network, no match, or an
|
||||
unexpected response shape. Raised loudly; callers (the Calendar
|
||||
tab's add-city endpoint, get_or_refresh_weather) decide what to do."""
|
||||
|
||||
|
||||
def weather_category(code: int) -> str:
|
||||
"""Falls back to "cloudy" for any WMO code Open-Meteo might add later
|
||||
that isn't in the table above -- an unrecognized code shouldn't drop
|
||||
@@ -133,3 +129,67 @@ def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[
|
||||
}
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
# --- Standalone weather widget fetches (app/widgets/weather.py) --------
|
||||
#
|
||||
# Unlike fetch_daily_forecast above (kept as-is for the calendar widget's
|
||||
# embedded strip, which does its own weather_category(code) lookup),
|
||||
# these return already-normalized {"category": ...} entries so
|
||||
# app/weather_render.py's build_* functions never need to know which
|
||||
# provider supplied the data (see app/weather/nws.py, which normalizes
|
||||
# its own icon/text shape to the same category set).
|
||||
|
||||
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
|
||||
"""{"temp": float, "category": str} for right now."""
|
||||
try:
|
||||
resp = httpx.get(FORECAST_URL, params={
|
||||
"latitude": latitude, "longitude": longitude,
|
||||
"current": "temperature_2m,weathercode",
|
||||
"temperature_unit": units,
|
||||
"timezone": "auto",
|
||||
}, timeout=HTTP_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
current = resp.json()["current"]
|
||||
return {"temp": current["temperature_2m"], "category": weather_category(current["weathercode"])}
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
|
||||
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
|
||||
one entry per hour, for the next `hours` hours (Open-Meteo's hourly
|
||||
data is always 1-hour resolution regardless of how far apart the
|
||||
ticks a caller actually wants to display are -- see
|
||||
app/weather_render.py's build_hourly, which samples every Nth
|
||||
entry)."""
|
||||
forecast_days = max(1, -(-hours // 24)) # ceil division -- enough days to cover `hours`
|
||||
try:
|
||||
resp = httpx.get(FORECAST_URL, params={
|
||||
"latitude": latitude, "longitude": longitude,
|
||||
"hourly": "temperature_2m,weathercode",
|
||||
"temperature_unit": units,
|
||||
"timezone": "auto",
|
||||
"forecast_days": forecast_days,
|
||||
}, timeout=HTTP_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
hourly = resp.json()["hourly"]
|
||||
return [
|
||||
{"time": t, "temp": temp, "category": weather_category(code)}
|
||||
for t, temp, code in zip(hourly["time"], hourly["temperature_2m"], hourly["weathercode"])
|
||||
][:hours]
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
|
||||
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
|
||||
-- same request as fetch_daily_forecast, just normalized to a
|
||||
category instead of a raw WMO code, and clamped to `days` (Open-
|
||||
Meteo's own real max is FORECAST_DAYS)."""
|
||||
clamped = max(1, min(days, FORECAST_DAYS))
|
||||
raw = fetch_daily_forecast(latitude, longitude, units)
|
||||
return {
|
||||
day: {"high": d["high"], "low": d["low"], "category": weather_category(d["code"])}
|
||||
for day, d in list(raw.items())[:clamped]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user