Weather: Replace wttr.in with Open-Meteo + structured logging

- Replace wttr.in (unreachable from Docker) with Open-Meteo API
  (free, no API key, reliable) with geocoding cache
- WMO weather codes mapped to German descriptions + emoji icons
- Add [WEATHER], [NEWS], [UNRAID], [DASHBOARD] log prefixes
- Structured integration status table on startup
- Suppress noisy httpx INFO logs (services log their own summaries)
- Add logging to unraid_service (was completely silent on errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 17:45:23 +01:00
parent 94cf618e0d
commit d3305a243c
6 changed files with 280 additions and 196 deletions

View file

@ -1,121 +1,123 @@
"""Weather service — fetches weather data from Open-Meteo (free, no API key).
Uses the Open-Meteo geocoding API to resolve city names to coordinates,
then fetches current weather, hourly forecast, and 3-day daily forecast.
"""
from __future__ import annotations
import httpx
from typing import Any, Dict, List, Optional
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
WEATHER_ICONS: Dict[int, str] = {
113: "\u2600\ufe0f", # Clear/Sunny
116: "\u26c5", # Partly Cloudy
119: "\u2601\ufe0f", # Cloudy
122: "\u2601\ufe0f", # Overcast
143: "\ud83c\udf2b\ufe0f", # Mist
176: "\ud83c\udf26\ufe0f", # Patchy rain nearby
179: "\ud83c\udf28\ufe0f", # Patchy snow nearby
182: "\ud83c\udf28\ufe0f", # Patchy sleet nearby
185: "\ud83c\udf28\ufe0f", # Patchy freezing drizzle nearby
200: "\u26c8\ufe0f", # Thundery outbreaks nearby
227: "\ud83c\udf28\ufe0f", # Blowing snow
230: "\u2744\ufe0f", # Blizzard
248: "\ud83c\udf2b\ufe0f", # Fog
260: "\ud83c\udf2b\ufe0f", # Freezing fog
263: "\ud83c\udf26\ufe0f", # Patchy light drizzle
266: "\ud83c\udf27\ufe0f", # Light drizzle
281: "\ud83c\udf28\ufe0f", # Freezing drizzle
284: "\ud83c\udf28\ufe0f", # Heavy freezing drizzle
293: "\ud83c\udf26\ufe0f", # Patchy light rain
296: "\ud83c\udf27\ufe0f", # Light rain
299: "\ud83c\udf27\ufe0f", # Moderate rain at times
302: "\ud83c\udf27\ufe0f", # Moderate rain
305: "\ud83c\udf27\ufe0f", # Heavy rain at times
308: "\ud83c\udf27\ufe0f", # Heavy rain
311: "\ud83c\udf28\ufe0f", # Light freezing rain
314: "\ud83c\udf28\ufe0f", # Moderate or heavy freezing rain
317: "\ud83c\udf28\ufe0f", # Light sleet
320: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet
323: "\ud83c\udf28\ufe0f", # Patchy light snow
326: "\u2744\ufe0f", # Light snow
329: "\u2744\ufe0f", # Patchy moderate snow
332: "\u2744\ufe0f", # Moderate snow
335: "\u2744\ufe0f", # Patchy heavy snow
338: "\u2744\ufe0f", # Heavy snow
350: "\ud83c\udf28\ufe0f", # Ice pellets
353: "\ud83c\udf26\ufe0f", # Light rain shower
356: "\ud83c\udf27\ufe0f", # Moderate or heavy rain shower
359: "\ud83c\udf27\ufe0f", # Torrential rain shower
362: "\ud83c\udf28\ufe0f", # Light sleet showers
365: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet showers
368: "\u2744\ufe0f", # Light snow showers
371: "\u2744\ufe0f", # Moderate or heavy snow showers
374: "\ud83c\udf28\ufe0f", # Light showers of ice pellets
377: "\ud83c\udf28\ufe0f", # Moderate or heavy showers of ice pellets
386: "\u26c8\ufe0f", # Patchy light rain with thunder
389: "\u26c8\ufe0f", # Moderate or heavy rain with thunder
392: "\u26c8\ufe0f", # Patchy light snow with thunder
395: "\u26c8\ufe0f", # Moderate or heavy snow with thunder
import httpx
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# WMO Weather Code → Emoji + German description
# https://open-meteo.com/en/docs#weathervariables
# ---------------------------------------------------------------------------
WMO_CODES: Dict[int, Tuple[str, str]] = {
0: ("☀️", "Klar"),
1: ("🌤️", "Überwiegend klar"),
2: ("", "Teilweise Bewölkt"),
3: ("☁️", "Bewölkt"),
45: ("🌫️", "Nebel"),
48: ("🌫️", "Reifnebel"),
51: ("🌦️", "Leichter Nieselregen"),
53: ("🌧️", "Mäßiger Nieselregen"),
55: ("🌧️", "Starker Nieselregen"),
56: ("🌨️", "Gefrierender Nieselregen"),
57: ("🌨️", "Starker gefr. Nieselregen"),
61: ("🌦️", "Leichter Regen"),
63: ("🌧️", "Mäßiger Regen"),
65: ("🌧️", "Starker Regen"),
66: ("🌨️", "Gefrierender Regen"),
67: ("🌨️", "Starker gefr. Regen"),
71: ("❄️", "Leichter Schneefall"),
73: ("❄️", "Mäßiger Schneefall"),
75: ("❄️", "Starker Schneefall"),
77: ("🌨️", "Schneekörner"),
80: ("🌦️", "Leichte Regenschauer"),
81: ("🌧️", "Mäßige Regenschauer"),
82: ("🌧️", "Starke Regenschauer"),
85: ("❄️", "Leichte Schneeschauer"),
86: ("❄️", "Starke Schneeschauer"),
95: ("⛈️", "Gewitter"),
96: ("⛈️", "Gewitter mit leichtem Hagel"),
99: ("⛈️", "Gewitter mit starkem Hagel"),
}
def _get_weather_icon(code: int) -> str:
"""Map a WWO weather code to an emoji icon."""
return WEATHER_ICONS.get(code, "\ud83c\udf24\ufe0f")
def _wmo_icon(code: int) -> str:
"""Map WMO weather code to an emoji."""
return WMO_CODES.get(code, ("🌤️", "Unbekannt"))[0]
def _parse_current_condition(condition: Dict[str, Any], location: str) -> Dict[str, Any]:
"""Parse a single current_condition entry from the wttr.in JSON."""
weather_code = int(condition.get("weatherCode", 113))
descriptions = condition.get("weatherDesc", [])
description = descriptions[0].get("value", "Unknown") if descriptions else "Unknown"
def _wmo_description(code: int) -> str:
"""Map WMO weather code to a German description."""
return WMO_CODES.get(code, ("🌤️", "Unbekannt"))[1]
return {
"location": location,
"temp": int(condition.get("temp_C", 0)),
"feels_like": int(condition.get("FeelsLikeC", 0)),
"humidity": int(condition.get("humidity", 0)),
"wind_kmh": int(condition.get("windspeedKmph", 0)),
"description": description,
"icon": _get_weather_icon(weather_code),
# ---------------------------------------------------------------------------
# Geocoding cache (in-memory, process-lifetime)
# ---------------------------------------------------------------------------
_geocode_cache: Dict[str, Dict[str, Any]] = {}
async def _geocode(location: str) -> Optional[Dict[str, Any]]:
"""Resolve a city name to lat/lon using Open-Meteo Geocoding API.
Returns dict with keys: latitude, longitude, name, timezone
"""
cache_key = location.lower().strip()
if cache_key in _geocode_cache:
return _geocode_cache[cache_key]
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": location, "count": 1, "language": "de"},
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
logger.error("[WEATHER] Geocoding failed for '%s': %s", location, exc)
return None
results = data.get("results", [])
if not results:
logger.warning("[WEATHER] Geocoding: no results for '%s'", location)
return None
geo = {
"latitude": results[0]["latitude"],
"longitude": results[0]["longitude"],
"name": results[0].get("name", location),
"timezone": results[0].get("timezone", "Europe/Berlin"),
}
_geocode_cache[cache_key] = geo
logger.info("[WEATHER] Geocoded '%s'%s (%.2f, %.2f)",
location, geo["name"], geo["latitude"], geo["longitude"])
return geo
def _parse_forecast_day(day: Dict[str, Any]) -> Dict[str, Any]:
"""Parse a single forecast day from the wttr.in weather array."""
date = day.get("date", "")
max_temp = int(day.get("maxtempC", 0))
min_temp = int(day.get("mintempC", 0))
astronomy = day.get("astronomy", [])
sunrise = astronomy[0].get("sunrise", "") if astronomy else ""
sunset = astronomy[0].get("sunset", "") if astronomy else ""
hourly = day.get("hourly", [])
if hourly:
midday = hourly[len(hourly) // 2]
weather_code = int(midday.get("weatherCode", 113))
descs = midday.get("weatherDesc", [])
description = descs[0].get("value", "Unknown") if descs else "Unknown"
else:
weather_code = 113
description = "Unknown"
return {
"date": date,
"max_temp": max_temp,
"min_temp": min_temp,
"icon": _get_weather_icon(weather_code),
"description": description,
"sunrise": sunrise,
"sunset": sunset,
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def fetch_weather(location: str) -> Dict[str, Any]:
"""Fetch current weather and 3-day forecast from wttr.in.
"""Fetch current weather and 3-day forecast from Open-Meteo.
Args:
location: City name or coordinates (e.g. "Berlin" or "52.52,13.405").
location: City name (e.g. "Berlin", "Leverkusen", "Rab,Croatia").
Returns:
Dictionary with current conditions and 3-day forecast.
Dict with current conditions, 3-day forecast, and optional error.
"""
fallback: Dict[str, Any] = {
"location": location,
@ -123,112 +125,151 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
"feels_like": 0,
"humidity": 0,
"wind_kmh": 0,
"description": "Unavailable",
"icon": "\u2753",
"description": "Nicht verfügbar",
"icon": "",
"forecast_3day": [],
"error": None,
}
# Step 1: Geocode
geo = await _geocode(location)
if geo is None:
fallback["error"] = f"Standort '{location}' nicht gefunden"
logger.error("[WEATHER] Cannot fetch weather — geocoding failed for '%s'", location)
return fallback
# Step 2: Fetch weather
try:
async with httpx.AsyncClient(timeout=10) as client:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"https://wttr.in/{location}",
params={"format": "j1"},
headers={"Accept": "application/json"},
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": geo["latitude"],
"longitude": geo["longitude"],
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset",
"timezone": geo["timezone"],
"forecast_days": 3,
},
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
fallback["error"] = f"HTTP {exc.response.status_code}"
return fallback
except httpx.RequestError as exc:
fallback["error"] = f"Request failed: {exc}"
msg = f"HTTP {exc.response.status_code}"
logger.error("[WEATHER] Open-Meteo HTTP error for '%s': %s", location, msg)
fallback["error"] = msg
return fallback
except Exception as exc:
fallback["error"] = str(exc)
msg = f"{type(exc).__name__}: {exc}"
logger.error("[WEATHER] Open-Meteo request failed for '%s': %s", location, msg)
fallback["error"] = msg
return fallback
current_conditions = data.get("current_condition", [])
if not current_conditions:
fallback["error"] = "No current condition data"
return fallback
# Step 3: Parse current conditions
current = data.get("current", {})
wmo_code = int(current.get("weather_code", 0))
result = _parse_current_condition(current_conditions[0], location)
result: Dict[str, Any] = {
"location": geo["name"],
"temp": round(current.get("temperature_2m", 0)),
"feels_like": round(current.get("apparent_temperature", 0)),
"humidity": round(current.get("relative_humidity_2m", 0)),
"wind_kmh": round(current.get("wind_speed_10m", 0)),
"description": _wmo_description(wmo_code),
"icon": _wmo_icon(wmo_code),
"error": None,
}
weather_days = data.get("weather", [])
# Step 4: Parse 3-day forecast
daily = data.get("daily", {})
dates = daily.get("time", [])
forecast_3day: List[Dict[str, Any]] = []
for day in weather_days[:3]:
forecast_3day.append(_parse_forecast_day(day))
for i, date_str in enumerate(dates[:3]):
code = int(daily.get("weather_code", [0] * 3)[i])
forecast_3day.append({
"date": date_str,
"max_temp": round(daily.get("temperature_2m_max", [0] * 3)[i]),
"min_temp": round(daily.get("temperature_2m_min", [0] * 3)[i]),
"icon": _wmo_icon(code),
"description": _wmo_description(code),
"sunrise": daily.get("sunrise", [""] * 3)[i].split("T")[-1] if daily.get("sunrise") else "",
"sunset": daily.get("sunset", [""] * 3)[i].split("T")[-1] if daily.get("sunset") else "",
})
result["forecast_3day"] = forecast_3day
result["error"] = None
logger.info("[WEATHER] Fetched '%s': %d°C, %s", geo["name"], result["temp"], result["description"])
return result
async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for the current day from wttr.in.
Returns the next 8 hourly slots from the current day's forecast.
"""Fetch hourly forecast for the next 8 hours from Open-Meteo.
Args:
location: City name or coordinates.
Returns:
List of hourly forecast dicts with time, temp, icon, and precip_chance.
List of hourly forecast dicts (max 8 entries).
"""
geo = await _geocode(location)
if geo is None:
logger.error("[WEATHER] Cannot fetch hourly — geocoding failed for '%s'", location)
return []
try:
async with httpx.AsyncClient(timeout=10) as client:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"https://wttr.in/{location}",
params={"format": "j1"},
headers={"Accept": "application/json"},
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": geo["latitude"],
"longitude": geo["longitude"],
"hourly": "temperature_2m,weather_code,precipitation_probability,wind_speed_10m",
"timezone": geo["timezone"],
"forecast_days": 2,
},
)
resp.raise_for_status()
data = resp.json()
except Exception:
except Exception as exc:
logger.error("[WEATHER] Hourly fetch failed for '%s': %s", location, exc)
return []
weather_days = data.get("weather", [])
if not weather_days:
hourly = data.get("hourly", {})
times = hourly.get("time", [])
temps = hourly.get("temperature_2m", [])
codes = hourly.get("weather_code", [])
precips = hourly.get("precipitation_probability", [])
winds = hourly.get("wind_speed_10m", [])
if not times:
logger.warning("[WEATHER] No hourly data for '%s'", location)
return []
all_hourly: List[Dict[str, Any]] = []
for day in weather_days[:2]:
hourly_entries = day.get("hourly", [])
for entry in hourly_entries:
time_raw = entry.get("time", "0")
time_value = int(time_raw)
hours = time_value // 100
minutes = time_value % 100
time_str = f"{hours:02d}:{minutes:02d}"
now = datetime.now()
now_str = now.strftime("%Y-%m-%dT%H:00")
weather_code = int(entry.get("weatherCode", 113))
descs = entry.get("weatherDesc", [])
description = descs[0].get("value", "Unknown") if descs else "Unknown"
all_hourly.append({
"time": time_str,
"temp": int(entry.get("tempC", 0)),
"icon": _get_weather_icon(weather_code),
"description": description,
"precip_chance": int(entry.get("chanceofrain", 0)),
"wind_kmh": int(entry.get("windspeedKmph", 0)),
})
from datetime import datetime
now_hour = datetime.now().hour
upcoming: List[Dict[str, Any]] = []
found_start = False
for slot in all_hourly:
slot_hour = int(slot["time"].split(":")[0])
if not found_start:
if slot_hour >= now_hour:
found_start = True
started = False
for i, t in enumerate(times):
if not started:
if t >= now_str:
started = True
else:
continue
upcoming.append(slot)
code = int(codes[i]) if i < len(codes) else 0
upcoming.append({
"time": t.split("T")[1][:5], # "HH:MM"
"temp": round(temps[i]) if i < len(temps) else 0,
"icon": _wmo_icon(code),
"description": _wmo_description(code),
"precip_chance": round(precips[i]) if i < len(precips) else 0,
"wind_kmh": round(winds[i]) if i < len(winds) else 0,
})
if len(upcoming) >= 8:
break
logger.debug("[WEATHER] Hourly for '%s': %d slots", location, len(upcoming))
return upcoming