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

@ -20,6 +20,10 @@ logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
) )
# Reduce noise from third-party libraries — our services log their own summaries
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@ -61,12 +65,21 @@ async def lifespan(app: FastAPI):
try: try:
await reload_settings() await reload_settings()
cfg = get_settings() cfg = get_settings()
logger.info(
"Settings loaded from DB — %d Unraid servers, MQTT=%s, HA=%s", # Structured startup summary
len(cfg.unraid_servers), integrations = [
"enabled" if cfg.mqtt_enabled else "disabled", ("Weather", True, f"{cfg.weather_location} + {cfg.weather_location_secondary}"),
"enabled" if cfg.ha_enabled else "disabled", ("HA", cfg.ha_enabled, cfg.ha_url or "not configured"),
) ("Vikunja", cfg.vikunja_enabled, cfg.vikunja_url or "not configured"),
("Unraid", cfg.unraid_enabled, f"{len(cfg.unraid_servers)} server(s)"),
("MQTT", cfg.mqtt_enabled, f"{cfg.mqtt_host}:{cfg.mqtt_port}" if cfg.mqtt_host else "not configured"),
("News", cfg.news_enabled, f"max_age={cfg.news_max_age_hours}h"),
]
logger.info("--- Integration Status ---")
for name, enabled, detail in integrations:
status = "ON " if enabled else "OFF"
logger.info(" [%s] %-10s %s", status, name, detail)
logger.info("--------------------------")
except Exception: except Exception:
logger.exception("Failed to load settings from DB — using ENV defaults") logger.exception("Failed to load settings from DB — using ENV defaults")
cfg = settings cfg = settings

View file

