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.
196 lines
9.0 KiB
Python
196 lines
9.0 KiB
Python
"""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.
|
|
"""
|
|
|
|
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"
|
|
|
|
CHECK_INTERVAL_S = 3 * 60 * 60 # weather doesn't need calendar_feed's 20-minute cadence
|
|
FORECAST_DAYS = 14 # comfortably covers the week view's furthest browse-forward
|
|
|
|
# Open-Meteo's WMO weather codes (https://open-meteo.com/en/docs), grouped
|
|
# into the handful of icon categories calendar_render.py actually draws.
|
|
_CODE_CATEGORIES = {
|
|
0: "clear",
|
|
1: "partly_cloudy", 2: "partly_cloudy",
|
|
3: "cloudy",
|
|
45: "fog", 48: "fog",
|
|
51: "rain", 53: "rain", 55: "rain", 56: "rain", 57: "rain",
|
|
61: "rain", 63: "rain", 65: "rain", 66: "rain", 67: "rain",
|
|
80: "rain", 81: "rain", 82: "rain",
|
|
71: "snow", 73: "snow", 75: "snow", 77: "snow", 85: "snow", 86: "snow",
|
|
95: "thunderstorm", 96: "thunderstorm", 99: "thunderstorm",
|
|
}
|
|
|
|
|
|
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
|
|
a day's weather entirely, just render with a generic icon."""
|
|
return _CODE_CATEGORIES.get(code, "cloudy")
|
|
|
|
|
|
# Open-Meteo's geocoder matches on the bare place name only -- "Portland,
|
|
# OR" returns zero results even though "Portland" alone returns three
|
|
# (OR/ME/IN, disambiguated by population-ranked order). So a ", <state>"
|
|
# or ", <country>" qualifier is split off client-side and used to filter
|
|
# among the candidates instead of being sent as part of the search term.
|
|
_US_STATE_ABBREVIATIONS = {
|
|
"al": "alabama", "ak": "alaska", "az": "arizona", "ar": "arkansas", "ca": "california",
|
|
"co": "colorado", "ct": "connecticut", "de": "delaware", "fl": "florida", "ga": "georgia",
|
|
"hi": "hawaii", "id": "idaho", "il": "illinois", "in": "indiana", "ia": "iowa",
|
|
"ks": "kansas", "ky": "kentucky", "la": "louisiana", "me": "maine", "md": "maryland",
|
|
"ma": "massachusetts", "mi": "michigan", "mn": "minnesota", "ms": "mississippi", "mo": "missouri",
|
|
"mt": "montana", "ne": "nebraska", "nv": "nevada", "nh": "new hampshire", "nj": "new jersey",
|
|
"nm": "new mexico", "ny": "new york", "nc": "north carolina", "nd": "north dakota", "oh": "ohio",
|
|
"ok": "oklahoma", "or": "oregon", "pa": "pennsylvania", "ri": "rhode island", "sc": "south carolina",
|
|
"sd": "south dakota", "tn": "tennessee", "tx": "texas", "ut": "utah", "vt": "vermont",
|
|
"va": "virginia", "wa": "washington", "wv": "west virginia", "wi": "wisconsin", "wy": "wyoming",
|
|
"dc": "district of columbia",
|
|
}
|
|
|
|
|
|
def _matches_qualifier(result: dict, qualifier: str) -> bool:
|
|
q = qualifier.strip().lower()
|
|
expanded = _US_STATE_ABBREVIATIONS.get(q, q)
|
|
admin1 = (result.get("admin1") or "").lower()
|
|
country = (result.get("country") or "").lower()
|
|
country_code = (result.get("country_code") or "").lower()
|
|
return expanded in admin1 or expanded in country or q == country_code
|
|
|
|
|
|
def geocode_city(name: str) -> dict:
|
|
"""Best-match {"label", "latitude", "longitude"} for a free-text city
|
|
name, optionally qualified with a state/country (e.g. "Portland, OR")
|
|
via Open-Meteo's geocoder. label is the resolved place name (city +
|
|
admin1/country when available), not necessarily what the user typed
|
|
-- shown back so they can confirm it found the right place before
|
|
it's saved."""
|
|
query, _, qualifier = name.partition(",")
|
|
query, qualifier = query.strip(), qualifier.strip()
|
|
try:
|
|
resp = httpx.get(GEOCODE_URL, params={"name": query, "count": 10}, timeout=HTTP_TIMEOUT_S)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except httpx.HTTPError as e:
|
|
raise WeatherFetchError(str(e)) from e
|
|
results = data.get("results") or []
|
|
if not results:
|
|
raise WeatherFetchError(f"No location found matching {name!r}")
|
|
if qualifier:
|
|
qualified = [r for r in results if _matches_qualifier(r, qualifier)]
|
|
if not qualified:
|
|
raise WeatherFetchError(f"No location found matching {name!r}")
|
|
results = qualified
|
|
r = results[0]
|
|
parts = [r["name"]]
|
|
if r.get("admin1"):
|
|
parts.append(r["admin1"])
|
|
if r.get("country"):
|
|
parts.append(r["country"])
|
|
return {"label": ", ".join(parts), "latitude": r["latitude"], "longitude": r["longitude"]}
|
|
|
|
|
|
def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[str, dict]:
|
|
"""{"YYYY-MM-DD": {"code": int, "high": float, "low": float}, ...}
|
|
for the next FORECAST_DAYS days, already in `units`
|
|
("fahrenheit"/"celsius") -- Open-Meteo converts server-side, so
|
|
there's no client-side unit math to get wrong."""
|
|
try:
|
|
resp = httpx.get(FORECAST_URL, params={
|
|
"latitude": latitude, "longitude": longitude,
|
|
"daily": "weathercode,temperature_2m_max,temperature_2m_min",
|
|
"temperature_unit": units,
|
|
"timezone": "auto",
|
|
"forecast_days": FORECAST_DAYS,
|
|
}, timeout=HTTP_TIMEOUT_S)
|
|
resp.raise_for_status()
|
|
daily = resp.json()["daily"]
|
|
return {
|
|
day: {"code": code, "high": high, "low": low}
|
|
for day, code, high, low in zip(
|
|
daily["time"], daily["weathercode"], daily["temperature_2m_max"], daily["temperature_2m_min"]
|
|
)
|
|
}
|
|
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]
|
|
}
|