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:
parent
94cf618e0d
commit
d3305a243c
6 changed files with 280 additions and 196 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue