| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355 |
- # -*- coding: utf-8 -*-
- """
- Trixy Homematic Smart Home Plugin.
- Sprachsteuerung fuer Homematic CCU via XML-API:
- - Licht ein/ausschalten (pro Raum oder gezielt)
- - LED-Stripe Farbe aendern / dimmen
- - Heizungs-Temperatur setzen/abfragen (allgemein oder pro Raum)
- - Fenster/Tuer-Status abfragen
- - Bewegungsmelder-Status abfragen
- - Licht-Status abfragen
- - CCU-Programme ausfuehren
- - Systemvariablen abfragen/setzen
- - Allgemeiner Smart Home Status
- Verwendet DeviceManager fuer praezise Geraetetyp-Klassifizierung:
- Steckdosen werden nicht als Licht geschaltet.
- """
- from __future__ import annotations
- import asyncio
- import datetime
- import time
- from typing import Any
- from trixy_core.plugins.trixy_plugin import TrixyPlugin
- from trixy_core.events.decorators import TrixyEvent
- from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
- from trixy_core.nlp.decorators import intent
- from trixy_core.nlp.handler import IntentResult, IntentReceivedData
- from plugins.homematic.api import HomematicApi
- from plugins.homematic.devices import DeviceManager
- # Farb-Mapping fuer LED-Stripes (HM-LC-RGBW-WM: COLOR 0-200 Hue-Kreis)
- COLOR_MAP: dict[str, int] = {
- "rot": 0,
- "orange": 20,
- "gelb": 40,
- "gruen": 60,
- "grün": 60,
- "tuerkis": 80,
- "türkis": 80,
- "cyan": 80,
- "blau": 120,
- "lila": 140,
- "violett": 140,
- "pink": 160,
- "rosa": 160,
- "magenta": 170,
- "weiss": 200,
- "weiß": 200,
- }
- class HomematicPlugin(TrixyPlugin):
- """Homematic Smart Home Steuerung."""
- NAME = "homematic"
- VERSION = "1.0.0"
- DESCRIPTION = "Homematic Smart Home Steuerung via XML-API"
- AUTHOR = "Trixy"
- def __init__(self, **kwargs) -> None:
- super().__init__(**kwargs)
- self._api: HomematicApi | None = None
- self._devices: DeviceManager | None = None
- self._monitor = None # HomematicMonitor (lazy import)
- # Programm/Sysvar Caches (nicht im DeviceManager)
- self._programs: dict[str, Any] = {}
- self._sysvars: dict[str, Any] = {}
- async def on_load(self) -> None:
- """Initialisiert die API-Verbindung und laedt Geraetedaten."""
- hm_cfg = self.config.get("homematic", {})
- base_url = hm_cfg.get("url", "http://homematic-raspi")
- session_id = hm_cfg.get("session_id", "")
- username = hm_cfg.get("username", "")
- password = hm_cfg.get("password", "")
- timeout = hm_cfg.get("timeout", 30)
- cache_ttl = hm_cfg.get("cache_ttl", 300)
- room_aliases = self.config.get("room_aliases", {})
- satellite_rooms = self.config.get("satellite_rooms", {})
- # _comment Eintraege entfernen
- room_aliases.pop("_comment", None)
- satellite_rooms.pop("_comment", None)
- self._api = HomematicApi(
- base_url=base_url,
- session_id=session_id,
- username=username,
- password=password,
- timeout=timeout,
- )
- # Token erforderlich fuer XML-API
- if not session_id:
- if username and password:
- pwarn("[Homematic] Kein XML-API Token konfiguriert. "
- "Login-Daten sind nur fuer die Pruefung im ConfigTool nutzbar. "
- "Bitte Token im CCU WebUI unter Einstellungen > Sicherheit erstellen "
- "und als session_id eintragen.")
- else:
- pwarn("[Homematic] Weder Token noch Login konfiguriert — Plugin inaktiv")
- return
- self._devices = DeviceManager(self._api, room_aliases, satellite_rooms)
- self._devices._cache_ttl = cache_ttl
- # Daten laden
- try:
- await self._devices.refresh()
- await self._refresh_programs_sysvars()
- self._register_room_entities()
- pinfo(f"[Homematic] Plugin geladen — "
- f"{len(self._devices._devices)} Geraete, "
- f"{len(self._devices._rooms)} Raeume, "
- f"{len(self._programs)} Programme")
- except Exception as e:
- perror(f"[Homematic] Initialisierung fehlgeschlagen: {e}")
- # Monitor starten
- self._start_monitor()
- async def on_unload(self) -> None:
- """Schliesst die API-Verbindung."""
- if self._monitor:
- self._monitor.stop()
- self._monitor = None
- if self._api:
- await self._api.close()
- self._api = None
- self._devices = None
- def _start_monitor(self) -> None:
- """Initialisiert und startet den Geraete-Monitor."""
- if not self._api or not self._api.session_id:
- return
- from plugins.homematic.monitor import HomematicMonitor
- hm_cfg = self.config.get("homematic", {})
- self._monitor = HomematicMonitor(
- api=self._api,
- plugin_path=self.plugin_path,
- event_callback=self._on_monitor_warning,
- )
- self._monitor.interval = hm_cfg.get("monitor_interval", 120)
- self._monitor.warn_battery_low = hm_cfg.get("warn_battery_low", True)
- self._monitor.warn_connection_changed = hm_cfg.get("warn_connection_changed", True)
- self._monitor.warn_status_changed = hm_cfg.get("warn_status_changed", False)
- self._monitor.start()
- async def _on_monitor_warning(self, event_name: str, data: dict) -> None:
- """Callback vom Monitor — gibt Warnungen per TTS aus und emittiert Event."""
- text = data.get("text", "")
- if not text:
- return
- # Event emittieren
- if hasattr(self.application, "events"):
- await self.application.events.emit(event_name, data)
- # TTS auf allen verbundenen Satellites
- if not hasattr(self.application, "satellites"):
- return
- connected = self.application.satellites.get_connected()
- if not connected:
- pdebug("[Homematic] Keine Satellites verbunden fuer TTS-Warnung")
- return
- # TTS-Request per emit() — kompatibel mit Piper-Plugin
- # (speak() nutzt trigger() mit EventData, Piper liest aus metadata via .get())
- for sat in connected:
- try:
- pdebug(f"[Homematic] Sende TTS-Warnung an {sat.alias or sat.id}...")
- await self.application.events.emit("tts_request", {
- "text": text,
- "satellite_id": sat.id,
- "source": "homematic_monitor",
- })
- except Exception as e:
- pwarn(f"[Homematic] TTS-Warnung an {sat.alias or sat.id} Fehler: {e}")
- @TrixyEvent(["plugin_settings_saved"])
- async def on_settings_saved(self, event_name: str, event_data: dict) -> None:
- """Reagiert auf Config-Aenderungen vom ConfigTool."""
- plugin_name = event_data.get("plugin_name", "")
- if plugin_name != self.NAME:
- return
- pdebug("[Homematic] Config wurde geaendert, lade neu...")
- self.reload_config()
- # Monitor mit neuer Config aktualisieren
- if self._monitor:
- hm_cfg = self.config.get("homematic", {})
- self._monitor.interval = hm_cfg.get("monitor_interval", 120)
- self._monitor.warn_battery_low = hm_cfg.get("warn_battery_low", True)
- self._monitor.warn_connection_changed = hm_cfg.get("warn_connection_changed", True)
- self._monitor.warn_status_changed = hm_cfg.get("warn_status_changed", False)
- self._monitor.restart()
- # DeviceManager Aliases aktualisieren
- if self._devices:
- room_aliases = self.config.get("room_aliases", {})
- satellite_rooms = self.config.get("satellite_rooms", {})
- room_aliases.pop("_comment", None)
- satellite_rooms.pop("_comment", None)
- self._devices._room_aliases = room_aliases
- self._devices._satellite_rooms = satellite_rooms
- async def handle_config_action(self, action: str, params: dict) -> dict:
- """Verarbeitet Aktionen vom ConfigTool.
- Args:
- action: Aktions-Name (z.B. "test_connection")
- params: Optionale Parameter
- Returns:
- Dict mit success/message/error
- """
- if action == "test_connection":
- return await self._test_connection(params)
- elif action == "get_rooms_data":
- return await self._get_rooms_data()
- elif action == "save_room_config":
- return await self._save_room_config(params)
- elif action == "get_devices_data":
- return await self._get_devices_data()
- elif action == "get_status_data":
- return await self._get_status_data()
- return {"error": f"Unbekannte Aktion: {action}"}
- async def _get_rooms_data(self) -> dict:
- """Liefert Raumliste, Aliases und Satellites fuer den ConfigTool Raeume-Tab."""
- # Temporaere API falls Plugin nicht voll initialisiert
- api = self._api
- temp_api = None
- if not api or not api.session_id:
- hm_cfg = self.config.get("homematic", {})
- sid = hm_cfg.get("session_id", "")
- url = hm_cfg.get("url", "")
- if not sid or not url:
- return {"error": "Kein Token konfiguriert — bitte unter F3 Einstellungen konfigurieren"}
- temp_api = HomematicApi(base_url=url, session_id=sid, timeout=10)
- api = temp_api
- try:
- if self._devices and not temp_api:
- await self._devices.ensure_cache()
- rooms = await api.get_room_list()
- if not rooms:
- if temp_api:
- await temp_api.close()
- return {"error": "Keine Raeume von der CCU erhalten — Token/Session pruefen"}
- room_aliases = self.config.get("room_aliases", {})
- satellite_rooms = self.config.get("satellite_rooms", {})
- # Satellite-Liste vom Server holen
- satellites: list[dict[str, str]] = []
- if hasattr(self.application, "satellites"):
- for sat in self.application.satellites.get_all():
- satellites.append({
- "id": sat.id,
- "alias": sat.alias,
- "room_id": sat.room_id,
- })
- # Inverse Mappings: Raum → Aliases, Raum → Satellite
- room_to_aliases: dict[str, list[str]] = {}
- for alias, target in room_aliases.items():
- if alias.startswith("_"):
- continue
- room_to_aliases.setdefault(target, []).append(alias)
- room_to_satellite: dict[str, str] = {}
- for sat_key, hm_room in satellite_rooms.items():
- room_to_satellite[hm_room] = sat_key
- rooms_data: list[dict] = []
- for room in rooms:
- room_lower = room.name.lower()
- rooms_data.append({
- "name": room.name,
- "ise_id": room.ise_id,
- "aliases": room_to_aliases.get(room_lower, []),
- "satellite": room_to_satellite.get(room_lower, ""),
- "channel_count": len(room.channel_ids),
- })
- if temp_api:
- await temp_api.close()
- return {
- "success": True,
- "rooms": rooms_data,
- "satellites": satellites,
- }
- except Exception as e:
- if temp_api:
- await temp_api.close()
- return {"error": f"Fehler beim Laden: {e}"}
- async def _save_room_config(self, params: dict) -> dict:
- """Speichert Raum-Konfiguration (Aliases, Satellite-Zuordnung)."""
- room_name = params.get("room_name", "").lower()
- aliases_str = params.get("aliases", "")
- satellite = params.get("satellite", "")
- if not room_name:
- return {"error": "Kein Raum angegeben"}
- # Aliases aktualisieren
- room_aliases = self.config.get("room_aliases", {})
- # Alte Aliases fuer diesen Raum entfernen
- old_aliases = [k for k, v in room_aliases.items() if v == room_name and not k.startswith("_")]
- for old in old_aliases:
- del room_aliases[old]
- # Neue Aliases setzen
- if aliases_str.strip():
- for alias in aliases_str.split(","):
- alias = alias.strip().lower()
- if alias and alias != room_name:
- room_aliases[alias] = room_name
- self.config["room_aliases"] = room_aliases
- # Satellite-Zuordnung aktualisieren
- satellite_rooms = self.config.get("satellite_rooms", {})
- # Alte Zuordnung fuer diesen Raum entfernen
- old_sats = [k for k, v in satellite_rooms.items() if v == room_name]
- for old in old_sats:
- del satellite_rooms[old]
- # Neue Zuordnung setzen
- if satellite:
- satellite_rooms[satellite] = room_name
- self.config["satellite_rooms"] = satellite_rooms
- # Config speichern
- self.save_config()
- # DeviceManager aktualisieren
- if self._devices:
- self._devices._room_aliases = room_aliases
- self._devices._satellite_rooms = satellite_rooms
- return {"success": True, "message": f"Raum '{room_name}' gespeichert"}
- async def _get_devices_data(self) -> dict:
- """Liefert Geraete-Uebersicht fuer das ConfigTool."""
- api = self._api
- temp_api = None
- if not api or not api.session_id:
- hm_cfg = self.config.get("homematic", {})
- sid = hm_cfg.get("session_id", "")
- url = hm_cfg.get("url", "")
- if not sid or not url:
- return {"error": "Kein Token konfiguriert"}
- temp_api = HomematicApi(base_url=url, session_id=sid, timeout=10)
- api = temp_api
- try:
- from plugins.homematic.device_types import classify_device
- devices = await api.get_device_list()
- rooms = await api.get_room_list()
- # Channel → Raum Mapping
- ch_to_room: dict[str, str] = {}
- for room in rooms:
- for ch_id in room.channel_ids:
- ch_to_room[ch_id] = room.name
- devices_data: list[dict] = []
- for dev in sorted(devices, key=lambda d: d.name):
- info = classify_device(dev.device_type)
- # Raum bestimmen (erster Channel der einem Raum zugeordnet ist)
- dev_room = ""
- for ch in dev.channels.values():
- if ch.ise_id in ch_to_room:
- dev_room = ch_to_room[ch.ise_id]
- break
- ch_names = [ch.name for ch in dev.channels.values()
- if ch.visible == "true"]
- devices_data.append({
- "label": f"{dev.name} [{info.category.value}]"
- + (f" — {dev_room}" if dev_room else ""),
- "device_name": dev.name,
- "device_type": dev.device_type,
- "category": info.category.value,
- "room": dev_room or "—",
- "channels": ", ".join(ch_names) if ch_names else "—",
- })
- if temp_api:
- await temp_api.close()
- return {"success": True, "devices": devices_data}
- except Exception as e:
- if temp_api:
- await temp_api.close()
- return {"error": f"Fehler: {e}"}
- async def _get_status_data(self) -> dict:
- """Liefert Live-Status als Tabelle fuer das ConfigTool.
- Jede Zeile: name, value, connection (markup), battery (markup)
- """
- api = self._api
- temp_api = None
- if not api or not api.session_id:
- hm_cfg = self.config.get("homematic", {})
- sid = hm_cfg.get("session_id", "")
- url = hm_cfg.get("url", "")
- if not sid or not url:
- return {"error": "Kein Token konfiguriert"}
- temp_api = HomematicApi(base_url=url, session_id=sid, timeout=10)
- api = temp_api
- try:
- devices = await api.get_state_list()
- rows: list[dict[str, str]] = []
- for dev in sorted(devices, key=lambda d: d.name):
- # UNREACH und LOWBAT aus Channel 0
- unreach = False
- lowbat = False
- battery_voltage = ""
- for ch in dev.channels.values():
- ur = ch.datapoints.get("UNREACH")
- if ur:
- unreach = ur.value_bool
- lb = ch.datapoints.get("LOWBAT")
- if lb and lb.value_bool:
- lowbat = True
- bs = ch.datapoints.get("BATTERY_STATE")
- if bs and bs.value:
- battery_voltage = bs.value
- # Wert bestimmen (je nach Geraetetyp)
- value = ""
- # LED-RGBW: Farbe + Level sammeln
- led_level: float | None = None
- led_color: int | None = None
- for ch in dev.channels.values():
- color_dp = ch.datapoints.get("COLOR")
- if color_dp and color_dp.value:
- try:
- led_color = int(float(color_dp.value))
- except (ValueError, TypeError):
- pass
- level_dp = ch.datapoints.get("LEVEL")
- if level_dp and level_dp.value:
- name_l = ch.name.lower()
- if "dimm" in name_l or "level" in name_l or ch.index == "1":
- led_level = level_dp.value_float
- for ch in dev.channels.values():
- actual = ch.datapoints.get("ACTUAL_TEMPERATURE")
- target = ch.datapoints.get("SET_TEMPERATURE")
- state = ch.datapoints.get("STATE")
- level = ch.datapoints.get("LEVEL")
- if actual and target:
- value = f"{actual.value_float:.1f}°C (Soll: {target.value_float:.1f}°C)"
- break
- elif state and ch.visible == "true":
- if state.value_bool:
- value = "[bold]AN[/bold]" if "schalter" in ch.name.lower() or "licht" in ch.name.lower() else "OFFEN"
- else:
- value = "aus" if "schalter" in ch.name.lower() or "licht" in ch.name.lower() else "geschlossen"
- break
- elif level and ch.visible == "true":
- pct = level.value_float * 100
- value = f"{pct:.0f}%"
- break
- # LED-RGBW: Farbblock anhaengen
- if led_color is not None:
- import colorsys
- if led_color >= 200:
- r, g, b = 255, 255, 255
- else:
- hue = led_color / 200.0
- rf, gf, bf = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
- r, g, b = int(rf * 255), int(gf * 255), int(bf * 255)
- pct = int((led_level or 0) * 100)
- color_block = f"[rgb({r},{g},{b})]████[/rgb({r},{g},{b})]"
- value = f"{pct}% {color_block}"
- # Connection (farbig)
- if unreach:
- connection = "[red]offline[/red]"
- else:
- connection = "[green]online[/green]"
- # Batterie (farbig)
- battery = ""
- if battery_voltage:
- try:
- volts = float(battery_voltage)
- # HM-CC-RT-DN: 2x AA, 3.0V voll, ~1.8V leer
- if volts <= 1.8:
- battery = f"[red]{volts:.1f}V LEER[/red]"
- elif volts <= 2.2:
- battery = f"[yellow]{volts:.1f}V[/yellow]"
- else:
- battery = f"[green]{volts:.1f}V[/green]"
- except ValueError:
- battery = battery_voltage
- elif lowbat:
- battery = "[red]LOW[/red]"
- else:
- # Kein Batterie-Geraet
- battery = "[dim]—[/dim]"
- rows.append({
- "name": dev.name,
- "value": value or "[dim]—[/dim]",
- "connection": connection,
- "battery": battery,
- })
- if temp_api:
- await temp_api.close()
- return {"success": True, "rows": rows}
- except Exception as e:
- if temp_api:
- await temp_api.close()
- return {"error": f"Fehler: {e}"}
- async def _test_connection(self, params: dict) -> dict:
- """Testet die Verbindung zur Homematic CCU.
- Prueft:
- 1. URL erreichbar?
- 2. Session-ID gueltig? (oder Login moeglich?)
- 3. API funktionsfaehig? (devicelist abrufen)
- """
- url = params.get("url", "")
- session_id = params.get("session_id", "")
- username = params.get("username", "")
- password = params.get("password", "")
- # Aktuelle Config als Fallback
- if not url:
- hm_cfg = self.config.get("homematic", {})
- url = hm_cfg.get("url", "")
- session_id = session_id or hm_cfg.get("session_id", "")
- username = username or hm_cfg.get("username", "")
- password = password or hm_cfg.get("password", "")
- if not url:
- return {"error": "Keine URL konfiguriert"}
- has_login = bool(username and password)
- has_token = bool(session_id)
- import aiohttp
- from plugins.homematic.api import HomematicApi
- test_api = HomematicApi(base_url=url, session_id=session_id, timeout=10)
- try:
- # 1. Erreichbarkeit pruefen (version.cgi braucht keine Auth)
- try:
- version = await test_api.get_version()
- except aiohttp.ClientError:
- await test_api.close()
- return {"error": f"CCU nicht erreichbar unter {url}"}
- except Exception as e:
- await test_api.close()
- return {"error": f"CCU nicht erreichbar: {e}"}
- if not version:
- await test_api.close()
- return {"error": f"Keine Antwort von {url}"}
- results: list[str] = [f"CCU erreichbar, XML-API v{version}"]
- # 2. Login pruefen (JSON-API, unabhaengig vom Token)
- if has_login:
- login_api = HomematicApi(
- base_url=url, username=username, password=password, timeout=10,
- )
- try:
- login_ok = await login_api.login()
- if login_ok:
- results.append("Login OK")
- else:
- results.append("Login fehlgeschlagen — ungueltige Zugangsdaten")
- except Exception:
- results.append("Login fehlgeschlagen — ungueltige Zugangsdaten")
- await login_api.close()
- # 3. Token/Session pruefen (XML-API)
- if has_token:
- try:
- devices = await test_api.get_device_list()
- rooms = await test_api.get_room_list()
- if devices or rooms:
- results.append(f"Token OK — {len(devices)} Geraete, {len(rooms)} Raeume")
- else:
- results.append("Token ungueltig — keine Berechtigung")
- await test_api.close()
- return {"error": " | ".join(results)}
- except Exception:
- results.append("Token ungueltig")
- await test_api.close()
- return {"error": " | ".join(results)}
- if not has_login and not has_token:
- await test_api.close()
- return {
- "success": True,
- "message": " | ".join(results)
- + " | Kein Token konfiguriert — bitte im CCU WebUI unter "
- "Einstellungen > Sicherheit einen XML-API Token erstellen",
- }
- await test_api.close()
- # Gesamtergebnis
- has_error = any("fehlgeschlagen" in r or "ungueltig" in r for r in results)
- if has_error:
- return {"error": " | ".join(results)}
- return {"success": True, "message": " | ".join(results)}
- except aiohttp.ClientError:
- await test_api.close()
- return {"error": f"CCU nicht erreichbar unter {url}"}
- except Exception as e:
- await test_api.close()
- return {"error": f"Verbindungsfehler: {e}"}
- def get_config_tool_tabs(self) -> list[dict]:
- """Stellt benutzerdefinierte Tabs fuer das ConfigTool bereit."""
- return [
- {
- "title": "Einstellungen",
- "description": "Homematic CCU Verbindung und Raum-Zuordnung",
- "schema": {
- "config_section": "homematic",
- "fields": [
- {
- "name": "url",
- "label": "CCU URL",
- "type": "text",
- "default": "http://homematic-raspi",
- "help": "URL der Homematic CCU (z.B. http://192.168.1.100)",
- },
- {
- "name": "username",
- "label": "Benutzername",
- "type": "text",
- "help": "CCU Login-Benutzername (leer = keine Authentifizierung)",
- },
- {
- "name": "password",
- "label": "Passwort",
- "type": "password",
- "help": "CCU Login-Passwort",
- },
- {
- "name": "session_id",
- "label": "Session-ID",
- "type": "text",
- "help": "Manuelle Session-ID (alternativ zu Login)",
- },
- {
- "name": "timeout",
- "label": "Timeout (Sek.)",
- "type": "number",
- "default": 30,
- "help": "HTTP-Timeout fuer API-Anfragen",
- },
- {
- "name": "cache_ttl",
- "label": "Cache-Dauer (Sek.)",
- "type": "number",
- "default": 300,
- "help": "Wie lange Geraetedaten gecacht werden",
- },
- {
- "name": "monitor_interval",
- "label": "Monitor-Intervall (Sek.)",
- "type": "number",
- "default": 120,
- "help": "Abfrage-Intervall fuer Geraeteueberwachung (0 = aus)",
- },
- {
- "name": "warn_battery_low",
- "label": "Warnung bei schwacher Batterie",
- "type": "checkbox",
- "default": True,
- },
- {
- "name": "warn_connection_changed",
- "label": "Warnung bei Verbindungsverlust",
- "type": "checkbox",
- "default": True,
- },
- {
- "name": "warn_status_changed",
- "label": "Warnung bei Statusaenderung",
- "type": "checkbox",
- "default": False,
- "help": "Meldet Aenderungen an Fenster/Tuer-Kontakten, Schaltern, etc.",
- },
- ],
- "actions": [
- {
- "id": "test_connection",
- "label": "Verbindung pruefen",
- "variant": "primary",
- "collect_fields": True,
- "help": "Testet die Verbindung mit den aktuellen Einstellungen",
- },
- ],
- },
- },
- {
- "title": "Raeume",
- "description": "Homematic-Raeume mit Satellite-Zuordnung und Aliases",
- "view_type": "interactive_list",
- "load_action": "get_rooms_data",
- "save_action": "save_room_config",
- "list_key": "rooms",
- "list_label_field": "name",
- "list_subtitle_field": "channel_count",
- "list_subtitle_suffix": " Geraete",
- "detail_fields": [
- {
- "name": "aliases",
- "label": "Aliases (kommagetrennt)",
- "type": "text",
- "help": "Alternative Namen fuer diesen Raum (z.B. bad, bathroom)",
- "value_from": "aliases_str",
- },
- {
- "name": "satellite",
- "label": "Satellite in diesem Raum",
- "type": "select",
- "choices_from": "satellites",
- "help": "Welcher Satellite steht in diesem Raum?",
- },
- ],
- },
- {
- "title": "Geraete",
- "view_type": "interactive_list",
- "load_action": "get_devices_data",
- "list_key": "devices",
- "list_label_field": "label",
- "detail_fields": [
- {"name": "device_name", "label": "Geraet", "type": "readonly"},
- {"name": "device_type", "label": "Typ", "type": "readonly"},
- {"name": "category", "label": "Kategorie", "type": "readonly"},
- {"name": "room", "label": "Raum", "type": "readonly"},
- {"name": "channels", "label": "Channels", "type": "readonly"},
- ],
- },
- {
- "title": "Status",
- "view_type": "table",
- "load_action": "get_status_data",
- "columns": [
- {"key": "name", "label": "Name", "width": 35},
- {"key": "value", "label": "Wert", "width": 30},
- {"key": "connection", "label": "Verbindung", "width": 12},
- {"key": "battery", "label": "Batterie", "width": 12},
- ],
- },
- ]
- def _register_room_entities(self) -> None:
- """Registriert CCU-Raeume und Aliases als Entity-Werte fuer die NLP-Erkennung."""
- if not self._devices:
- return
- try:
- from trixy_core.nlp.entities import EntityRegistry
- # EntityRegistry aus dem NLP-Plugin holen
- entity_registry: EntityRegistry | None = None
- if hasattr(self.application, "plugins"):
- for name in ("nlp_llm", "nlp_classifier"):
- plugin = self.application.plugins.get(name)
- if plugin and hasattr(plugin, "_entity_registry"):
- entity_registry = plugin._entity_registry
- break
- if not entity_registry:
- return
- # CCU-Raumnamen registrieren
- room_names = [r.lower() for r in self._devices.get_room_names()]
- entity_registry.add_values("raum", room_names)
- # Aliases registrieren
- aliases = list(self._devices._room_aliases.keys())
- entity_registry.add_values("raum", aliases)
- pdebug(f"[Homematic] {len(room_names)} Raeume + {len(aliases)} Aliases als Entities registriert")
- except Exception as e:
- pdebug(f"[Homematic] Entity-Registrierung fehlgeschlagen: {e}")
- async def _refresh_programs_sysvars(self) -> None:
- """Laedt Programme und Systemvariablen."""
- if not self._api:
- return
- import asyncio
- programs, sysvars = await asyncio.gather(
- self._api.get_program_list(),
- self._api.get_sysvar_list(),
- )
- self._programs = {p.name.lower(): p for p in programs}
- self._sysvars = {s.name.lower(): s for s in sysvars}
- def _get_room(self, data: IntentReceivedData) -> str:
- """Extrahiert den Raum aus den Slots oder loest ihn ueber den Satellite auf.
- Reihenfolge:
- 1. Expliziter Raum-Slot ("mach das Licht im Wohnzimmer an")
- 2. Satellite-Room-Mapping (Satellite steht im Wohnzimmer → "wohnzimmer")
- """
- raum = data.get_slot("raum", "")
- if raum:
- return raum
- # Satellite-basierte Raum-Aufloesung
- if self._devices and (data.satellite_id or data.room_id):
- # Satellite-Alias aus Metadaten holen
- alias = ""
- if data.session and hasattr(data.session, "satellite"):
- sat = data.session.satellite
- if sat and hasattr(sat, "alias"):
- alias = sat.alias or ""
- resolved = self._devices.resolve_room_for_satellite(
- satellite_id=data.satellite_id,
- room_id=data.room_id,
- alias=alias,
- )
- if resolved:
- return resolved
- return ""
- # ========================================================================
- # Intent-Handler: Licht
- # ========================================================================
- @intent(
- "homematic_licht_schalten",
- description="Licht ein-/ausschalten",
- patterns=[
- "mach das licht im {raum} {aktion}",
- "mach das licht {aktion} im {raum}",
- "mach {raum} licht {aktion}",
- "schalte das licht im {raum} {aktion}",
- "schalte licht im {raum} {aktion}",
- "{raum} licht {aktion}",
- "licht im {raum} {aktion}",
- "licht {aktion} im {raum}",
- "mach licht {aktion}",
- "licht {aktion}",
- ],
- )
- async def handle_licht_schalten(self, data: IntentReceivedData) -> IntentResult:
- """Schaltet Licht in einem Raum ein oder aus."""
- await self._devices.ensure_cache()
- raum = self._get_room(data)
- aktion = data.get_slot("aktion", "").lower()
- if not raum:
- return IntentResult.failure("missing_room", "In welchem Raum?")
- if not self._devices.room_exists(raum):
- return IntentResult.failure("unknown_room", f"Raum '{raum}' nicht gefunden.")
- einschalten = aktion in ("an", "ein", "einschalten", "on")
- switched = await self._devices.switch_lights(raum, einschalten)
- if switched == 0:
- return IntentResult.failure("no_light", f"Kein Licht im {raum} gefunden.")
- raum_display = self._devices.normalize_room(raum).title()
- aktion_text = "eingeschaltet" if einschalten else "ausgeschaltet"
- return IntentResult.success_with_response(f"Licht im {raum_display} {aktion_text}.")
- @intent(
- "homematic_licht_status",
- description="Licht-Status abfragen",
- patterns=[
- "ist das licht im {raum} an",
- "ist licht im {raum} an",
- "ist im {raum} licht an",
- "brennt im {raum} licht",
- "brennt das licht im {raum}",
- ],
- )
- async def handle_licht_status(self, data: IntentReceivedData) -> IntentResult:
- """Fragt ab ob Licht in einem Raum an ist."""
- await self._devices.ensure_cache()
- raum = self._get_room(data)
- if not raum:
- return IntentResult.failure("missing_room", "In welchem Raum?")
- if not self._devices.room_exists(raum):
- return IntentResult.failure("unknown_room", f"Raum '{raum}' nicht gefunden.")
- states = await self._devices.get_light_states(raum)
- raum_display = self._devices.normalize_room(raum).title()
- if not states:
- return IntentResult.failure("no_light", f"Kein Licht im {raum_display} gefunden.")
- lights_on = [n for n, on in states.items() if on]
- if lights_on:
- return IntentResult.success_with_response(
- f"Im {raum_display} ist Licht an: {', '.join(lights_on)}."
- )
- return IntentResult.success_with_response(f"Im {raum_display} ist kein Licht an.")
- # ========================================================================
- # Intent-Handler: LED / Farbe
- # ========================================================================
- @intent(
- "homematic_led_farbe",
- description="LED-Stripe Farbe aendern",
- patterns=[
- "mach die leds im {raum} {farbe}",
- "mach die led im {raum} {farbe}",
- "stelle die leds im {raum} auf {farbe}",
- "led {raum} {farbe}",
- "leds im {raum} {farbe}",
- "aendere die led farbe im {raum} auf {farbe}",
- "ändere die led farbe im {raum} auf {farbe}",
- ],
- )
- async def handle_led_farbe(self, data: IntentReceivedData) -> IntentResult:
- """Aendert die Farbe von LED-Stripes in einem Raum."""
- await self._devices.ensure_cache()
- raum = self._get_room(data)
- farbe = data.get_slot("farbe", "").lower()
- if not raum:
- return IntentResult.failure("missing_room", "In welchem Raum?")
- if not farbe:
- return IntentResult.failure("missing_color", "Welche Farbe?")
- color_value = COLOR_MAP.get(farbe)
- if color_value is None:
- verfuegbar = ", ".join(sorted({k for k in COLOR_MAP if not any(c in k for c in "üöä")}))
- return IntentResult.failure("unknown_color", f"Farbe '{farbe}' unbekannt. Verfuegbar: {verfuegbar}")
- changed = await self._devices.set_led_color(raum, color_value)
- if changed == 0:
- return IntentResult.failure("no_led", f"Keine LED-Stripes im {raum} gefunden.")
- raum_display = self._devices.normalize_room(raum).title()
- return IntentResult.success_with_response(f"LEDs im {raum_display} auf {farbe.title()} gesetzt.")
- @intent(
- "homematic_led_dimmen",
- description="LED-Stripe Helligkeit setzen",
- patterns=[
- "dimme die leds im {raum} auf {prozent}",
- "setze die led helligkeit im {raum} auf {prozent}",
- "leds im {raum} auf {prozent} prozent",
- "led {raum} {prozent} prozent",
- ],
- )
- async def handle_led_dimmen(self, data: IntentReceivedData) -> IntentResult:
- """Setzt die Helligkeit von LED-Stripes."""
- await self._devices.ensure_cache()
- raum = self._get_room(data)
- prozent = data.get_slot("prozent", "")
- if not raum:
- return IntentResult.failure("missing_room", "In welchem Raum?")
- try:
- level = float(prozent) / 100.0
- level = max(0.0, min(1.0, level))
- except (ValueError, TypeError):
- return IntentResult.failure("invalid_level", "Ungueltiger Prozentwert.")
- changed = await self._devices.set_led_level(raum, level)
- if changed == 0:
- return IntentResult.failure("no_led", f"Kein LED-Dimmer im {raum} gefunden.")
- raum_display = self._devices.normalize_room(raum).title()
- return IntentResult.success_with_response(f"LEDs im {raum_display} auf {int(level * 100)} Prozent.")
- # ========================================================================
- # Intent-Handler: Heizung / Temperatur
- # ========================================================================
- @intent(
- "homematic_temperatur_abfragen",
- description="Aktuelle Temperatur abfragen",
- patterns=[
- "wie warm ist es im {raum}",
- "welche temperatur hat es im {raum}",
- "temperatur im {raum}",
- "wie viel grad ist es im {raum}",
- "wie viel grad hat es im {raum}",
- "wie kalt ist es im {raum}",
- ],
- )
- async def handle_temperatur_abfragen(self, data: IntentReceivedData) -> IntentResult:
- """Fragt die aktuelle Temperatur in einem Raum ab."""
- await self._devices.ensure_cache()
- raum = self._get_room(data)
- if not raum:
- return IntentResult.failure("missing_room", "In welchem Raum?")
- temps = await self._devices.get_temperatures(raum)
- if not temps:
- return IntentResult.failure("no_thermostat", f"Kein Thermostat im {raum} gefunden.")
- raum_display = self._devices.normalize_room(raum).title()
- if len(temps) == 1:
- t = temps[0]
- return IntentResult.success_with_response(
- f"Im {raum_display} sind es {t['actual']:.1f} Grad."
- )
- avg = sum(t["actual"] for t in temps) / len(temps)
- details = ", ".join(f"{t['name']}: {t['actual']:.1f} Grad" for t in temps)
- return IntentResult.success_with_response(
- f"Im {raum_display} sind es durchschnittlich {avg:.1f} Grad. {details}"
- )
- @intent(
- "homematic_heizung_abfragen",
- description="Heizungs-Solltemperatur abfragen",
- patterns=[
- "auf wie viel grad ist die heizung im {raum} eingestellt",
- "auf wie viel grad steht die heizung im {raum}",
- "wie hoch ist die heizung im {raum} eingestellt",
- "heizung im {raum}",
- "was ist die soll temperatur im {raum}",
- "auf wie viel grad ist die heizung eingestellt",
- ],
- )
- async def handle_heizung_abfragen(self, data: IntentReceivedData) -> IntentResult:
- """Fragt die Soll-Temperatur der Heizung ab."""
- await self._devices.ensure_cache()
- raum = self._get_room(data)
- temps = await self._devices.get_temperatures(raum)
- if not temps:
- return IntentResult.failure("no_thermostat", "Kein Thermostat gefunden.")
- if raum:
- raum_display = self._devices.normalize_room(raum).title()
- parts = [f"{t['name']}: {t['actual']:.1f} Grad, eingestellt auf {t['target']:.1f} Grad" for t in temps]
- return IntentResult.success_with_response(f"Heizung im {raum_display}: {'; '.join(parts)}.")
- else:
- parts = [f"{t['name']} {t['target']:.1f} Grad" for t in temps]
- return IntentResult.success_with_response(f"Heizungseinstellungen: {', '.join(parts)}.")
- @intent(
- "homematic_heizung_setzen",
- description="Heizungstemperatur einstellen",
- patterns=[
- "stelle die heizung im {raum} auf {temperatur} grad",
- "setze die heizung im {raum} auf {temperatur} grad",
- "heizung im {raum} auf {temperatur} grad",
- "heizung {raum} auf {temperatur}",
- "stelle die heizung auf {temperatur} grad",
- "heizung auf {temperatur} grad",
- ],
- )
- async def handle_heizung_setzen(self, data: IntentReceivedData) -> IntentResult:
- """Setzt die Soll-Temperatur der Heizung."""
- await self._devices.ensure_cache()
- raum = self._get_room(data)
- temperatur = data.get_slot("temperatur", "")
- try:
- temp = float(temperatur)
- except (ValueError, TypeError):
- return IntentResult.failure("invalid_temp", "Ungueltige Temperatur.")
- if temp < 4.5 or temp > 30.5:
- return IntentResult.failure(
- "temp_out_of_range",
- f"{temp} Grad ist ausserhalb des Bereichs (4,5 bis 30,5 Grad)."
- )
- if raum:
- changed = await self._devices.set_temperature(raum, temp)
- raum_display = self._devices.normalize_room(raum).title()
- if changed == 0:
- return IntentResult.failure("no_thermostat", f"Kein Thermostat im {raum_display} gefunden.")
- return IntentResult.success_with_response(f"Heizung im {raum_display} auf {temp:.1f} Grad gestellt.")
- else:
- # Alle Raeume
- total = 0
- for room_name in self._devices.get_room_names():
- total += await self._devices.set_temperature(room_name, temp)
- if total == 0:
- return IntentResult.failure("no_thermostat", "Kein Thermostat gefunden.")
- return IntentResult.success_with_response(f"Alle Heizungen auf {temp:.1f} Grad gestellt.")
- # ========================================================================
- # Intent-Handler: Fenster / Tuer
- # ========================================================================
- @intent(
- "homematic_fenster_status",
- description="Fenster/Tuer-Status abfragen",
- patterns=[
- "ist das fenster im {raum} offen",
- "ist das fenster im {raum} auf",
- "ist die tür im {raum} offen",
- "sind die fenster offen",
- "sind fenster offen",
- "welche fenster sind offen",
- "ist ein fenster offen",
- "fenster status",
- ],
- )
- async def handle_fenster_status(self, data: IntentReceivedData) -> IntentResult:
- """Fragt den Status von Fenstern und Tueren ab."""
- await self._devices.ensure_cache()
- raum = data.get_slot("raum", "")
- windows = await self._devices.get_window_states(raum)
- if raum:
- raum_display = self._devices.normalize_room(raum).title()
- if not windows:
- return IntentResult.failure("no_sensor", f"Kein Fenster-/Tuersensor im {raum_display}.")
- offen = [n for n, o in windows.items() if o]
- if offen:
- return IntentResult.success_with_response(f"Im {raum_display} offen: {', '.join(offen)}.")
- return IntentResult.success_with_response(f"Im {raum_display} ist alles geschlossen.")
- else:
- offen = [n for n, o in windows.items() if o]
- if offen:
- return IntentResult.success_with_response(f"{len(offen)} offen: {', '.join(offen)}.")
- return IntentResult.success_with_response("Alle Fenster und Tueren sind geschlossen.")
- # ========================================================================
- # Intent-Handler: Bewegungsmelder
- # ========================================================================
- @intent(
- "homematic_bewegung_status",
- description="Letzte Bewegung abfragen",
- patterns=[
- "wann wurde zuletzt eine bewegung im {raum} erkannt",
- "wann war die letzte bewegung im {raum}",
- "bewegungsmelder {raum}",
- "gab es eine bewegung im {raum}",
- ],
- )
- async def handle_bewegung_status(self, data: IntentReceivedData) -> IntentResult:
- """Fragt den letzten Bewegungszeitpunkt ab."""
- await self._devices.ensure_cache()
- raum = self._get_room(data)
- if not raum:
- return IntentResult.failure("missing_room", "In welchem Raum?")
- motion = await self._devices.get_last_motion(raum)
- raum_display = self._devices.normalize_room(raum).title()
- if not motion:
- return IntentResult.failure("no_motion_sensor", f"Kein Bewegungsmelder im {raum_display}.")
- ts = motion["timestamp"]
- if ts > 0:
- dt = datetime.datetime.fromtimestamp(ts)
- now = datetime.datetime.now()
- diff = (now - dt).total_seconds()
- if diff < 60:
- zeit_text = "vor wenigen Sekunden"
- elif diff < 3600:
- zeit_text = f"vor {int(diff / 60)} Minuten"
- elif diff < 86400:
- zeit_text = f"vor {int(diff / 3600)} Stunden"
- else:
- zeit_text = f"am {dt.strftime('%d.%m. um %H:%M')}"
- return IntentResult.success_with_response(f"Letzte Bewegung im {raum_display}: {zeit_text}.")
- return IntentResult.failure("no_data", f"Keine Bewegungsdaten im {raum_display}.")
- # ========================================================================
- # Intent-Handler: Programme
- # ========================================================================
- @intent(
- "homematic_programm_ausfuehren",
- description="CCU-Programm ausfuehren",
- patterns=[
- "fuehre das programm {programm} aus",
- "führe das programm {programm} aus",
- "starte programm {programm}",
- "starte das programm {programm}",
- "programm {programm} starten",
- "aktiviere {programm}",
- ],
- )
- async def handle_programm_ausfuehren(self, data: IntentReceivedData) -> IntentResult:
- """Fuehrt ein CCU-Programm aus."""
- programm = data.get_slot("programm", "").lower()
- if not programm:
- return IntentResult.failure("missing_program", "Welches Programm?")
- # Exakt oder Teilmatch
- prog = self._programs.get(programm)
- if not prog:
- for name, p in self._programs.items():
- if programm in name:
- prog = p
- break
- if not prog:
- return IntentResult.failure("unknown_program", f"Programm '{programm}' nicht gefunden.")
- try:
- success = await self._api.run_program(prog.ise_id)
- if success:
- return IntentResult.success_with_response(f"Programm '{prog.name}' gestartet.")
- return IntentResult.failure("program_failed", f"Programm '{prog.name}' konnte nicht gestartet werden.")
- except Exception as e:
- return IntentResult.failure("error", f"Fehler: {e}")
- # ========================================================================
- # Intent-Handler: Systemvariablen
- # ========================================================================
- @intent(
- "homematic_sysvar_abfragen",
- description="Systemvariable abfragen",
- patterns=[
- "wie ist der status von {variable}",
- "was ist {variable}",
- "systemvariable {variable}",
- "homematic variable {variable}",
- "sind wir {variable}",
- ],
- )
- async def handle_sysvar_abfragen(self, data: IntentReceivedData) -> IntentResult:
- """Fragt eine Systemvariable ab."""
- variable = data.get_slot("variable", "").lower()
- if not variable:
- return IntentResult.failure("missing_var", "Welche Variable?")
- sv = self._sysvars.get(variable)
- if not sv:
- for name, s in self._sysvars.items():
- if variable in name:
- sv = s
- break
- if not sv:
- return IntentResult.failure("unknown_var", f"Variable '{variable}' nicht gefunden.")
- # Aktuellen Wert abrufen
- try:
- current = await self._api.get_sysvar(sv.ise_id)
- if current:
- sv = current
- except Exception:
- pass
- if sv.var_type == "2": # Boolean
- wert = sv.value_name_1 if sv.value_bool else sv.value_name_0
- wert = wert or ("aktiv" if sv.value_bool else "inaktiv")
- elif sv.unit:
- wert = f"{sv.variable} {sv.unit}"
- else:
- wert = sv.variable or sv.value
- return IntentResult.success_with_response(f"{sv.name}: {wert}")
- # ========================================================================
- # Intent-Handler: Allgemeiner Status
- # ========================================================================
- @intent(
- "homematic_status",
- description="Allgemeiner Smart Home Status",
- patterns=[
- "smart home status",
- "homematic status",
- "wie ist der hausstatus",
- "haus status",
- ],
- )
- async def handle_status(self, data: IntentReceivedData) -> IntentResult:
- """Gibt einen allgemeinen Smart Home Status."""
- await self._devices.ensure_cache()
- parts: list[str] = []
- # Fenster
- try:
- windows = await self._devices.get_window_states()
- offen = [n for n, o in windows.items() if o]
- if offen:
- parts.append(f"{len(offen)} Fenster offen: {', '.join(offen)}")
- else:
- parts.append("Alle Fenster geschlossen")
- except Exception:
- pass
- # Anwesenheit
- anw = self._sysvars.get("anwesenheit")
- if anw:
- try:
- current = await self._api.get_sysvar(anw.ise_id)
- if current:
- parts.append(current.value_name_1 if current.value_bool else current.value_name_0)
- except Exception:
- pass
- # Durchschnittstemperatur
- try:
- temps = await self._devices.get_temperatures()
- if temps:
- avg = sum(t["actual"] for t in temps) / len(temps)
- parts.append(f"Durchschnittstemperatur {avg:.1f} Grad")
- except Exception:
- pass
- if not parts:
- return IntentResult.failure("no_data", "Keine Statusdaten verfuegbar.")
- return IntentResult.success_with_response(". ".join(parts) + ".")
- # Plugin-Export
- Plugin = HomematicPlugin
|