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