@ -53,6 +53,13 @@ async def get_all() -> Dict[str, Any]:
weather_data, news_data, servers_data, ha_data, tasks_data = results weather_data, news_data, servers_data, ha_data, tasks_data = results
# Log a concise summary of what worked and what failed
sections = {"weather": weather_data, "news": news_data, "servers": servers_data,
"ha": ha_data, "tasks": tasks_data}
errors = [k for k, v in sections.items() if isinstance(v, dict) and v.get("error")]
if errors:
logger.warning("[DASHBOARD] Sections with errors: %s", ", ".join(errors))
return { return {
"weather": weather_data, "weather": weather_data,
"news": news_data, "news": news_data,

View file

@ -43,6 +43,7 @@ async def get_news_articles(
# --- cache hit? ----------------------------------------------------------- # --- cache hit? -----------------------------------------------------------
cached = await cache.get(key) cached = await cache.get(key)
if cached is not None: if cached is not None:
logger.debug("[NEWS] Cache hit (key=%s)", key)
return cached return cached
# --- cache miss ----------------------------------------------------------- # --- cache miss -----------------------------------------------------------
@ -51,8 +52,10 @@ async def get_news_articles(
try: try:
articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=get_settings().news_max_age_hours) articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=get_settings().news_max_age_hours)
logger.info("[NEWS] Fetched %d articles (limit=%d, offset=%d, category=%s)",
len(articles), limit, offset, category)
except Exception as exc: except Exception as exc:
logger.exception("Failed to fetch news articles") logger.exception("[NEWS] Failed to fetch articles")
return { return {
"articles": [], "articles": [],
"total": 0, "total": 0,

View file

@ -21,65 +21,72 @@ CACHE_KEY = "weather"
@router.get("/weather") @router.get("/weather")
async def get_weather() -> Dict[str, Any]: async def get_weather() -> Dict[str, Any]:
"""Return weather for both configured locations plus an hourly forecast. """Return weather for both configured locations plus an hourly forecast."""
The response shape is::
{
"primary": { ... weather dict or error stub },
"secondary": { ... weather dict or error stub },
"hourly": [ ... forecast entries or empty list ],
}
"""
# --- cache hit? ----------------------------------------------------------- # --- cache hit? -----------------------------------------------------------
cached = await cache.get(CACHE_KEY) cached = await cache.get(CACHE_KEY)
if cached is not None: if cached is not None:
logger.debug("[WEATHER] Cache hit (key=%s)", CACHE_KEY)
return cached return cached
# --- cache miss -- fetch all three in parallel ---------------------------- # --- cache miss -- fetch all three in parallel ----------------------------
primary_data: Dict[str, Any] = {} cfg = get_settings()
secondary_data: Dict[str, Any] = {} logger.info("[WEATHER] Cache miss — fetching '%s' + '%s'",
hourly_data: List[Dict[str, Any]] = [] cfg.weather_location, cfg.weather_location_secondary)
results = await asyncio.gather( results = await asyncio.gather(
_safe_fetch_weather(get_settings().weather_location), _safe_fetch_weather(cfg.weather_location),
_safe_fetch_weather(get_settings().weather_location_secondary), _safe_fetch_weather(cfg.weather_location_secondary),
_safe_fetch_hourly(get_settings().weather_location), _safe_fetch_hourly(cfg.weather_location),
return_exceptions=False, # we handle errors inside the helpers return_exceptions=False,
) )
primary_data = results[0] primary_data = results[0]
secondary_data = results[1] secondary_data = results[1]
hourly_data = results[2] hourly_data = results[2]
# Log result summary
_log_weather_result("primary", cfg.weather_location, primary_data)
_log_weather_result("secondary", cfg.weather_location_secondary, secondary_data)
logger.info("[WEATHER] Hourly: %d slots", len(hourly_data))
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"primary": primary_data, "primary": primary_data,
"secondary": secondary_data, "secondary": secondary_data,
"hourly": hourly_data, "hourly": hourly_data,
} }
await cache.set(CACHE_KEY, payload, get_settings().weather_cache_ttl) await cache.set(CACHE_KEY, payload, cfg.weather_cache_ttl)
logger.debug("[WEATHER] Cached for %ds", cfg.weather_cache_ttl)
return payload return payload
# -- internal helpers --------------------------------------------------------- # -- internal helpers ---------------------------------------------------------
def _log_weather_result(label: str, location: str, data: Dict[str, Any]) -> None:
"""Log a concise summary of a weather result."""
if data.get("error"):
logger.error("[WEATHER] %s (%s): ERROR — %s", label, location,
data.get("message", data.get("error")))
else:
logger.info("[WEATHER] %s (%s): %d°C, %s",
label, data.get("location", location),
data.get("temp", 0), data.get("description", "?"))
async def _safe_fetch_weather(location: str) -> Dict[str, Any]: async def _safe_fetch_weather(location: str) -> Dict[str, Any]:
"""Fetch weather for *location*, returning an error stub on failure.""" """Fetch weather for *location*, returning an error stub on failure."""
try: try:
data = await fetch_weather(location) return await fetch_weather(location)
return data
except Exception as exc: except Exception as exc:
logger.exception("Failed to fetch weather for %s", location) logger.exception("[WEATHER] Unhandled error fetching '%s'", location)
return {"error": True, "message": str(exc), "location": location} return {"error": True, "message": str(exc), "location": location}
async def _safe_fetch_hourly(location: str) -> List[Dict[str, Any]]: async def _safe_fetch_hourly(location: str) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for *location*, returning ``[]`` on failure.""" """Fetch hourly forecast for *location*, returning ``[]`` on failure."""
try: try:
data = await fetch_hourly_forecast(location) return await fetch_hourly_forecast(location)
return data
except Exception as exc: except Exception as exc:
logger.exception("Failed to fetch hourly forecast for %s", location) logger.exception("[WEATHER] Unhandled error fetching hourly for '%s'", location)
return [] return []

View file

@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import httpx import httpx
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass @dataclass
class ServerConfig: class ServerConfig:
@ -114,9 +117,14 @@ async def _try_api_endpoint(
_parse_system_info(data, result) _parse_system_info(data, result)
_parse_array_info(data, result) _parse_array_info(data, result)
_parse_docker_info(data, result) _parse_docker_info(data, result)
logger.info("[UNRAID] %s (%s): API OK", server.name, server.host)
return True return True
except Exception: else:
pass logger.warning("[UNRAID] %s (%s): /api/system returned HTTP %d",
server.name, server.host, resp.status_code)
except Exception as exc:
logger.warning("[UNRAID] %s (%s): /api/system failed: %s",
server.name, server.host, exc)
# Try individual endpoints if the combined one failed # Try individual endpoints if the combined one failed
fetched_any = False fetched_any = False
@ -208,11 +216,16 @@ async def fetch_server_stats(server: ServerConfig) -> Dict[str, Any]:
api_ok = await _try_api_endpoint(client, server, result) api_ok = await _try_api_endpoint(client, server, result)
if not api_ok and not result["online"]: if not api_ok and not result["online"]:
logger.info("[UNRAID] %s: API failed, trying connectivity check", server.name)
await _try_connectivity_check(client, server, result) await _try_connectivity_check(client, server, result)
except Exception as exc: except Exception as exc:
result["online"] = False result["online"] = False
result["error"] = str(exc) result["error"] = str(exc)
logger.error("[UNRAID] %s (%s): connection failed: %s", server.name, server.host, exc)
if not result["online"]:
logger.warning("[UNRAID] %s (%s): offline (error=%s)", server.name, server.host, result.get("error"))
return result return result

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 from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import httpx import httpx
from typing import Any, Dict, List, Optional
WEATHER_ICONS: Dict[int, str] = { logger = logging.getLogger(__name__)
113: "\u2600\ufe0f", # Clear/Sunny
116: "\u26c5", # Partly Cloudy # ---------------------------------------------------------------------------
119: "\u2601\ufe0f", # Cloudy # WMO Weather Code → Emoji + German description
122: "\u2601\ufe0f", # Overcast # https://open-meteo.com/en/docs#weathervariables
143: "\ud83c\udf2b\ufe0f", # Mist # ---------------------------------------------------------------------------
176: "\ud83c\udf26\ufe0f", # Patchy rain nearby
179: "\ud83c\udf28\ufe0f", # Patchy snow nearby WMO_CODES: Dict[int, Tuple[str, str]] = {
182: "\ud83c\udf28\ufe0f", # Patchy sleet nearby 0: ("☀️", "Klar"),
185: "\ud83c\udf28\ufe0f", # Patchy freezing drizzle nearby 1: ("🌤️", "Überwiegend klar"),
200: "\u26c8\ufe0f", # Thundery outbreaks nearby 2: ("", "Teilweise Bewölkt"),
227: "\ud83c\udf28\ufe0f", # Blowing snow 3: ("☁️", "Bewölkt"),
230: "\u2744\ufe0f", # Blizzard 45: ("🌫️", "Nebel"),
248: "\ud83c\udf2b\ufe0f", # Fog 48: ("🌫️", "Reifnebel"),
260: "\ud83c\udf2b\ufe0f", # Freezing fog 51: ("🌦️", "Leichter Nieselregen"),
263: "\ud83c\udf26\ufe0f", # Patchy light drizzle 53: ("🌧️", "Mäßiger Nieselregen"),
266: "\ud83c\udf27\ufe0f", # Light drizzle 55: ("🌧️", "Starker Nieselregen"),
281: "\ud83c\udf28\ufe0f", # Freezing drizzle 56: ("🌨️", "Gefrierender Nieselregen"),
284: "\ud83c\udf28\ufe0f", # Heavy freezing drizzle 57: ("🌨️", "Starker gefr. Nieselregen"),
293: "\ud83c\udf26\ufe0f", # Patchy light rain 61: ("🌦️", "Leichter Regen"),
296: "\ud83c\udf27\ufe0f", # Light rain 63: ("🌧️", "Mäßiger Regen"),
299: "\ud83c\udf27\ufe0f", # Moderate rain at times 65: ("🌧️", "Starker Regen"),
302: "\ud83c\udf27\ufe0f", # Moderate rain 66: ("🌨️", "Gefrierender Regen"),
305: "\ud83c\udf27\ufe0f", # Heavy rain at times 67: ("🌨️", "Starker gefr. Regen"),
308: "\ud83c\udf27\ufe0f", # Heavy rain 71: ("❄️", "Leichter Schneefall"),
311: "\ud83c\udf28\ufe0f", # Light freezing rain 73: ("❄️", "Mäßiger Schneefall"),
314: "\ud83c\udf28\ufe0f", # Moderate or heavy freezing rain 75: ("❄️", "Starker Schneefall"),
317: "\ud83c\udf28\ufe0f", # Light sleet 77: ("🌨️", "Schneekörner"),
320: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet 80: ("🌦️", "Leichte Regenschauer"),
323: "\ud83c\udf28\ufe0f", # Patchy light snow 81: ("🌧️", "Mäßige Regenschauer"),
326: "\u2744\ufe0f", # Light snow 82: ("🌧️", "Starke Regenschauer"),
329: "\u2744\ufe0f", # Patchy moderate snow 85: ("❄️", "Leichte Schneeschauer"),
332: "\u2744\ufe0f", # Moderate snow 86: ("❄️", "Starke Schneeschauer"),
335: "\u2744\ufe0f", # Patchy heavy snow 95: ("⛈️", "Gewitter"),
338: "\u2744\ufe0f", # Heavy snow 96: ("⛈️", "Gewitter mit leichtem Hagel"),
350: "\ud83c\udf28\ufe0f", # Ice pellets 99: ("⛈️", "Gewitter mit starkem Hagel"),
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
} }
def _get_weather_icon(code: int) -> str: def _wmo_icon(code: int) -> str:
"""Map a WWO weather code to an emoji icon.""" """Map WMO weather code to an emoji."""
return WEATHER_ICONS.get(code, "\ud83c\udf24\ufe0f") return WMO_CODES.get(code, ("🌤️", "Unbekannt"))[0]
def _parse_current_condition(condition: Dict[str, Any], location: str) -> Dict[str, Any]: def _wmo_description(code: int) -> str:
"""Parse a single current_condition entry from the wttr.in JSON.""" """Map WMO weather code to a German description."""
weather_code = int(condition.get("weatherCode", 113)) return WMO_CODES.get(code, ("🌤️", "Unbekannt"))[1]
descriptions = condition.get("weatherDesc", [])
description = descriptions[0].get("value", "Unknown") if descriptions else "Unknown"
return {
"location": location, # ---------------------------------------------------------------------------
"temp": int(condition.get("temp_C", 0)), # Geocoding cache (in-memory, process-lifetime)
"feels_like": int(condition.get("FeelsLikeC", 0)), # ---------------------------------------------------------------------------
"humidity": int(condition.get("humidity", 0)),
"wind_kmh": int(condition.get("windspeedKmph", 0)), _geocode_cache: Dict[str, Dict[str, Any]] = {}
"description": description,
"icon": _get_weather_icon(weather_code),
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.""" # Public API
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,
}
async def fetch_weather(location: str) -> Dict[str, Any]: 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: Args:
location: City name or coordinates (e.g. "Berlin" or "52.52,13.405"). location: City name (e.g. "Berlin", "Leverkusen", "Rab,Croatia").
Returns: Returns:
Dictionary with current conditions and 3-day forecast. Dict with current conditions, 3-day forecast, and optional error.
""" """
fallback: Dict[str, Any] = { fallback: Dict[str, Any] = {
"location": location, "location": location,
@ -123,112 +125,151 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
"feels_like": 0, "feels_like": 0,
"humidity": 0, "humidity": 0,
"wind_kmh": 0, "wind_kmh": 0,
"description": "Unavailable", "description": "Nicht verfügbar",
"icon": "\u2753", "icon": "",
"forecast_3day": [], "forecast_3day": [],
"error": None, "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: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"https://wttr.in/{location}", "https://api.open-meteo.com/v1/forecast",
params={"format": "j1"}, params={
headers={"Accept": "application/json"}, "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() resp.raise_for_status()
data = resp.json() data = resp.json()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
fallback["error"] = f"HTTP {exc.response.status_code}" msg = f"HTTP {exc.response.status_code}"
return fallback logger.error("[WEATHER] Open-Meteo HTTP error for '%s': %s", location, msg)
except httpx.RequestError as exc: fallback["error"] = msg
fallback["error"] = f"Request failed: {exc}"
return fallback return fallback
except Exception as exc: 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 return fallback
current_conditions = data.get("current_condition", []) # Step 3: Parse current conditions
if not current_conditions: current = data.get("current", {})
fallback["error"] = "No current condition data" wmo_code = int(current.get("weather_code", 0))
return fallback
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]] = [] 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["forecast_3day"] = forecast_3day
result["error"] = None logger.info("[WEATHER] Fetched '%s': %d°C, %s", geo["name"], result["temp"], result["description"])
return result return result
async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]: async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for the current day from wttr.in. """Fetch hourly forecast for the next 8 hours from Open-Meteo.
Returns the next 8 hourly slots from the current day's forecast.
Args: Args:
location: City name or coordinates. location: City name or coordinates.
Returns: 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: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get( resp = await client.get(
f"https://wttr.in/{location}", "https://api.open-meteo.com/v1/forecast",
params={"format": "j1"}, params={
headers={"Accept": "application/json"}, "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() resp.raise_for_status()
data = resp.json() data = resp.json()
except Exception: except Exception as exc:
logger.error("[WEATHER] Hourly fetch failed for '%s': %s", location, exc)
return [] return []
weather_days = data.get("weather", []) hourly = data.get("hourly", {})
if not weather_days: 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 [] return []
all_hourly: List[Dict[str, Any]] = [] now = datetime.now()
for day in weather_days[:2]: now_str = now.strftime("%Y-%m-%dT%H:00")
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}"
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]] = [] upcoming: List[Dict[str, Any]] = []
found_start = False started = False
for slot in all_hourly:
slot_hour = int(slot["time"].split(":")[0]) for i, t in enumerate(times):
if not found_start: if not started:
if slot_hour >= now_hour: if t >= now_str:
found_start = True started = True
else: else:
continue 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: if len(upcoming) >= 8:
break break
logger.debug("[WEATHER] Hourly for '%s': %d slots", location, len(upcoming))
return upcoming return upcoming