open_meteo.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. # -*- coding: utf-8 -*-
  2. """
  3. Open-Meteo Wetter-Provider.
  4. Nutzt das DWD-ICON-Modell. Kein API-Key erforderlich.
  5. https://open-meteo.com/
  6. Nutzt den OnlineService HTTP-Client mit 48h Offline-Cache.
  7. """
  8. from __future__ import annotations
  9. from datetime import date, datetime
  10. from typing import TYPE_CHECKING
  11. from plugins.weather.models import ForecastDay, WeatherData, WeatherResult
  12. from plugins.weather.providers.base import WeatherProvider
  13. from plugins.weather.utils import get_wmo_description
  14. from trixy_core.utils.debug import pdebug, perror
  15. if TYPE_CHECKING:
  16. from trixy_core.online.client import HttpClient
  17. # 48 Stunden Cache fuer Offline-Betrieb
  18. _CACHE_TTL = 48 * 3600
  19. class OpenMeteoProvider(WeatherProvider):
  20. """Wetter-Provider ueber die Open-Meteo API (DWD-ICON Modell)."""
  21. BASE_URL = "https://api.open-meteo.com/v1/dwd-icon"
  22. def __init__(self, config: dict, http: "HttpClient | None" = None) -> None:
  23. super().__init__(name="open_meteo", config=config, http=http)
  24. async def get_weather(
  25. self, lat: float, lon: float, location_name: str = ""
  26. ) -> WeatherResult | None:
  27. """Ruft Wetterdaten von Open-Meteo ab (mit 48h Offline-Cache)."""
  28. params = {
  29. "latitude": str(lat),
  30. "longitude": str(lon),
  31. "current": ",".join([
  32. "temperature_2m",
  33. "relative_humidity_2m",
  34. "weather_code",
  35. "wind_speed_10m",
  36. "wind_direction_10m",
  37. "precipitation",
  38. "apparent_temperature",
  39. "surface_pressure",
  40. "cloud_cover",
  41. ]),
  42. "daily": ",".join([
  43. "temperature_2m_max",
  44. "temperature_2m_min",
  45. "precipitation_sum",
  46. "weather_code",
  47. "wind_speed_10m_max",
  48. ]),
  49. "timezone": "Europe/Berlin",
  50. "forecast_days": "7",
  51. }
  52. try:
  53. # OnlineService HTTP-Client nutzen (mit 48h Cache)
  54. if self._http:
  55. resp = await self._http.get(
  56. self.BASE_URL, params=params, cache_ttl=_CACHE_TTL,
  57. )
  58. if not resp.ok:
  59. if resp.from_cache:
  60. pdebug(f"OpenMeteo: Verwende Offline-Cache")
  61. else:
  62. perror(f"OpenMeteo: Fehler: {resp.error or resp.status_code}")
  63. return None
  64. data = resp.json()
  65. if resp.from_cache:
  66. pdebug(f"OpenMeteo: Daten aus Offline-Cache")
  67. else:
  68. # Fallback: httpx direkt (ohne Cache)
  69. import httpx
  70. async with httpx.AsyncClient(timeout=10.0) as client:
  71. response = await client.get(self.BASE_URL, params=params)
  72. response.raise_for_status()
  73. data = response.json()
  74. return self._parse_response(data, location_name)
  75. except Exception as e:
  76. perror(f"OpenMeteo: Fehler beim Abruf: {e}")
  77. return None
  78. async def health_check(self) -> bool:
  79. """Prueft ob die Open-Meteo API erreichbar ist."""
  80. params = {
  81. "latitude": "49.25",
  82. "longitude": "8.88",
  83. "current": "temperature_2m",
  84. }
  85. try:
  86. if self._http:
  87. resp = await self._http.get(self.BASE_URL, params=params, timeout=5.0)
  88. return resp.ok
  89. else:
  90. import httpx
  91. async with httpx.AsyncClient(timeout=5.0) as client:
  92. response = await client.get(self.BASE_URL, params=params)
  93. return response.status_code == 200
  94. except Exception:
  95. return False
  96. def _parse_response(
  97. self, data: dict, location_name: str
  98. ) -> WeatherResult | None:
  99. """Parst die Open-Meteo API-Antwort."""
  100. try:
  101. current = data.get("current", {})
  102. daily = data.get("daily", {})
  103. weather_code = current.get("weather_code", 0)
  104. current_weather = WeatherData(
  105. location=location_name,
  106. temperature=current.get("temperature_2m", 0.0),
  107. feels_like=current.get("apparent_temperature"),
  108. humidity=int(current.get("relative_humidity_2m", 0)),
  109. description=get_wmo_description(weather_code),
  110. wind_speed=current.get("wind_speed_10m", 0.0),
  111. wind_direction=int(current.get("wind_direction_10m", 0)),
  112. precipitation=current.get("precipitation", 0.0),
  113. cloud_cover=_safe_int(current.get("cloud_cover")),
  114. pressure=current.get("surface_pressure"),
  115. provider_name=self.name,
  116. timestamp=datetime.now(),
  117. raw_data=current,
  118. )
  119. # Vorhersage parsen
  120. forecast = self._parse_forecast(daily)
  121. return WeatherResult(
  122. current=current_weather,
  123. forecast=forecast,
  124. fetched_at=datetime.now(),
  125. )
  126. except Exception as e:
  127. perror(f"OpenMeteo: Fehler beim Parsen: {e}")
  128. return None
  129. def _parse_forecast(self, daily: dict) -> list[ForecastDay]:
  130. """Parst die taegliche Vorhersage."""
  131. forecast: list[ForecastDay] = []
  132. dates = daily.get("time", [])
  133. temp_max = daily.get("temperature_2m_max", [])
  134. temp_min = daily.get("temperature_2m_min", [])
  135. precip = daily.get("precipitation_sum", [])
  136. codes = daily.get("weather_code", [])
  137. wind_max = daily.get("wind_speed_10m_max", [])
  138. for i in range(len(dates)):
  139. try:
  140. forecast.append(ForecastDay(
  141. date=date.fromisoformat(dates[i]),
  142. temp_min=temp_min[i] if i < len(temp_min) else 0.0,
  143. temp_max=temp_max[i] if i < len(temp_max) else 0.0,
  144. description=get_wmo_description(
  145. codes[i] if i < len(codes) else 0
  146. ),
  147. precipitation=precip[i] if i < len(precip) else 0.0,
  148. wind_speed_max=wind_max[i] if i < len(wind_max) else 0.0,
  149. ))
  150. except (ValueError, IndexError) as e:
  151. pdebug(f"OpenMeteo: Forecast-Tag {i} uebersprungen: {e}")
  152. return forecast
  153. def _safe_int(value: object) -> int | None:
  154. """Konvertiert einen Wert sicher zu int oder None."""
  155. if value is None:
  156. return None
  157. try:
  158. return int(value)
  159. except (TypeError, ValueError):
  160. return None