main.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. # -*- coding: utf-8 -*-
  2. """
  3. Weather-Plugin fuer Trixy.
  4. Stellt aktuelle Wetterdaten und Vorhersagen per Sprachabfrage bereit.
  5. Unterstuetzt drei Provider: Open-Meteo, OpenWeatherMap, Bright Sky (DWD).
  6. Wetterdaten werden im Cache gehalten und per Scheduler periodisch aktualisiert.
  7. """
  8. from datetime import date, timedelta
  9. from pathlib import Path
  10. from typing import Any
  11. from plugins.weather.cache import WeatherCache
  12. from plugins.weather.models import WeatherResult
  13. from plugins.weather.providers.base import WeatherProvider
  14. from plugins.weather.providers.brightsky import BrightSkyProvider
  15. from plugins.weather.providers.open_meteo import OpenMeteoProvider
  16. from plugins.weather.providers.openweathermap import OpenWeatherMapProvider
  17. from plugins.weather.utils import (
  18. format_forecast_for_tts,
  19. format_weather_for_tts,
  20. )
  21. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  22. from trixy_core.nlp.decorators import intent, pattern, example
  23. from trixy_core.nlp.intent_registry import IntentRegistry
  24. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  25. from trixy_core.utils.debug import pdebug, perror, pinfo, pwarn
  26. # Wochentag-Namen fuer TTS
  27. _WEEKDAY_NAMES = [
  28. "Montag", "Dienstag", "Mittwoch", "Donnerstag",
  29. "Freitag", "Samstag", "Sonntag",
  30. ]
  31. # Provider-Zuordnung
  32. PROVIDER_TYPES: dict[str, type[WeatherProvider]] = {
  33. "open_meteo": OpenMeteoProvider,
  34. "openweathermap": OpenWeatherMapProvider,
  35. "brightsky": BrightSkyProvider,
  36. }
  37. class WeatherPlugin(TrixyPlugin):
  38. """
  39. Wetter-Plugin mit Cache und periodischer Aktualisierung.
  40. Unterstuetzt aktuelle Abfragen, Tages- und Wochenvorhersagen
  41. fuer beliebige Staedte.
  42. """
  43. NAME = "weather"
  44. VERSION = "1.0.0"
  45. DESCRIPTION = "Wetterabfrage per Sprache mit Cache und Vorhersage"
  46. AUTHOR = "Trixy"
  47. def __init__(
  48. self,
  49. application: Any,
  50. plugin_path: Path,
  51. config: dict[str, Any] | None = None,
  52. ) -> None:
  53. super().__init__(application, plugin_path, config)
  54. self._registry = IntentRegistry.get_instance()
  55. self._provider: WeatherProvider | None = None
  56. self._cache: WeatherCache | None = None
  57. self._scheduler_job_id: str | None = None
  58. # ===== Lifecycle =====
  59. async def on_load(self) -> None:
  60. """Plugin laden: Provider, Cache, Scheduler, Intents initialisieren."""
  61. pinfo(f"WeatherPlugin: Lade Plugin v{self.VERSION}")
  62. # 1. Provider initialisieren
  63. self._init_provider()
  64. if not self._provider:
  65. perror("WeatherPlugin: Kein Provider verfuegbar")
  66. return
  67. # 2. Cache erstellen
  68. ttl = self.get_config_value("cache_ttl_hours", 5)
  69. self._cache = WeatherCache(ttl_hours=ttl)
  70. # 3. Initialer Fetch fuer Default-Standort
  71. await self._update_default_location()
  72. # 4. Scheduler-Job registrieren
  73. self._register_scheduler_job()
  74. # 5. Intents werden automatisch vom PluginManager registriert
  75. # (via @intent/@pattern/@example Dekoratoren auf den Handler-Methoden)
  76. pinfo(
  77. f"WeatherPlugin: Geladen (Provider: {self._provider.name})"
  78. )
  79. async def on_unload(self) -> None:
  80. """Plugin entladen: Scheduler und Intents aufraemen."""
  81. # 1. Scheduler-Job entfernen
  82. self._remove_scheduler_job()
  83. # 2. Intents deregistrieren
  84. self._registry.unregister_plugin(self.NAME)
  85. # 3. Cache leeren
  86. if self._cache:
  87. self._cache.clear()
  88. self._provider = None
  89. self._cache = None
  90. pdebug("WeatherPlugin: Entladen")
  91. # ===== Provider-Initialisierung =====
  92. def _get_http_client(self):
  93. """Gibt den HTTP-Client des OnlineService zurueck (falls verfuegbar)."""
  94. try:
  95. online = self.application.services.get_service("OnlineService")
  96. if online and hasattr(online, "http"):
  97. return online.http
  98. except Exception:
  99. pass
  100. return None
  101. def _init_provider(self) -> None:
  102. """
  103. Initialisiert den konfigurierten Provider.
  104. Versucht den Default-Provider aus der Config. Bei Fehlschlag
  105. wird auf den naechsten verfuegbaren Provider zurueckgegriffen.
  106. Der OnlineService HTTP-Client wird fuer Offline-Cache durchgereicht.
  107. """
  108. default_provider = self.get_config_value("provider", "open_meteo")
  109. providers_config = self.get_config_value("providers", {})
  110. http = self._get_http_client()
  111. if http:
  112. pdebug("WeatherPlugin: OnlineService HTTP-Client verfuegbar (Offline-Cache aktiv)")
  113. # Reihenfolge: konfigurierter Default zuerst, dann alle anderen
  114. order = [default_provider] + [
  115. k for k in providers_config if k != default_provider
  116. ]
  117. for name in order:
  118. prov_config = providers_config.get(name, {})
  119. if not prov_config.get("enabled", True):
  120. continue
  121. provider_class = PROVIDER_TYPES.get(name)
  122. if not provider_class:
  123. pwarn(f"WeatherPlugin: Unbekannter Provider '{name}'")
  124. continue
  125. # OpenWeatherMap braucht API-Key
  126. if name == "openweathermap" and not prov_config.get("api_key"):
  127. pdebug("WeatherPlugin: OpenWeatherMap uebersprungen (kein API-Key)")
  128. continue
  129. try:
  130. self._provider = provider_class(config=prov_config, http=http)
  131. pdebug(f"WeatherPlugin: Provider '{name}' initialisiert")
  132. return
  133. except Exception as e:
  134. perror(f"WeatherPlugin: Provider '{name}' Fehler: {e}")
  135. pwarn("WeatherPlugin: Kein Provider konnte initialisiert werden")
  136. # ===== Scheduler =====
  137. def _register_scheduler_job(self) -> None:
  138. """Registriert den periodischen Wetter-Update-Job."""
  139. scheduler = self.application.services.get_service("SchedulerService")
  140. if not scheduler:
  141. pwarn("WeatherPlugin: SchedulerService nicht verfuegbar")
  142. return
  143. from trixy_core.scheduler.action.callback import CallbackAction
  144. from trixy_core.scheduler.trigger.interval import IntervalTrigger
  145. interval_hours = self.get_config_value("update_interval_hours", 1)
  146. try:
  147. job = scheduler.create_job(
  148. trigger=IntervalTrigger(hours=interval_hours),
  149. actions=CallbackAction(self._update_default_location),
  150. job_id="weather_update",
  151. save=False,
  152. )
  153. self._scheduler_job_id = job.job_id
  154. pinfo(
  155. f"WeatherPlugin: Scheduler-Job registriert "
  156. f"(alle {interval_hours} Stunden)"
  157. )
  158. except Exception as e:
  159. perror(f"WeatherPlugin: Scheduler-Job Fehler: {e}")
  160. def _remove_scheduler_job(self) -> None:
  161. """Entfernt den Scheduler-Job."""
  162. if not self._scheduler_job_id:
  163. return
  164. scheduler = self.application.services.get_service("SchedulerService")
  165. if scheduler:
  166. try:
  167. scheduler.remove_job(self._scheduler_job_id, delete_file=False)
  168. except Exception:
  169. pass
  170. self._scheduler_job_id = None
  171. # ===== Wetter-Abruf =====
  172. async def _update_default_location(self) -> None:
  173. """Aktualisiert den Cache fuer den Default-Standort."""
  174. city = self.get_config_value("default_location.city", "Sinsheim")
  175. lat = self.get_config_value("default_location.latitude", 49.2547)
  176. lon = self.get_config_value("default_location.longitude", 8.8795)
  177. result = await self._fetch_weather(lat, lon, city)
  178. if result:
  179. pinfo(f"WeatherPlugin: Default-Standort '{city}' aktualisiert")
  180. else:
  181. pwarn(f"WeatherPlugin: Default-Standort '{city}' konnte nicht aktualisiert werden")
  182. async def _fetch_weather(
  183. self, lat: float, lon: float, location_name: str
  184. ) -> WeatherResult | None:
  185. """
  186. Ruft Wetterdaten ab und speichert sie im Cache.
  187. Args:
  188. lat: Breitengrad
  189. lon: Laengengrad
  190. location_name: Stadtname fuer Anzeige und Cache-Key
  191. Returns:
  192. WeatherResult oder None bei Fehler
  193. """
  194. if not self._provider or not self._cache:
  195. return None
  196. try:
  197. result = await self._provider.get_weather(lat, lon, location_name)
  198. if result:
  199. key = location_name.lower().strip()
  200. self._cache.set(key, result)
  201. return result
  202. except Exception as e:
  203. perror(f"WeatherPlugin: Abruf-Fehler: {e}")
  204. return None
  205. async def _get_weather_for_city(
  206. self, city: str | None
  207. ) -> WeatherResult | None:
  208. """
  209. Liefert Wetterdaten fuer eine Stadt (Cache oder frischer Abruf).
  210. Args:
  211. city: Stadtname oder None fuer Default-Standort
  212. Returns:
  213. WeatherResult oder None bei Fehler
  214. """
  215. if not self._provider or not self._cache:
  216. return None
  217. # Default-Standort verwenden
  218. if not city:
  219. city = self.get_config_value("default_location.city", "Sinsheim")
  220. lat = self.get_config_value("default_location.latitude", 49.2547)
  221. lon = self.get_config_value("default_location.longitude", 8.8795)
  222. else:
  223. lat = None
  224. lon = None
  225. key = city.lower().strip()
  226. # Cache pruefen
  227. cached = self._cache.get(key)
  228. if cached:
  229. pdebug(f"WeatherPlugin: Cache-Treffer fuer '{city}'")
  230. return cached
  231. # Geocoding fuer fremde Staedte
  232. if lat is None or lon is None:
  233. geo = await self._provider.geocode(city)
  234. if not geo:
  235. return None
  236. lat, lon, city = geo
  237. # Frischer Abruf
  238. return await self._fetch_weather(lat, lon, city)
  239. # ===== Intent-Handler =====
  240. @intent("current_weather", description="Aktuelles Wetter abfragen")
  241. @pattern("(wie ist|wie wird) [denn] {das|} wetter [draussen|gerade|heute]")
  242. @pattern("wetter [in] {city}")
  243. @pattern("(wie warm|wie kalt) ist [es] [draussen|gerade]")
  244. @pattern("temperatur [draussen]")
  245. @example("Wie ist das Wetter?", "Wie ist das Wetter in Berlin?")
  246. @example("Wie warm ist es?", "Wie kalt ist es draussen?")
  247. @example("Wetter in Hamburg", "Temperatur draussen")
  248. async def handle_current_weather(
  249. self, data: IntentReceivedData, city: str = "",
  250. ) -> IntentResult:
  251. """Handler fuer den Intent 'current_weather'."""
  252. city = data.get_slot("city")
  253. result = await self._get_weather_for_city(city)
  254. if not result:
  255. location = city or self.get_config_value(
  256. "default_location.city", "Sinsheim"
  257. )
  258. return IntentResult.failure(
  259. f"Wetterdaten fuer '{location}' nicht verfuegbar",
  260. response_text=f"Die Wetterdaten fuer {location} konnten "
  261. f"leider nicht abgerufen werden.",
  262. )
  263. response = format_weather_for_tts(result.current)
  264. return IntentResult.success_with_response(response)
  265. @intent("weather_forecast", description="Wettervorhersage fuer einen bestimmten Tag")
  266. @pattern("wie wird {das|} wetter {date}")
  267. @pattern("wie wird [das] wetter [am] {date} [in] {city}")
  268. @pattern("wetter {date}")
  269. @pattern("vorhersage [fuer] {date}")
  270. @pattern("brauche ich {date} [einen] regenschirm")
  271. @example("Wie wird das Wetter morgen?", "Wetter morgen")
  272. @example("Wie wird das Wetter am Freitag in Muenchen?")
  273. @example("Vorhersage fuer Freitag")
  274. async def handle_weather_forecast(
  275. self, data: IntentReceivedData, city: str = "", date: str = "",
  276. ) -> IntentResult:
  277. """Handler fuer den Intent 'weather_forecast'."""
  278. city = data.get_slot("city")
  279. # ResolvedEntity direkt nutzen (Entity-System liefert date-Objekt)
  280. resolved_date = data.get_resolved_slot("date")
  281. date_str = data.get_slot("date", "morgen")
  282. result = await self._get_weather_for_city(city)
  283. if not result:
  284. location = city or self.get_config_value(
  285. "default_location.city", "Sinsheim"
  286. )
  287. return IntentResult.failure(
  288. f"Wetterdaten fuer '{location}' nicht verfuegbar",
  289. response_text=f"Die Wetterdaten fuer {location} konnten "
  290. f"leider nicht abgerufen werden.",
  291. )
  292. # Datum: ResolvedEntity hat bereits ein date-Objekt
  293. target_date = None
  294. if resolved_date is not None:
  295. from datetime import date as date_type
  296. if isinstance(resolved_date.value, date_type):
  297. target_date = resolved_date.value
  298. # Fallback: String-Parsing
  299. if target_date is None and date_str:
  300. from plugins.calendar.utils import parse_date_string
  301. target_date = parse_date_string(date_str)
  302. if not target_date:
  303. target_date = date.today() + timedelta(days=1)
  304. # Passenden Forecast-Tag finden
  305. forecast_day = None
  306. for day in result.forecast:
  307. if day.date == target_date:
  308. forecast_day = day
  309. break
  310. if not forecast_day:
  311. return IntentResult.failure(
  312. f"Keine Vorhersage fuer {date_str}",
  313. response_text=f"Fuer {date_str} liegt leider keine "
  314. f"Vorhersage vor.",
  315. )
  316. # Datum-Label erzeugen
  317. date_label = self._get_date_label(target_date)
  318. location = result.current.location
  319. response = f"{location}, {format_forecast_for_tts(forecast_day, date_label)}"
  320. return IntentResult.success_with_response(response)
  321. @intent("weather_week", description="Wettervorhersage fuer die naechsten Tage")
  322. @pattern("wie wird {das|} wetter [diese|die] (woche|naechsten tage)")
  323. @pattern("(wettervorhersage|wochenwetter)")
  324. @pattern("wetter fuer [die] woche")
  325. @example("Wie wird das Wetter diese Woche?", "Wettervorhersage")
  326. @example("Wochenwetter", "Wie wird das Wetter die naechsten Tage?")
  327. async def handle_weather_week(
  328. self, data: IntentReceivedData, city: str = "",
  329. ) -> IntentResult:
  330. """Handler fuer den Intent 'weather_week'."""
  331. city = data.get_slot("city")
  332. result = await self._get_weather_for_city(city)
  333. if not result:
  334. location = city or self.get_config_value(
  335. "default_location.city", "Sinsheim"
  336. )
  337. return IntentResult.failure(
  338. f"Wetterdaten fuer '{location}' nicht verfuegbar",
  339. response_text=f"Die Wetterdaten fuer {location} konnten "
  340. f"leider nicht abgerufen werden.",
  341. )
  342. # Naechste 3-5 Tage (ab morgen)
  343. tomorrow = date.today() + timedelta(days=1)
  344. upcoming = [
  345. day for day in result.forecast
  346. if tomorrow <= day.date <= tomorrow + timedelta(days=4)
  347. ]
  348. if not upcoming:
  349. return IntentResult.failure(
  350. "Keine Wochenvorhersage verfuegbar",
  351. response_text="Fuer die naechsten Tage liegt leider keine "
  352. "Vorhersage vor.",
  353. )
  354. location = result.current.location
  355. parts = [f"Das Wetter in {location} fuer die naechsten Tage."]
  356. for day in upcoming:
  357. label = self._get_date_label(day.date)
  358. parts.append(format_forecast_for_tts(day, label))
  359. response = " ".join(parts)
  360. return IntentResult.success_with_response(response)
  361. # ===== Hilfsfunktionen =====
  362. @staticmethod
  363. def _get_date_label(target: date) -> str:
  364. """
  365. Erzeugt ein lesbares Datum-Label fuer TTS.
  366. Args:
  367. target: Zieldatum
  368. Returns:
  369. Label wie "Heute", "Morgen", "Am Freitag"
  370. """
  371. today = date.today()
  372. diff = (target - today).days
  373. if diff == 0:
  374. return "Heute"
  375. if diff == 1:
  376. return "Morgen"
  377. if diff == 2:
  378. return "Uebermorgen"
  379. if 0 < diff <= 6:
  380. return f"Am {_WEEKDAY_NAMES[target.weekday()]}"
  381. return f"Am {target.day}."