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",
|
||||
)
|
||||
|
||||
# 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
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
@ -61,12 +65,21 @@ async def lifespan(app: FastAPI):
|
|||
try:
|
||||
await reload_settings()
|
||||
cfg = get_settings()
|
||||
logger.info(
|
||||
"Settings loaded from DB — %d Unraid servers, MQTT=%s, HA=%s",
|
||||
len(cfg.unraid_servers),
|
||||
"enabled" if cfg.mqtt_enabled else "disabled",
|
||||
"enabled" if cfg.ha_enabled else "disabled",
|
||||
)
|
||||
|
||||
# Structured startup summary
|
||||
integrations = [
|
||||
("Weather", True, f"{cfg.weather_location} + {cfg.weather_location_secondary}"),
|
||||
("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:
|
||||
logger.exception("Failed to load settings from DB — using ENV defaults")
|
||||
cfg = settings
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ async def get_all() -> Dict[str, Any]:
|
|||
|
||||
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 {
|
||||
"weather": weather_data,
|
||||
"news": news_data,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ async def get_news_articles(
|
|||
# --- cache hit? -----------------------------------------------------------
|
||||
cached = await cache.get(key)
|
||||
if cached is not None:
|
||||
logger.debug("[NEWS] Cache hit (key=%s)", key)
|
||||
return cached
|
||||
|
||||
# --- cache miss -----------------------------------------------------------
|
||||
|
|
@ -51,8 +52,10 @@ async def get_news_articles(
|
|||
|
||||
try:
|
||||
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:
|
||||
logger.exception("Failed to fetch news articles")
|
||||
logger.exception("[NEWS] Failed to fetch articles")
|
||||
return {
|
||||
"articles": [],
|
||||
"total": 0,
|
||||
|
|
|
|||
|
|
@ -21,65 +21,72 @@ CACHE_KEY = "weather"
|
|||
|
||||
@router.get("/weather")
|
||||
async def get_weather() -> Dict[str, Any]:
|
||||
"""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 ],
|
||||
}
|
||||
"""
|
||||
"""Return weather for both configured locations plus an hourly forecast."""
|
||||
|
||||
# --- cache hit? -----------------------------------------------------------
|
||||
cached = await cache.get(CACHE_KEY)
|
||||
if cached is not None:
|
||||
logger.debug("[WEATHER] Cache hit (key=%s)", CACHE_KEY)
|
||||
return cached
|
||||
|
||||
# --- cache miss -- fetch all three in parallel ----------------------------
|
||||
primary_data: Dict[str, Any] = {}
|
||||
secondary_data: Dict[str, Any] = {}
|
||||
hourly_data: List[Dict[str, Any]] = []
|
||||
cfg = get_settings()
|
||||
logger.info("[WEATHER] Cache miss — fetching '%s' + '%s'",
|
||||
cfg.weather_location, cfg.weather_location_secondary)
|
||||
|
||||
results = await asyncio.gather(
|
||||
_safe_fetch_weather(get_settings().weather_location),
|
||||
_safe_fetch_weather(get_settings().weather_location_secondary),
|
||||
_safe_fetch_hourly(get_settings().weather_location),
|
||||
return_exceptions=False, # we handle errors inside the helpers
|
||||
_safe_fetch_weather(cfg.weather_location),
|
||||
_safe_fetch_weather(cfg.weather_location_secondary),
|
||||
_safe_fetch_hourly(cfg.weather_location),
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
primary_data = results[0]
|
||||
secondary_data = results[1]
|
||||
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] = {
|
||||
"primary": primary_data,
|
||||
"secondary": secondary_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
|
||||
|
||||
|
||||
# -- 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]:
|
||||
"""Fetch weather for *location*, returning an error stub on failure."""
|
||||
try:
|
||||
data = await fetch_weather(location)
|
||||
return data
|
||||
return await fetch_weather(location)
|
||||
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}
|
||||
|
||||
|
||||
async def _safe_fetch_hourly(location: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch hourly forecast for *location*, returning ``[]`` on failure."""
|
||||
try:
|
||||
data = await fetch_hourly_forecast(location)
|
||||
return data
|
||||
return await fetch_hourly_forecast(location)
|
||||
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 []
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import httpx
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
|
|
@ -114,9 +117,14 @@ async def _try_api_endpoint(
|
|||
_parse_system_info(data, result)
|
||||
_parse_array_info(data, result)
|
||||
_parse_docker_info(data, result)
|
||||
logger.info("[UNRAID] %s (%s): API OK", server.name, server.host)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
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
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
except Exception as exc:
|
||||
result["online"] = False
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
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
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue