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 httpx import logging
from typing import Any, Dict, List, Optional from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
WEATHER_ICONS: Dict[int, str] = { import httpx
113: "\u2600\ufe0f", # Clear/Sunny
116: "\u26c5", # Partly Cloudy logger = logging.getLogger(__name__)
119: "\u2601\ufe0f", # Cloudy
122: "\u2601\ufe0f", # Overcast # ---------------------------------------------------------------------------
143: "\ud83c\udf2b\ufe0f", # Mist # WMO Weather Code → Emoji + German description
176: "\ud83c\udf26\ufe0f", # Patchy rain nearby # https://open-meteo.com/en/docs#weathervariables
179: "\ud83c\udf28\ufe0f", # Patchy snow nearby # ---------------------------------------------------------------------------
182: "\ud83c\udf28\ufe0f", # Patchy sleet nearby
185: "\ud83c\udf28\ufe0f", # Patchy freezing drizzle nearby WMO_CODES: Dict[int, Tuple[str, str]] = {
200: "\u26c8\ufe0f", # Thundery outbreaks nearby 0: ("☀️", "Klar"),
227: "\ud83c\udf28\ufe0f", # Blowing snow 1: ("🌤️", "Überwiegend klar"),
230: "\u2744\ufe0f", # Blizzard 2: ("", "Teilweise Bewölkt"),
248: "\ud83c\udf2b\ufe0f", # Fog 3: ("☁️", "Bewölkt"),
260: "\ud83c\udf2b\ufe0f", # Freezing fog 45: ("🌫️", "Nebel"),
263: "\ud83c\udf26\ufe0f", # Patchy light drizzle 48: ("🌫️", "Reifnebel"),
266: "\ud83c\udf27\ufe0f", # Light drizzle 51: ("🌦️", "Leichter Nieselregen"),
281: "\ud83c\udf28\ufe0f", # Freezing drizzle 53: ("🌧️", "Mäßiger Nieselregen"),
284: "\ud83c\udf28\ufe0f", # Heavy freezing drizzle 55: ("🌧️", "Starker Nieselregen"),
293: "\ud83c\udf26\ufe0f", # Patchy light rain 56: ("🌨️", "Gefrierender Nieselregen"),
296: "\ud83c\udf27\ufe0f", # Light rain 57: ("🌨️", "Starker gefr. Nieselregen"),
299: "\ud83c\udf27\ufe0f", # Moderate rain at times 61: ("🌦️", "Leichter Regen"),
302: "\ud83c\udf27\ufe0f", # Moderate rain 63: ("🌧️", "Mäßiger Regen"),
305: "\ud83c\udf27\ufe0f", # Heavy rain at times 65: ("🌧️", "Starker Regen"),
308: "\ud83c\udf27\ufe0f", # Heavy rain 66: ("🌨️", "Gefrierender Regen"),
311: "\ud83c\udf28\ufe0f", # Light freezing rain 67: ("🌨️", "Starker gefr. Regen"),
314: "\ud83c\udf28\ufe0f", # Moderate or heavy freezing rain 71: ("❄️", "Leichter Schneefall"),
317: "\ud83c\udf28\ufe0f", # Light sleet 73: ("❄️", "Mäßiger Schneefall"),
320: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet 75: ("❄️", "Starker Schneefall"),
323: "\ud83c\udf28\ufe0f", # Patchy light snow 77: ("🌨️", "Schneekörner"),
326: "\u2744\ufe0f", # Light snow 80: ("🌦️", "Leichte Regenschauer"),
329: "\u2744\ufe0f", # Patchy moderate snow 81: ("🌧️", "Mäßige Regenschauer"),
332: "\u2744\ufe0f", # Moderate snow 82: ("🌧️", "Starke Regenschauer"),
335: "\u2744\ufe0f", # Patchy heavy snow 85: ("❄️", "Leichte Schneeschauer"),
338: "\u2744\ufe0f", # Heavy snow 86: ("❄️", "Starke Schneeschauer"),
350: "\ud83c\udf28\ufe0f", # Ice pellets 95: ("⛈️", "Gewitter"),
353: "\ud83c\udf26\ufe0f", # Light rain shower 96: ("⛈️", "Gewitter mit leichtem Hagel"),
356: "\ud83c\udf27\ufe0f", # Moderate or heavy rain shower 99: ("⛈️", "Gewitter mit starkem Hagel"),
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