2026-03-03 01:13:49 +01:00
|
|
|
"""Weather data router -- primary, secondary & tertiary locations with hourly forecasts."""
|
2026-03-02 01:48:51 +01:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
from typing import Any, Dict, List
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter
|
|
|
|
|
|
|
|
|
|
from server.cache import cache
|
feat: add Admin Panel with JWT auth, DB settings, and integration management
Complete admin backend with login, where all integrations (weather, news,
Home Assistant, Vikunja, Unraid, MQTT) can be configured via web UI instead
of ENV variables. Two-layer config: ENV seeds DB on first start, then DB
is source of truth. Auto-migration system on startup.
Backend: db.py shared pool, auth.py JWT, settings_service CRUD, seed_service,
admin router (protected), test_connections per integration, config.py rewrite.
Frontend: react-router v6, login page, admin layout with sidebar, 8 settings
pages (General, Weather, News, HA, Vikunja, Unraid, MQTT, ChangePassword),
shared IntegrationForm + TestButton components.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:37:30 +01:00
|
|
|
from server.config import get_settings
|
2026-03-02 01:48:51 +01:00
|
|
|
from server.services.weather_service import fetch_hourly_forecast, fetch_weather
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api", tags=["weather"])
|
|
|
|
|
|
|
|
|
|
CACHE_KEY = "weather"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/weather")
|
|
|
|
|
async def get_weather() -> Dict[str, Any]:
|
2026-03-02 17:45:23 +01:00
|
|
|
"""Return weather for both configured locations plus an hourly forecast."""
|
2026-03-02 01:48:51 +01:00
|
|
|
|
|
|
|
|
# --- cache hit? -----------------------------------------------------------
|
|
|
|
|
cached = await cache.get(CACHE_KEY)
|
|
|
|
|
if cached is not None:
|
2026-03-02 17:45:23 +01:00
|
|
|
logger.debug("[WEATHER] Cache hit (key=%s)", CACHE_KEY)
|
2026-03-02 01:48:51 +01:00
|
|
|
return cached
|
|
|
|
|
|
|
|
|
|
# --- cache miss -- fetch all three in parallel ----------------------------
|
2026-03-02 17:45:23 +01:00
|
|
|
cfg = get_settings()
|
2026-03-03 01:13:49 +01:00
|
|
|
logger.info("[WEATHER] Cache miss — fetching '%s' + '%s' + '%s'",
|
|
|
|
|
cfg.weather_location, cfg.weather_location_secondary,
|
|
|
|
|
cfg.weather_location_tertiary)
|
2026-03-02 01:48:51 +01:00
|
|
|
|
|
|
|
|
results = await asyncio.gather(
|
2026-03-02 17:45:23 +01:00
|
|
|
_safe_fetch_weather(cfg.weather_location),
|
|
|
|
|
_safe_fetch_weather(cfg.weather_location_secondary),
|
2026-03-03 01:13:49 +01:00
|
|
|
_safe_fetch_weather(cfg.weather_location_tertiary),
|
|
|
|
|
_safe_fetch_hourly(cfg.weather_location, max_slots=24),
|
|
|
|
|
_safe_fetch_hourly(cfg.weather_location_secondary, max_slots=24),
|
|
|
|
|
_safe_fetch_hourly(cfg.weather_location_tertiary, max_slots=24),
|
2026-03-02 17:45:23 +01:00
|
|
|
return_exceptions=False,
|
2026-03-02 01:48:51 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
primary_data = results[0]
|
|
|
|
|
secondary_data = results[1]
|
2026-03-03 01:13:49 +01:00
|
|
|
tertiary_data = results[2]
|
|
|
|
|
hourly_data = results[3]
|
|
|
|
|
hourly_secondary = results[4]
|
|
|
|
|
hourly_tertiary = results[5]
|
2026-03-02 01:48:51 +01:00
|
|
|
|
2026-03-02 17:45:23 +01:00
|
|
|
# Log result summary
|
|
|
|
|
_log_weather_result("primary", cfg.weather_location, primary_data)
|
|
|
|
|
_log_weather_result("secondary", cfg.weather_location_secondary, secondary_data)
|
2026-03-03 01:13:49 +01:00
|
|
|
_log_weather_result("tertiary", cfg.weather_location_tertiary, tertiary_data)
|
|
|
|
|
logger.info("[WEATHER] Hourly: %d + %d + %d slots",
|
|
|
|
|
len(hourly_data), len(hourly_secondary), len(hourly_tertiary))
|
2026-03-02 17:45:23 +01:00
|
|
|
|
2026-03-02 01:48:51 +01:00
|
|
|
payload: Dict[str, Any] = {
|
|
|
|
|
"primary": primary_data,
|
|
|
|
|
"secondary": secondary_data,
|
2026-03-03 01:13:49 +01:00
|
|
|
"tertiary": tertiary_data,
|
2026-03-02 01:48:51 +01:00
|
|
|
"hourly": hourly_data,
|
2026-03-03 01:13:49 +01:00
|
|
|
"hourly_secondary": hourly_secondary,
|
|
|
|
|
"hourly_tertiary": hourly_tertiary,
|
2026-03-02 01:48:51 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 17:45:23 +01:00
|
|
|
await cache.set(CACHE_KEY, payload, cfg.weather_cache_ttl)
|
|
|
|
|
logger.debug("[WEATHER] Cached for %ds", cfg.weather_cache_ttl)
|
2026-03-02 01:48:51 +01:00
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- internal helpers ---------------------------------------------------------
|
|
|
|
|
|
2026-03-02 17:45:23 +01:00
|
|
|
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", "?"))
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 01:48:51 +01:00
|
|
|
async def _safe_fetch_weather(location: str) -> Dict[str, Any]:
|
|
|
|
|
"""Fetch weather for *location*, returning an error stub on failure."""
|
|
|
|
|
try:
|
2026-03-02 17:45:23 +01:00
|
|
|
return await fetch_weather(location)
|
2026-03-02 01:48:51 +01:00
|
|
|
except Exception as exc:
|
2026-03-02 17:45:23 +01:00
|
|
|
logger.exception("[WEATHER] Unhandled error fetching '%s'", location)
|
2026-03-02 01:48:51 +01:00
|
|
|
return {"error": True, "message": str(exc), "location": location}
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 01:13:49 +01:00
|
|
|
async def _safe_fetch_hourly(location: str, max_slots: int = 8) -> List[Dict[str, Any]]:
|
2026-03-02 01:48:51 +01:00
|
|
|
"""Fetch hourly forecast for *location*, returning ``[]`` on failure."""
|
|
|
|
|
try:
|
2026-03-03 01:13:49 +01:00
|
|
|
return await fetch_hourly_forecast(location, max_slots=max_slots)
|
2026-03-02 01:48:51 +01:00
|
|
|
except Exception as exc:
|
2026-03-02 17:45:23 +01:00
|
|
|
logger.exception("[WEATHER] Unhandled error fetching hourly for '%s'", location)
|
2026-03-02 01:48:51 +01:00
|
|
|
return []
|