# -*- coding: utf-8 -*- """ Weather-Plugin fuer Trixy. Stellt aktuelle Wetterdaten und Vorhersagen per Sprachabfrage bereit. Unterstuetzt drei Provider: Open-Meteo, OpenWeatherMap, Bright Sky (DWD). Wetterdaten werden im Cache gehalten und per Scheduler periodisch aktualisiert. """ from datetime import date, timedelta from pathlib import Path from typing import Any from plugins.weather.cache import WeatherCache from plugins.weather.models import WeatherResult from plugins.weather.providers.base import WeatherProvider from plugins.weather.providers.brightsky import BrightSkyProvider from plugins.weather.providers.open_meteo import OpenMeteoProvider from plugins.weather.providers.openweathermap import OpenWeatherMapProvider from plugins.weather.utils import ( format_forecast_for_tts, format_weather_for_tts, ) from trixy_core.nlp.handler import IntentReceivedData, IntentResult from trixy_core.nlp.decorators import intent, pattern, example from trixy_core.nlp.intent_registry import IntentRegistry from trixy_core.plugins.trixy_plugin import TrixyPlugin from trixy_core.utils.debug import pdebug, perror, pinfo, pwarn # Wochentag-Namen fuer TTS _WEEKDAY_NAMES = [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag", ] # Provider-Zuordnung PROVIDER_TYPES: dict[str, type[WeatherProvider]] = { "open_meteo": OpenMeteoProvider, "openweathermap": OpenWeatherMapProvider, "brightsky": BrightSkyProvider, } class WeatherPlugin(TrixyPlugin): """ Wetter-Plugin mit Cache und periodischer Aktualisierung. Unterstuetzt aktuelle Abfragen, Tages- und Wochenvorhersagen fuer beliebige Staedte. """ NAME = "weather" VERSION = "1.0.0" DESCRIPTION = "Wetterabfrage per Sprache mit Cache und Vorhersage" AUTHOR = "Trixy" def __init__( self, application: Any, plugin_path: Path, config: dict[str, Any] | None = None, ) -> None: super().__init__(application, plugin_path, config) self._registry = IntentRegistry.get_instance() self._provider: WeatherProvider | None = None self._cache: WeatherCache | None = None self._scheduler_job_id: str | None = None # ===== Lifecycle ===== async def on_load(self) -> None: """Plugin laden: Provider, Cache, Scheduler, Intents initialisieren.""" pinfo(f"WeatherPlugin: Lade Plugin v{self.VERSION}") # 1. Provider initialisieren self._init_provider() if not self._provider: perror("WeatherPlugin: Kein Provider verfuegbar") return # 2. Cache erstellen ttl = self.get_config_value("cache_ttl_hours", 5) self._cache = WeatherCache(ttl_hours=ttl) # 3. Initialer Fetch fuer Default-Standort await self._update_default_location() # 4. Scheduler-Job registrieren self._register_scheduler_job() # 5. Intents werden automatisch vom PluginManager registriert # (via @intent/@pattern/@example Dekoratoren auf den Handler-Methoden) pinfo( f"WeatherPlugin: Geladen (Provider: {self._provider.name})" ) async def on_unload(self) -> None: """Plugin entladen: Scheduler und Intents aufraemen.""" # 1. Scheduler-Job entfernen self._remove_scheduler_job() # 2. Intents deregistrieren self._registry.unregister_plugin(self.NAME) # 3. Cache leeren if self._cache: self._cache.clear() self._provider = None self._cache = None pdebug("WeatherPlugin: Entladen") # ===== Provider-Initialisierung ===== def _get_http_client(self): """Gibt den HTTP-Client des OnlineService zurueck (falls verfuegbar).""" try: online = self.application.services.get_service("OnlineService") if online and hasattr(online, "http"): return online.http except Exception: pass return None def _init_provider(self) -> None: """ Initialisiert den konfigurierten Provider. Versucht den Default-Provider aus der Config. Bei Fehlschlag wird auf den naechsten verfuegbaren Provider zurueckgegriffen. Der OnlineService HTTP-Client wird fuer Offline-Cache durchgereicht. """ default_provider = self.get_config_value("provider", "open_meteo") providers_config = self.get_config_value("providers", {}) http = self._get_http_client() if http: pdebug("WeatherPlugin: OnlineService HTTP-Client verfuegbar (Offline-Cache aktiv)") # Reihenfolge: konfigurierter Default zuerst, dann alle anderen order = [default_provider] + [ k for k in providers_config if k != default_provider ] for name in order: prov_config = providers_config.get(name, {}) if not prov_config.get("enabled", True): continue provider_class = PROVIDER_TYPES.get(name) if not provider_class: pwarn(f"WeatherPlugin: Unbekannter Provider '{name}'") continue # OpenWeatherMap braucht API-Key if name == "openweathermap" and not prov_config.get("api_key"): pdebug("WeatherPlugin: OpenWeatherMap uebersprungen (kein API-Key)") continue try: self._provider = provider_class(config=prov_config, http=http) pdebug(f"WeatherPlugin: Provider '{name}' initialisiert") return except Exception as e: perror(f"WeatherPlugin: Provider '{name}' Fehler: {e}") pwarn("WeatherPlugin: Kein Provider konnte initialisiert werden") # ===== Scheduler ===== def _register_scheduler_job(self) -> None: """Registriert den periodischen Wetter-Update-Job.""" scheduler = self.application.services.get_service("SchedulerService") if not scheduler: pwarn("WeatherPlugin: SchedulerService nicht verfuegbar") return from trixy_core.scheduler.action.callback import CallbackAction from trixy_core.scheduler.trigger.interval import IntervalTrigger interval_hours = self.get_config_value("update_interval_hours", 1) try: job = scheduler.create_job( trigger=IntervalTrigger(hours=interval_hours), actions=CallbackAction(self._update_default_location), job_id="weather_update", save=False, ) self._scheduler_job_id = job.job_id pinfo( f"WeatherPlugin: Scheduler-Job registriert " f"(alle {interval_hours} Stunden)" ) except Exception as e: perror(f"WeatherPlugin: Scheduler-Job Fehler: {e}") def _remove_scheduler_job(self) -> None: """Entfernt den Scheduler-Job.""" if not self._scheduler_job_id: return scheduler = self.application.services.get_service("SchedulerService") if scheduler: try: scheduler.remove_job(self._scheduler_job_id, delete_file=False) except Exception: pass self._scheduler_job_id = None # ===== Wetter-Abruf ===== async def _update_default_location(self) -> None: """Aktualisiert den Cache fuer den Default-Standort.""" city = self.get_config_value("default_location.city", "Sinsheim") lat = self.get_config_value("default_location.latitude", 49.2547) lon = self.get_config_value("default_location.longitude", 8.8795) result = await self._fetch_weather(lat, lon, city) if result: pinfo(f"WeatherPlugin: Default-Standort '{city}' aktualisiert") else: pwarn(f"WeatherPlugin: Default-Standort '{city}' konnte nicht aktualisiert werden") async def _fetch_weather( self, lat: float, lon: float, location_name: str ) -> WeatherResult | None: """ Ruft Wetterdaten ab und speichert sie im Cache. Args: lat: Breitengrad lon: Laengengrad location_name: Stadtname fuer Anzeige und Cache-Key Returns: WeatherResult oder None bei Fehler """ if not self._provider or not self._cache: return None try: result = await self._provider.get_weather(lat, lon, location_name) if result: key = location_name.lower().strip() self._cache.set(key, result) return result except Exception as e: perror(f"WeatherPlugin: Abruf-Fehler: {e}") return None async def _get_weather_for_city( self, city: str | None ) -> WeatherResult | None: """ Liefert Wetterdaten fuer eine Stadt (Cache oder frischer Abruf). Args: city: Stadtname oder None fuer Default-Standort Returns: WeatherResult oder None bei Fehler """ if not self._provider or not self._cache: return None # Default-Standort verwenden if not city: city = self.get_config_value("default_location.city", "Sinsheim") lat = self.get_config_value("default_location.latitude", 49.2547) lon = self.get_config_value("default_location.longitude", 8.8795) else: lat = None lon = None key = city.lower().strip() # Cache pruefen cached = self._cache.get(key) if cached: pdebug(f"WeatherPlugin: Cache-Treffer fuer '{city}'") return cached # Geocoding fuer fremde Staedte if lat is None or lon is None: geo = await self._provider.geocode(city) if not geo: return None lat, lon, city = geo # Frischer Abruf return await self._fetch_weather(lat, lon, city) # ===== Intent-Handler ===== @intent("current_weather", description="Aktuelles Wetter abfragen") @pattern("(wie ist|wie wird) [denn] {das|} wetter [draussen|gerade|heute]") @pattern("wetter [in] {city}") @pattern("(wie warm|wie kalt) ist [es] [draussen|gerade]") @pattern("temperatur [draussen]") @example("Wie ist das Wetter?", "Wie ist das Wetter in Berlin?") @example("Wie warm ist es?", "Wie kalt ist es draussen?") @example("Wetter in Hamburg", "Temperatur draussen") async def handle_current_weather( self, data: IntentReceivedData, city: str = "", ) -> IntentResult: """Handler fuer den Intent 'current_weather'.""" city = data.get_slot("city") result = await self._get_weather_for_city(city) if not result: location = city or self.get_config_value( "default_location.city", "Sinsheim" ) return IntentResult.failure( f"Wetterdaten fuer '{location}' nicht verfuegbar", response_text=f"Die Wetterdaten fuer {location} konnten " f"leider nicht abgerufen werden.", ) response = format_weather_for_tts(result.current) return IntentResult.success_with_response(response) @intent("weather_forecast", description="Wettervorhersage fuer einen bestimmten Tag") @pattern("wie wird {das|} wetter {date}") @pattern("wie wird [das] wetter [am] {date} [in] {city}") @pattern("wetter {date}") @pattern("vorhersage [fuer] {date}") @pattern("brauche ich {date} [einen] regenschirm") @example("Wie wird das Wetter morgen?", "Wetter morgen") @example("Wie wird das Wetter am Freitag in Muenchen?") @example("Vorhersage fuer Freitag") async def handle_weather_forecast( self, data: IntentReceivedData, city: str = "", date: str = "", ) -> IntentResult: """Handler fuer den Intent 'weather_forecast'.""" city = data.get_slot("city") # ResolvedEntity direkt nutzen (Entity-System liefert date-Objekt) resolved_date = data.get_resolved_slot("date") date_str = data.get_slot("date", "morgen") result = await self._get_weather_for_city(city) if not result: location = city or self.get_config_value( "default_location.city", "Sinsheim" ) return IntentResult.failure( f"Wetterdaten fuer '{location}' nicht verfuegbar", response_text=f"Die Wetterdaten fuer {location} konnten " f"leider nicht abgerufen werden.", ) # Datum: ResolvedEntity hat bereits ein date-Objekt target_date = None if resolved_date is not None: from datetime import date as date_type if isinstance(resolved_date.value, date_type): target_date = resolved_date.value # Fallback: String-Parsing if target_date is None and date_str: from plugins.calendar.utils import parse_date_string target_date = parse_date_string(date_str) if not target_date: target_date = date.today() + timedelta(days=1) # Passenden Forecast-Tag finden forecast_day = None for day in result.forecast: if day.date == target_date: forecast_day = day break if not forecast_day: return IntentResult.failure( f"Keine Vorhersage fuer {date_str}", response_text=f"Fuer {date_str} liegt leider keine " f"Vorhersage vor.", ) # Datum-Label erzeugen date_label = self._get_date_label(target_date) location = result.current.location response = f"{location}, {format_forecast_for_tts(forecast_day, date_label)}" return IntentResult.success_with_response(response) @intent("weather_week", description="Wettervorhersage fuer die naechsten Tage") @pattern("wie wird {das|} wetter [diese|die] (woche|naechsten tage)") @pattern("(wettervorhersage|wochenwetter)") @pattern("wetter fuer [die] woche") @example("Wie wird das Wetter diese Woche?", "Wettervorhersage") @example("Wochenwetter", "Wie wird das Wetter die naechsten Tage?") async def handle_weather_week( self, data: IntentReceivedData, city: str = "", ) -> IntentResult: """Handler fuer den Intent 'weather_week'.""" city = data.get_slot("city") result = await self._get_weather_for_city(city) if not result: location = city or self.get_config_value( "default_location.city", "Sinsheim" ) return IntentResult.failure( f"Wetterdaten fuer '{location}' nicht verfuegbar", response_text=f"Die Wetterdaten fuer {location} konnten " f"leider nicht abgerufen werden.", ) # Naechste 3-5 Tage (ab morgen) tomorrow = date.today() + timedelta(days=1) upcoming = [ day for day in result.forecast if tomorrow <= day.date <= tomorrow + timedelta(days=4) ] if not upcoming: return IntentResult.failure( "Keine Wochenvorhersage verfuegbar", response_text="Fuer die naechsten Tage liegt leider keine " "Vorhersage vor.", ) location = result.current.location parts = [f"Das Wetter in {location} fuer die naechsten Tage."] for day in upcoming: label = self._get_date_label(day.date) parts.append(format_forecast_for_tts(day, label)) response = " ".join(parts) return IntentResult.success_with_response(response) # ===== Hilfsfunktionen ===== @staticmethod def _get_date_label(target: date) -> str: """ Erzeugt ein lesbares Datum-Label fuer TTS. Args: target: Zieldatum Returns: Label wie "Heute", "Morgen", "Am Freitag" """ today = date.today() diff = (target - today).days if diff == 0: return "Heute" if diff == 1: return "Morgen" if diff == 2: return "Uebermorgen" if 0 < diff <= 6: return f"Am {_WEEKDAY_NAMES[target.weekday()]}" return f"Am {target.day}."