Files
tfaour efb0f2e22d
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m10s
Build and push server image / deploy (push) Successful in 51s
Swap hand-drawn weather icons for Environment Canada's real icon set
The hand-drawn glyphs (draw_cloud/draw_sun/draw_raindrop/draw_snowflake/
draw_lightning_bolt) are replaced by 7 vendored bitmaps, one per shared
weather category, sourced from weather.gc.ca's public icon set -- these
are small, flat-shaded images that dither cleanly onto the panel's
6-color palette and read as recognizable weather icons in a way the
hand-drawn attempt (a plain circle-with-ticks "sun") didn't. Used for
every provider's rendering (Open-Meteo, NWS, EC), not just when EC is
selected.

Vendored (not fetched live at render time), matching this project's
existing convention for the Noto Emoji fonts -- server/app/weather_icons/
SOURCE.md documents the source, attribution, and the licensing caveat
(this is a personal, non-commercial project; the icon images' own
copyright terms are less clearly permissive than the weather data's own
End-use Licence, since they're served from the public website rather
than ECCC's data servers).

draw_weather_icon's signature changes from (draw, cx, cy, r, category,
palette_rgb) to (img, cx, cy, r, category): pasting a bitmap needs the
Image object, not just an ImageDraw handle, and palette_rgb is no longer
needed since the shared _quantize step already maps whatever's on the
composited canvas to the frame's actual palette -- no per-icon color
resolution required anymore.
2026-07-27 17:22:59 +00:00

197 lines
8.3 KiB
Python

"""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
# vendored icon set (app/weather_render.py, app/weather_icons/) doesn'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