main.py 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355
  1. # -*- coding: utf-8 -*-
  2. """
  3. Trixy Homematic Smart Home Plugin.
  4. Sprachsteuerung fuer Homematic CCU via XML-API:
  5. - Licht ein/ausschalten (pro Raum oder gezielt)
  6. - LED-Stripe Farbe aendern / dimmen
  7. - Heizungs-Temperatur setzen/abfragen (allgemein oder pro Raum)
  8. - Fenster/Tuer-Status abfragen
  9. - Bewegungsmelder-Status abfragen
  10. - Licht-Status abfragen
  11. - CCU-Programme ausfuehren
  12. - Systemvariablen abfragen/setzen
  13. - Allgemeiner Smart Home Status
  14. Verwendet DeviceManager fuer praezise Geraetetyp-Klassifizierung:
  15. Steckdosen werden nicht als Licht geschaltet.
  16. """
  17. from __future__ import annotations
  18. import asyncio
  19. import datetime
  20. import time
  21. from typing import Any
  22. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  23. from trixy_core.events.decorators import TrixyEvent
  24. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  25. from trixy_core.nlp.decorators import intent
  26. from trixy_core.nlp.handler import IntentResult, IntentReceivedData
  27. from plugins.homematic.api import HomematicApi
  28. from plugins.homematic.devices import DeviceManager
  29. # Farb-Mapping fuer LED-Stripes (HM-LC-RGBW-WM: COLOR 0-200 Hue-Kreis)
  30. COLOR_MAP: dict[str, int] = {
  31. "rot": 0,
  32. "orange": 20,
  33. "gelb": 40,
  34. "gruen": 60,
  35. "grün": 60,
  36. "tuerkis": 80,
  37. "türkis": 80,
  38. "cyan": 80,
  39. "blau": 120,
  40. "lila": 140,
  41. "violett": 140,
  42. "pink": 160,
  43. "rosa": 160,
  44. "magenta": 170,
  45. "weiss": 200,
  46. "weiß": 200,
  47. }
  48. class HomematicPlugin(TrixyPlugin):
  49. """Homematic Smart Home Steuerung."""
  50. NAME = "homematic"
  51. VERSION = "1.0.0"
  52. DESCRIPTION = "Homematic Smart Home Steuerung via XML-API"
  53. AUTHOR = "Trixy"
  54. def __init__(self, **kwargs) -> None:
  55. super().__init__(**kwargs)
  56. self._api: HomematicApi | None = None
  57. self._devices: DeviceManager | None = None
  58. self._monitor = None # HomematicMonitor (lazy import)
  59. # Programm/Sysvar Caches (nicht im DeviceManager)
  60. self._programs: dict[str, Any] = {}
  61. self._sysvars: dict[str, Any] = {}
  62. async def on_load(self) -> None:
  63. """Initialisiert die API-Verbindung und laedt Geraetedaten."""
  64. hm_cfg = self.config.get("homematic", {})
  65. base_url = hm_cfg.get("url", "http://homematic-raspi")
  66. session_id = hm_cfg.get("session_id", "")
  67. username = hm_cfg.get("username", "")
  68. password = hm_cfg.get("password", "")
  69. timeout = hm_cfg.get("timeout", 30)
  70. cache_ttl = hm_cfg.get("cache_ttl", 300)
  71. room_aliases = self.config.get("room_aliases", {})
  72. satellite_rooms = self.config.get("satellite_rooms", {})
  73. # _comment Eintraege entfernen
  74. room_aliases.pop("_comment", None)
  75. satellite_rooms.pop("_comment", None)
  76. self._api = HomematicApi(
  77. base_url=base_url,
  78. session_id=session_id,
  79. username=username,
  80. password=password,
  81. timeout=timeout,
  82. )
  83. # Token erforderlich fuer XML-API
  84. if not session_id:
  85. if username and password:
  86. pwarn("[Homematic] Kein XML-API Token konfiguriert. "
  87. "Login-Daten sind nur fuer die Pruefung im ConfigTool nutzbar. "
  88. "Bitte Token im CCU WebUI unter Einstellungen > Sicherheit erstellen "
  89. "und als session_id eintragen.")
  90. else:
  91. pwarn("[Homematic] Weder Token noch Login konfiguriert — Plugin inaktiv")
  92. return
  93. self._devices = DeviceManager(self._api, room_aliases, satellite_rooms)
  94. self._devices._cache_ttl = cache_ttl
  95. # Daten laden
  96. try:
  97. await self._devices.refresh()
  98. await self._refresh_programs_sysvars()
  99. self._register_room_entities()
  100. pinfo(f"[Homematic] Plugin geladen — "
  101. f"{len(self._devices._devices)} Geraete, "
  102. f"{len(self._devices._rooms)} Raeume, "
  103. f"{len(self._programs)} Programme")
  104. except Exception as e:
  105. perror(f"[Homematic] Initialisierung fehlgeschlagen: {e}")
  106. # Monitor starten
  107. self._start_monitor()
  108. async def on_unload(self) -> None:
  109. """Schliesst die API-Verbindung."""
  110. if self._monitor:
  111. self._monitor.stop()
  112. self._monitor = None
  113. if self._api:
  114. await self._api.close()
  115. self._api = None
  116. self._devices = None
  117. def _start_monitor(self) -> None:
  118. """Initialisiert und startet den Geraete-Monitor."""
  119. if not self._api or not self._api.session_id:
  120. return
  121. from plugins.homematic.monitor import HomematicMonitor
  122. hm_cfg = self.config.get("homematic", {})
  123. self._monitor = HomematicMonitor(
  124. api=self._api,
  125. plugin_path=self.plugin_path,
  126. event_callback=self._on_monitor_warning,
  127. )
  128. self._monitor.interval = hm_cfg.get("monitor_interval", 120)
  129. self._monitor.warn_battery_low = hm_cfg.get("warn_battery_low", True)
  130. self._monitor.warn_connection_changed = hm_cfg.get("warn_connection_changed", True)
  131. self._monitor.warn_status_changed = hm_cfg.get("warn_status_changed", False)
  132. self._monitor.start()
  133. async def _on_monitor_warning(self, event_name: str, data: dict) -> None:
  134. """Callback vom Monitor — gibt Warnungen per TTS aus und emittiert Event."""
  135. text = data.get("text", "")
  136. if not text:
  137. return
  138. # Event emittieren
  139. if hasattr(self.application, "events"):
  140. await self.application.events.emit(event_name, data)
  141. # TTS auf allen verbundenen Satellites
  142. if not hasattr(self.application, "satellites"):
  143. return
  144. connected = self.application.satellites.get_connected()
  145. if not connected:
  146. pdebug("[Homematic] Keine Satellites verbunden fuer TTS-Warnung")
  147. return
  148. # TTS-Request per emit() — kompatibel mit Piper-Plugin
  149. # (speak() nutzt trigger() mit EventData, Piper liest aus metadata via .get())
  150. for sat in connected:
  151. try:
  152. pdebug(f"[Homematic] Sende TTS-Warnung an {sat.alias or sat.id}...")
  153. await self.application.events.emit("tts_request", {
  154. "text": text,
  155. "satellite_id": sat.id,
  156. "source": "homematic_monitor",
  157. })
  158. except Exception as e:
  159. pwarn(f"[Homematic] TTS-Warnung an {sat.alias or sat.id} Fehler: {e}")
  160. @TrixyEvent(["plugin_settings_saved"])
  161. async def on_settings_saved(self, event_name: str, event_data: dict) -> None:
  162. """Reagiert auf Config-Aenderungen vom ConfigTool."""
  163. plugin_name = event_data.get("plugin_name", "")
  164. if plugin_name != self.NAME:
  165. return
  166. pdebug("[Homematic] Config wurde geaendert, lade neu...")
  167. self.reload_config()
  168. # Monitor mit neuer Config aktualisieren
  169. if self._monitor:
  170. hm_cfg = self.config.get("homematic", {})
  171. self._monitor.interval = hm_cfg.get("monitor_interval", 120)
  172. self._monitor.warn_battery_low = hm_cfg.get("warn_battery_low", True)
  173. self._monitor.warn_connection_changed = hm_cfg.get("warn_connection_changed", True)
  174. self._monitor.warn_status_changed = hm_cfg.get("warn_status_changed", False)
  175. self._monitor.restart()
  176. # DeviceManager Aliases aktualisieren
  177. if self._devices:
  178. room_aliases = self.config.get("room_aliases", {})
  179. satellite_rooms = self.config.get("satellite_rooms", {})
  180. room_aliases.pop("_comment", None)
  181. satellite_rooms.pop("_comment", None)
  182. self._devices._room_aliases = room_aliases
  183. self._devices._satellite_rooms = satellite_rooms
  184. async def handle_config_action(self, action: str, params: dict) -> dict:
  185. """Verarbeitet Aktionen vom ConfigTool.
  186. Args:
  187. action: Aktions-Name (z.B. "test_connection")
  188. params: Optionale Parameter
  189. Returns:
  190. Dict mit success/message/error
  191. """
  192. if action == "test_connection":
  193. return await self._test_connection(params)
  194. elif action == "get_rooms_data":
  195. return await self._get_rooms_data()
  196. elif action == "save_room_config":
  197. return await self._save_room_config(params)
  198. elif action == "get_devices_data":
  199. return await self._get_devices_data()
  200. elif action == "get_status_data":
  201. return await self._get_status_data()
  202. return {"error": f"Unbekannte Aktion: {action}"}
  203. async def _get_rooms_data(self) -> dict:
  204. """Liefert Raumliste, Aliases und Satellites fuer den ConfigTool Raeume-Tab."""
  205. # Temporaere API falls Plugin nicht voll initialisiert
  206. api = self._api
  207. temp_api = None
  208. if not api or not api.session_id:
  209. hm_cfg = self.config.get("homematic", {})
  210. sid = hm_cfg.get("session_id", "")
  211. url = hm_cfg.get("url", "")
  212. if not sid or not url:
  213. return {"error": "Kein Token konfiguriert — bitte unter F3 Einstellungen konfigurieren"}
  214. temp_api = HomematicApi(base_url=url, session_id=sid, timeout=10)
  215. api = temp_api
  216. try:
  217. if self._devices and not temp_api:
  218. await self._devices.ensure_cache()
  219. rooms = await api.get_room_list()
  220. if not rooms:
  221. if temp_api:
  222. await temp_api.close()
  223. return {"error": "Keine Raeume von der CCU erhalten — Token/Session pruefen"}
  224. room_aliases = self.config.get("room_aliases", {})
  225. satellite_rooms = self.config.get("satellite_rooms", {})
  226. # Satellite-Liste vom Server holen
  227. satellites: list[dict[str, str]] = []
  228. if hasattr(self.application, "satellites"):
  229. for sat in self.application.satellites.get_all():
  230. satellites.append({
  231. "id": sat.id,
  232. "alias": sat.alias,
  233. "room_id": sat.room_id,
  234. })
  235. # Inverse Mappings: Raum → Aliases, Raum → Satellite
  236. room_to_aliases: dict[str, list[str]] = {}
  237. for alias, target in room_aliases.items():
  238. if alias.startswith("_"):
  239. continue
  240. room_to_aliases.setdefault(target, []).append(alias)
  241. room_to_satellite: dict[str, str] = {}
  242. for sat_key, hm_room in satellite_rooms.items():
  243. room_to_satellite[hm_room] = sat_key
  244. rooms_data: list[dict] = []
  245. for room in rooms:
  246. room_lower = room.name.lower()
  247. rooms_data.append({
  248. "name": room.name,
  249. "ise_id": room.ise_id,
  250. "aliases": room_to_aliases.get(room_lower, []),
  251. "satellite": room_to_satellite.get(room_lower, ""),
  252. "channel_count": len(room.channel_ids),
  253. })
  254. if temp_api:
  255. await temp_api.close()
  256. return {
  257. "success": True,
  258. "rooms": rooms_data,
  259. "satellites": satellites,
  260. }
  261. except Exception as e:
  262. if temp_api:
  263. await temp_api.close()
  264. return {"error": f"Fehler beim Laden: {e}"}
  265. async def _save_room_config(self, params: dict) -> dict:
  266. """Speichert Raum-Konfiguration (Aliases, Satellite-Zuordnung)."""
  267. room_name = params.get("room_name", "").lower()
  268. aliases_str = params.get("aliases", "")
  269. satellite = params.get("satellite", "")
  270. if not room_name:
  271. return {"error": "Kein Raum angegeben"}
  272. # Aliases aktualisieren
  273. room_aliases = self.config.get("room_aliases", {})
  274. # Alte Aliases fuer diesen Raum entfernen
  275. old_aliases = [k for k, v in room_aliases.items() if v == room_name and not k.startswith("_")]
  276. for old in old_aliases:
  277. del room_aliases[old]
  278. # Neue Aliases setzen
  279. if aliases_str.strip():
  280. for alias in aliases_str.split(","):
  281. alias = alias.strip().lower()
  282. if alias and alias != room_name:
  283. room_aliases[alias] = room_name
  284. self.config["room_aliases"] = room_aliases
  285. # Satellite-Zuordnung aktualisieren
  286. satellite_rooms = self.config.get("satellite_rooms", {})
  287. # Alte Zuordnung fuer diesen Raum entfernen
  288. old_sats = [k for k, v in satellite_rooms.items() if v == room_name]
  289. for old in old_sats:
  290. del satellite_rooms[old]
  291. # Neue Zuordnung setzen
  292. if satellite:
  293. satellite_rooms[satellite] = room_name
  294. self.config["satellite_rooms"] = satellite_rooms
  295. # Config speichern
  296. self.save_config()
  297. # DeviceManager aktualisieren
  298. if self._devices:
  299. self._devices._room_aliases = room_aliases
  300. self._devices._satellite_rooms = satellite_rooms
  301. return {"success": True, "message": f"Raum '{room_name}' gespeichert"}
  302. async def _get_devices_data(self) -> dict:
  303. """Liefert Geraete-Uebersicht fuer das ConfigTool."""
  304. api = self._api
  305. temp_api = None
  306. if not api or not api.session_id:
  307. hm_cfg = self.config.get("homematic", {})
  308. sid = hm_cfg.get("session_id", "")
  309. url = hm_cfg.get("url", "")
  310. if not sid or not url:
  311. return {"error": "Kein Token konfiguriert"}
  312. temp_api = HomematicApi(base_url=url, session_id=sid, timeout=10)
  313. api = temp_api
  314. try:
  315. from plugins.homematic.device_types import classify_device
  316. devices = await api.get_device_list()
  317. rooms = await api.get_room_list()
  318. # Channel → Raum Mapping
  319. ch_to_room: dict[str, str] = {}
  320. for room in rooms:
  321. for ch_id in room.channel_ids:
  322. ch_to_room[ch_id] = room.name
  323. devices_data: list[dict] = []
  324. for dev in sorted(devices, key=lambda d: d.name):
  325. info = classify_device(dev.device_type)
  326. # Raum bestimmen (erster Channel der einem Raum zugeordnet ist)
  327. dev_room = ""
  328. for ch in dev.channels.values():
  329. if ch.ise_id in ch_to_room:
  330. dev_room = ch_to_room[ch.ise_id]
  331. break
  332. ch_names = [ch.name for ch in dev.channels.values()
  333. if ch.visible == "true"]
  334. devices_data.append({
  335. "label": f"{dev.name} [{info.category.value}]"
  336. + (f" — {dev_room}" if dev_room else ""),
  337. "device_name": dev.name,
  338. "device_type": dev.device_type,
  339. "category": info.category.value,
  340. "room": dev_room or "—",
  341. "channels": ", ".join(ch_names) if ch_names else "—",
  342. })
  343. if temp_api:
  344. await temp_api.close()
  345. return {"success": True, "devices": devices_data}
  346. except Exception as e:
  347. if temp_api:
  348. await temp_api.close()
  349. return {"error": f"Fehler: {e}"}
  350. async def _get_status_data(self) -> dict:
  351. """Liefert Live-Status als Tabelle fuer das ConfigTool.
  352. Jede Zeile: name, value, connection (markup), battery (markup)
  353. """
  354. api = self._api
  355. temp_api = None
  356. if not api or not api.session_id:
  357. hm_cfg = self.config.get("homematic", {})
  358. sid = hm_cfg.get("session_id", "")
  359. url = hm_cfg.get("url", "")
  360. if not sid or not url:
  361. return {"error": "Kein Token konfiguriert"}
  362. temp_api = HomematicApi(base_url=url, session_id=sid, timeout=10)
  363. api = temp_api
  364. try:
  365. devices = await api.get_state_list()
  366. rows: list[dict[str, str]] = []
  367. for dev in sorted(devices, key=lambda d: d.name):
  368. # UNREACH und LOWBAT aus Channel 0
  369. unreach = False
  370. lowbat = False
  371. battery_voltage = ""
  372. for ch in dev.channels.values():
  373. ur = ch.datapoints.get("UNREACH")
  374. if ur:
  375. unreach = ur.value_bool
  376. lb = ch.datapoints.get("LOWBAT")
  377. if lb and lb.value_bool:
  378. lowbat = True
  379. bs = ch.datapoints.get("BATTERY_STATE")
  380. if bs and bs.value:
  381. battery_voltage = bs.value
  382. # Wert bestimmen (je nach Geraetetyp)
  383. value = ""
  384. # LED-RGBW: Farbe + Level sammeln
  385. led_level: float | None = None
  386. led_color: int | None = None
  387. for ch in dev.channels.values():
  388. color_dp = ch.datapoints.get("COLOR")
  389. if color_dp and color_dp.value:
  390. try:
  391. led_color = int(float(color_dp.value))
  392. except (ValueError, TypeError):
  393. pass
  394. level_dp = ch.datapoints.get("LEVEL")
  395. if level_dp and level_dp.value:
  396. name_l = ch.name.lower()
  397. if "dimm" in name_l or "level" in name_l or ch.index == "1":
  398. led_level = level_dp.value_float
  399. for ch in dev.channels.values():
  400. actual = ch.datapoints.get("ACTUAL_TEMPERATURE")
  401. target = ch.datapoints.get("SET_TEMPERATURE")
  402. state = ch.datapoints.get("STATE")
  403. level = ch.datapoints.get("LEVEL")
  404. if actual and target:
  405. value = f"{actual.value_float:.1f}°C (Soll: {target.value_float:.1f}°C)"
  406. break
  407. elif state and ch.visible == "true":
  408. if state.value_bool:
  409. value = "[bold]AN[/bold]" if "schalter" in ch.name.lower() or "licht" in ch.name.lower() else "OFFEN"
  410. else:
  411. value = "aus" if "schalter" in ch.name.lower() or "licht" in ch.name.lower() else "geschlossen"
  412. break
  413. elif level and ch.visible == "true":
  414. pct = level.value_float * 100
  415. value = f"{pct:.0f}%"
  416. break
  417. # LED-RGBW: Farbblock anhaengen
  418. if led_color is not None:
  419. import colorsys
  420. if led_color >= 200:
  421. r, g, b = 255, 255, 255
  422. else:
  423. hue = led_color / 200.0
  424. rf, gf, bf = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
  425. r, g, b = int(rf * 255), int(gf * 255), int(bf * 255)
  426. pct = int((led_level or 0) * 100)
  427. color_block = f"[rgb({r},{g},{b})]████[/rgb({r},{g},{b})]"
  428. value = f"{pct}% {color_block}"
  429. # Connection (farbig)
  430. if unreach:
  431. connection = "[red]offline[/red]"
  432. else:
  433. connection = "[green]online[/green]"
  434. # Batterie (farbig)
  435. battery = ""
  436. if battery_voltage:
  437. try:
  438. volts = float(battery_voltage)
  439. # HM-CC-RT-DN: 2x AA, 3.0V voll, ~1.8V leer
  440. if volts <= 1.8:
  441. battery = f"[red]{volts:.1f}V LEER[/red]"
  442. elif volts <= 2.2:
  443. battery = f"[yellow]{volts:.1f}V[/yellow]"
  444. else:
  445. battery = f"[green]{volts:.1f}V[/green]"
  446. except ValueError:
  447. battery = battery_voltage
  448. elif lowbat:
  449. battery = "[red]LOW[/red]"
  450. else:
  451. # Kein Batterie-Geraet
  452. battery = "[dim]—[/dim]"
  453. rows.append({
  454. "name": dev.name,
  455. "value": value or "[dim]—[/dim]",
  456. "connection": connection,
  457. "battery": battery,
  458. })
  459. if temp_api:
  460. await temp_api.close()
  461. return {"success": True, "rows": rows}
  462. except Exception as e:
  463. if temp_api:
  464. await temp_api.close()
  465. return {"error": f"Fehler: {e}"}
  466. async def _test_connection(self, params: dict) -> dict:
  467. """Testet die Verbindung zur Homematic CCU.
  468. Prueft:
  469. 1. URL erreichbar?
  470. 2. Session-ID gueltig? (oder Login moeglich?)
  471. 3. API funktionsfaehig? (devicelist abrufen)
  472. """
  473. url = params.get("url", "")
  474. session_id = params.get("session_id", "")
  475. username = params.get("username", "")
  476. password = params.get("password", "")
  477. # Aktuelle Config als Fallback
  478. if not url:
  479. hm_cfg = self.config.get("homematic", {})
  480. url = hm_cfg.get("url", "")
  481. session_id = session_id or hm_cfg.get("session_id", "")
  482. username = username or hm_cfg.get("username", "")
  483. password = password or hm_cfg.get("password", "")
  484. if not url:
  485. return {"error": "Keine URL konfiguriert"}
  486. has_login = bool(username and password)
  487. has_token = bool(session_id)
  488. import aiohttp
  489. from plugins.homematic.api import HomematicApi
  490. test_api = HomematicApi(base_url=url, session_id=session_id, timeout=10)
  491. try:
  492. # 1. Erreichbarkeit pruefen (version.cgi braucht keine Auth)
  493. try:
  494. version = await test_api.get_version()
  495. except aiohttp.ClientError:
  496. await test_api.close()
  497. return {"error": f"CCU nicht erreichbar unter {url}"}
  498. except Exception as e:
  499. await test_api.close()
  500. return {"error": f"CCU nicht erreichbar: {e}"}
  501. if not version:
  502. await test_api.close()
  503. return {"error": f"Keine Antwort von {url}"}
  504. results: list[str] = [f"CCU erreichbar, XML-API v{version}"]
  505. # 2. Login pruefen (JSON-API, unabhaengig vom Token)
  506. if has_login:
  507. login_api = HomematicApi(
  508. base_url=url, username=username, password=password, timeout=10,
  509. )
  510. try:
  511. login_ok = await login_api.login()
  512. if login_ok:
  513. results.append("Login OK")
  514. else:
  515. results.append("Login fehlgeschlagen — ungueltige Zugangsdaten")
  516. except Exception:
  517. results.append("Login fehlgeschlagen — ungueltige Zugangsdaten")
  518. await login_api.close()
  519. # 3. Token/Session pruefen (XML-API)
  520. if has_token:
  521. try:
  522. devices = await test_api.get_device_list()
  523. rooms = await test_api.get_room_list()
  524. if devices or rooms:
  525. results.append(f"Token OK — {len(devices)} Geraete, {len(rooms)} Raeume")
  526. else:
  527. results.append("Token ungueltig — keine Berechtigung")
  528. await test_api.close()
  529. return {"error": " | ".join(results)}
  530. except Exception:
  531. results.append("Token ungueltig")
  532. await test_api.close()
  533. return {"error": " | ".join(results)}
  534. if not has_login and not has_token:
  535. await test_api.close()
  536. return {
  537. "success": True,
  538. "message": " | ".join(results)
  539. + " | Kein Token konfiguriert — bitte im CCU WebUI unter "
  540. "Einstellungen > Sicherheit einen XML-API Token erstellen",
  541. }
  542. await test_api.close()
  543. # Gesamtergebnis
  544. has_error = any("fehlgeschlagen" in r or "ungueltig" in r for r in results)
  545. if has_error:
  546. return {"error": " | ".join(results)}
  547. return {"success": True, "message": " | ".join(results)}
  548. except aiohttp.ClientError:
  549. await test_api.close()
  550. return {"error": f"CCU nicht erreichbar unter {url}"}
  551. except Exception as e:
  552. await test_api.close()
  553. return {"error": f"Verbindungsfehler: {e}"}
  554. def get_config_tool_tabs(self) -> list[dict]:
  555. """Stellt benutzerdefinierte Tabs fuer das ConfigTool bereit."""
  556. return [
  557. {
  558. "title": "Einstellungen",
  559. "description": "Homematic CCU Verbindung und Raum-Zuordnung",
  560. "schema": {
  561. "config_section": "homematic",
  562. "fields": [
  563. {
  564. "name": "url",
  565. "label": "CCU URL",
  566. "type": "text",
  567. "default": "http://homematic-raspi",
  568. "help": "URL der Homematic CCU (z.B. http://192.168.1.100)",
  569. },
  570. {
  571. "name": "username",
  572. "label": "Benutzername",
  573. "type": "text",
  574. "help": "CCU Login-Benutzername (leer = keine Authentifizierung)",
  575. },
  576. {
  577. "name": "password",
  578. "label": "Passwort",
  579. "type": "password",
  580. "help": "CCU Login-Passwort",
  581. },
  582. {
  583. "name": "session_id",
  584. "label": "Session-ID",
  585. "type": "text",
  586. "help": "Manuelle Session-ID (alternativ zu Login)",
  587. },
  588. {
  589. "name": "timeout",
  590. "label": "Timeout (Sek.)",
  591. "type": "number",
  592. "default": 30,
  593. "help": "HTTP-Timeout fuer API-Anfragen",
  594. },
  595. {
  596. "name": "cache_ttl",
  597. "label": "Cache-Dauer (Sek.)",
  598. "type": "number",
  599. "default": 300,
  600. "help": "Wie lange Geraetedaten gecacht werden",
  601. },
  602. {
  603. "name": "monitor_interval",
  604. "label": "Monitor-Intervall (Sek.)",
  605. "type": "number",
  606. "default": 120,
  607. "help": "Abfrage-Intervall fuer Geraeteueberwachung (0 = aus)",
  608. },
  609. {
  610. "name": "warn_battery_low",
  611. "label": "Warnung bei schwacher Batterie",
  612. "type": "checkbox",
  613. "default": True,
  614. },
  615. {
  616. "name": "warn_connection_changed",
  617. "label": "Warnung bei Verbindungsverlust",
  618. "type": "checkbox",
  619. "default": True,
  620. },
  621. {
  622. "name": "warn_status_changed",
  623. "label": "Warnung bei Statusaenderung",
  624. "type": "checkbox",
  625. "default": False,
  626. "help": "Meldet Aenderungen an Fenster/Tuer-Kontakten, Schaltern, etc.",
  627. },
  628. ],
  629. "actions": [
  630. {
  631. "id": "test_connection",
  632. "label": "Verbindung pruefen",
  633. "variant": "primary",
  634. "collect_fields": True,
  635. "help": "Testet die Verbindung mit den aktuellen Einstellungen",
  636. },
  637. ],
  638. },
  639. },
  640. {
  641. "title": "Raeume",
  642. "description": "Homematic-Raeume mit Satellite-Zuordnung und Aliases",
  643. "view_type": "interactive_list",
  644. "load_action": "get_rooms_data",
  645. "save_action": "save_room_config",
  646. "list_key": "rooms",
  647. "list_label_field": "name",
  648. "list_subtitle_field": "channel_count",
  649. "list_subtitle_suffix": " Geraete",
  650. "detail_fields": [
  651. {
  652. "name": "aliases",
  653. "label": "Aliases (kommagetrennt)",
  654. "type": "text",
  655. "help": "Alternative Namen fuer diesen Raum (z.B. bad, bathroom)",
  656. "value_from": "aliases_str",
  657. },
  658. {
  659. "name": "satellite",
  660. "label": "Satellite in diesem Raum",
  661. "type": "select",
  662. "choices_from": "satellites",
  663. "help": "Welcher Satellite steht in diesem Raum?",
  664. },
  665. ],
  666. },
  667. {
  668. "title": "Geraete",
  669. "view_type": "interactive_list",
  670. "load_action": "get_devices_data",
  671. "list_key": "devices",
  672. "list_label_field": "label",
  673. "detail_fields": [
  674. {"name": "device_name", "label": "Geraet", "type": "readonly"},
  675. {"name": "device_type", "label": "Typ", "type": "readonly"},
  676. {"name": "category", "label": "Kategorie", "type": "readonly"},
  677. {"name": "room", "label": "Raum", "type": "readonly"},
  678. {"name": "channels", "label": "Channels", "type": "readonly"},
  679. ],
  680. },
  681. {
  682. "title": "Status",
  683. "view_type": "table",
  684. "load_action": "get_status_data",
  685. "columns": [
  686. {"key": "name", "label": "Name", "width": 35},
  687. {"key": "value", "label": "Wert", "width": 30},
  688. {"key": "connection", "label": "Verbindung", "width": 12},
  689. {"key": "battery", "label": "Batterie", "width": 12},
  690. ],
  691. },
  692. ]
  693. def _register_room_entities(self) -> None:
  694. """Registriert CCU-Raeume und Aliases als Entity-Werte fuer die NLP-Erkennung."""
  695. if not self._devices:
  696. return
  697. try:
  698. from trixy_core.nlp.entities import EntityRegistry
  699. # EntityRegistry aus dem NLP-Plugin holen
  700. entity_registry: EntityRegistry | None = None
  701. if hasattr(self.application, "plugins"):
  702. for name in ("nlp_llm", "nlp_classifier"):
  703. plugin = self.application.plugins.get(name)
  704. if plugin and hasattr(plugin, "_entity_registry"):
  705. entity_registry = plugin._entity_registry
  706. break
  707. if not entity_registry:
  708. return
  709. # CCU-Raumnamen registrieren
  710. room_names = [r.lower() for r in self._devices.get_room_names()]
  711. entity_registry.add_values("raum", room_names)
  712. # Aliases registrieren
  713. aliases = list(self._devices._room_aliases.keys())
  714. entity_registry.add_values("raum", aliases)
  715. pdebug(f"[Homematic] {len(room_names)} Raeume + {len(aliases)} Aliases als Entities registriert")
  716. except Exception as e:
  717. pdebug(f"[Homematic] Entity-Registrierung fehlgeschlagen: {e}")
  718. async def _refresh_programs_sysvars(self) -> None:
  719. """Laedt Programme und Systemvariablen."""
  720. if not self._api:
  721. return
  722. import asyncio
  723. programs, sysvars = await asyncio.gather(
  724. self._api.get_program_list(),
  725. self._api.get_sysvar_list(),
  726. )
  727. self._programs = {p.name.lower(): p for p in programs}
  728. self._sysvars = {s.name.lower(): s for s in sysvars}
  729. def _get_room(self, data: IntentReceivedData) -> str:
  730. """Extrahiert den Raum aus den Slots oder loest ihn ueber den Satellite auf.
  731. Reihenfolge:
  732. 1. Expliziter Raum-Slot ("mach das Licht im Wohnzimmer an")
  733. 2. Satellite-Room-Mapping (Satellite steht im Wohnzimmer → "wohnzimmer")
  734. """
  735. raum = data.get_slot("raum", "")
  736. if raum:
  737. return raum
  738. # Satellite-basierte Raum-Aufloesung
  739. if self._devices and (data.satellite_id or data.room_id):
  740. # Satellite-Alias aus Metadaten holen
  741. alias = ""
  742. if data.session and hasattr(data.session, "satellite"):
  743. sat = data.session.satellite
  744. if sat and hasattr(sat, "alias"):
  745. alias = sat.alias or ""
  746. resolved = self._devices.resolve_room_for_satellite(
  747. satellite_id=data.satellite_id,
  748. room_id=data.room_id,
  749. alias=alias,
  750. )
  751. if resolved:
  752. return resolved
  753. return ""
  754. # ========================================================================
  755. # Intent-Handler: Licht
  756. # ========================================================================
  757. @intent(
  758. "homematic_licht_schalten",
  759. description="Licht ein-/ausschalten",
  760. patterns=[
  761. "mach das licht im {raum} {aktion}",
  762. "mach das licht {aktion} im {raum}",
  763. "mach {raum} licht {aktion}",
  764. "schalte das licht im {raum} {aktion}",
  765. "schalte licht im {raum} {aktion}",
  766. "{raum} licht {aktion}",
  767. "licht im {raum} {aktion}",
  768. "licht {aktion} im {raum}",
  769. "mach licht {aktion}",
  770. "licht {aktion}",
  771. ],
  772. )
  773. async def handle_licht_schalten(self, data: IntentReceivedData) -> IntentResult:
  774. """Schaltet Licht in einem Raum ein oder aus."""
  775. await self._devices.ensure_cache()
  776. raum = self._get_room(data)
  777. aktion = data.get_slot("aktion", "").lower()
  778. if not raum:
  779. return IntentResult.failure("missing_room", "In welchem Raum?")
  780. if not self._devices.room_exists(raum):
  781. return IntentResult.failure("unknown_room", f"Raum '{raum}' nicht gefunden.")
  782. einschalten = aktion in ("an", "ein", "einschalten", "on")
  783. switched = await self._devices.switch_lights(raum, einschalten)
  784. if switched == 0:
  785. return IntentResult.failure("no_light", f"Kein Licht im {raum} gefunden.")
  786. raum_display = self._devices.normalize_room(raum).title()
  787. aktion_text = "eingeschaltet" if einschalten else "ausgeschaltet"
  788. return IntentResult.success_with_response(f"Licht im {raum_display} {aktion_text}.")
  789. @intent(
  790. "homematic_licht_status",
  791. description="Licht-Status abfragen",
  792. patterns=[
  793. "ist das licht im {raum} an",
  794. "ist licht im {raum} an",
  795. "ist im {raum} licht an",
  796. "brennt im {raum} licht",
  797. "brennt das licht im {raum}",
  798. ],
  799. )
  800. async def handle_licht_status(self, data: IntentReceivedData) -> IntentResult:
  801. """Fragt ab ob Licht in einem Raum an ist."""
  802. await self._devices.ensure_cache()
  803. raum = self._get_room(data)
  804. if not raum:
  805. return IntentResult.failure("missing_room", "In welchem Raum?")
  806. if not self._devices.room_exists(raum):
  807. return IntentResult.failure("unknown_room", f"Raum '{raum}' nicht gefunden.")
  808. states = await self._devices.get_light_states(raum)
  809. raum_display = self._devices.normalize_room(raum).title()
  810. if not states:
  811. return IntentResult.failure("no_light", f"Kein Licht im {raum_display} gefunden.")
  812. lights_on = [n for n, on in states.items() if on]
  813. if lights_on:
  814. return IntentResult.success_with_response(
  815. f"Im {raum_display} ist Licht an: {', '.join(lights_on)}."
  816. )
  817. return IntentResult.success_with_response(f"Im {raum_display} ist kein Licht an.")
  818. # ========================================================================
  819. # Intent-Handler: LED / Farbe
  820. # ========================================================================
  821. @intent(
  822. "homematic_led_farbe",
  823. description="LED-Stripe Farbe aendern",
  824. patterns=[
  825. "mach die leds im {raum} {farbe}",
  826. "mach die led im {raum} {farbe}",
  827. "stelle die leds im {raum} auf {farbe}",
  828. "led {raum} {farbe}",
  829. "leds im {raum} {farbe}",
  830. "aendere die led farbe im {raum} auf {farbe}",
  831. "ändere die led farbe im {raum} auf {farbe}",
  832. ],
  833. )
  834. async def handle_led_farbe(self, data: IntentReceivedData) -> IntentResult:
  835. """Aendert die Farbe von LED-Stripes in einem Raum."""
  836. await self._devices.ensure_cache()
  837. raum = self._get_room(data)
  838. farbe = data.get_slot("farbe", "").lower()
  839. if not raum:
  840. return IntentResult.failure("missing_room", "In welchem Raum?")
  841. if not farbe:
  842. return IntentResult.failure("missing_color", "Welche Farbe?")
  843. color_value = COLOR_MAP.get(farbe)
  844. if color_value is None:
  845. verfuegbar = ", ".join(sorted({k for k in COLOR_MAP if not any(c in k for c in "üöä")}))
  846. return IntentResult.failure("unknown_color", f"Farbe '{farbe}' unbekannt. Verfuegbar: {verfuegbar}")
  847. changed = await self._devices.set_led_color(raum, color_value)
  848. if changed == 0:
  849. return IntentResult.failure("no_led", f"Keine LED-Stripes im {raum} gefunden.")
  850. raum_display = self._devices.normalize_room(raum).title()
  851. return IntentResult.success_with_response(f"LEDs im {raum_display} auf {farbe.title()} gesetzt.")
  852. @intent(
  853. "homematic_led_dimmen",
  854. description="LED-Stripe Helligkeit setzen",
  855. patterns=[
  856. "dimme die leds im {raum} auf {prozent}",
  857. "setze die led helligkeit im {raum} auf {prozent}",
  858. "leds im {raum} auf {prozent} prozent",
  859. "led {raum} {prozent} prozent",
  860. ],
  861. )
  862. async def handle_led_dimmen(self, data: IntentReceivedData) -> IntentResult:
  863. """Setzt die Helligkeit von LED-Stripes."""
  864. await self._devices.ensure_cache()
  865. raum = self._get_room(data)
  866. prozent = data.get_slot("prozent", "")
  867. if not raum:
  868. return IntentResult.failure("missing_room", "In welchem Raum?")
  869. try:
  870. level = float(prozent) / 100.0
  871. level = max(0.0, min(1.0, level))
  872. except (ValueError, TypeError):
  873. return IntentResult.failure("invalid_level", "Ungueltiger Prozentwert.")
  874. changed = await self._devices.set_led_level(raum, level)
  875. if changed == 0:
  876. return IntentResult.failure("no_led", f"Kein LED-Dimmer im {raum} gefunden.")
  877. raum_display = self._devices.normalize_room(raum).title()
  878. return IntentResult.success_with_response(f"LEDs im {raum_display} auf {int(level * 100)} Prozent.")
  879. # ========================================================================
  880. # Intent-Handler: Heizung / Temperatur
  881. # ========================================================================
  882. @intent(
  883. "homematic_temperatur_abfragen",
  884. description="Aktuelle Temperatur abfragen",
  885. patterns=[
  886. "wie warm ist es im {raum}",
  887. "welche temperatur hat es im {raum}",
  888. "temperatur im {raum}",
  889. "wie viel grad ist es im {raum}",
  890. "wie viel grad hat es im {raum}",
  891. "wie kalt ist es im {raum}",
  892. ],
  893. )
  894. async def handle_temperatur_abfragen(self, data: IntentReceivedData) -> IntentResult:
  895. """Fragt die aktuelle Temperatur in einem Raum ab."""
  896. await self._devices.ensure_cache()
  897. raum = self._get_room(data)
  898. if not raum:
  899. return IntentResult.failure("missing_room", "In welchem Raum?")
  900. temps = await self._devices.get_temperatures(raum)
  901. if not temps:
  902. return IntentResult.failure("no_thermostat", f"Kein Thermostat im {raum} gefunden.")
  903. raum_display = self._devices.normalize_room(raum).title()
  904. if len(temps) == 1:
  905. t = temps[0]
  906. return IntentResult.success_with_response(
  907. f"Im {raum_display} sind es {t['actual']:.1f} Grad."
  908. )
  909. avg = sum(t["actual"] for t in temps) / len(temps)
  910. details = ", ".join(f"{t['name']}: {t['actual']:.1f} Grad" for t in temps)
  911. return IntentResult.success_with_response(
  912. f"Im {raum_display} sind es durchschnittlich {avg:.1f} Grad. {details}"
  913. )
  914. @intent(
  915. "homematic_heizung_abfragen",
  916. description="Heizungs-Solltemperatur abfragen",
  917. patterns=[
  918. "auf wie viel grad ist die heizung im {raum} eingestellt",
  919. "auf wie viel grad steht die heizung im {raum}",
  920. "wie hoch ist die heizung im {raum} eingestellt",
  921. "heizung im {raum}",
  922. "was ist die soll temperatur im {raum}",
  923. "auf wie viel grad ist die heizung eingestellt",
  924. ],
  925. )
  926. async def handle_heizung_abfragen(self, data: IntentReceivedData) -> IntentResult:
  927. """Fragt die Soll-Temperatur der Heizung ab."""
  928. await self._devices.ensure_cache()
  929. raum = self._get_room(data)
  930. temps = await self._devices.get_temperatures(raum)
  931. if not temps:
  932. return IntentResult.failure("no_thermostat", "Kein Thermostat gefunden.")
  933. if raum:
  934. raum_display = self._devices.normalize_room(raum).title()
  935. parts = [f"{t['name']}: {t['actual']:.1f} Grad, eingestellt auf {t['target']:.1f} Grad" for t in temps]
  936. return IntentResult.success_with_response(f"Heizung im {raum_display}: {'; '.join(parts)}.")
  937. else:
  938. parts = [f"{t['name']} {t['target']:.1f} Grad" for t in temps]
  939. return IntentResult.success_with_response(f"Heizungseinstellungen: {', '.join(parts)}.")
  940. @intent(
  941. "homematic_heizung_setzen",
  942. description="Heizungstemperatur einstellen",
  943. patterns=[
  944. "stelle die heizung im {raum} auf {temperatur} grad",
  945. "setze die heizung im {raum} auf {temperatur} grad",
  946. "heizung im {raum} auf {temperatur} grad",
  947. "heizung {raum} auf {temperatur}",
  948. "stelle die heizung auf {temperatur} grad",
  949. "heizung auf {temperatur} grad",
  950. ],
  951. )
  952. async def handle_heizung_setzen(self, data: IntentReceivedData) -> IntentResult:
  953. """Setzt die Soll-Temperatur der Heizung."""
  954. await self._devices.ensure_cache()
  955. raum = self._get_room(data)
  956. temperatur = data.get_slot("temperatur", "")
  957. try:
  958. temp = float(temperatur)
  959. except (ValueError, TypeError):
  960. return IntentResult.failure("invalid_temp", "Ungueltige Temperatur.")
  961. if temp < 4.5 or temp > 30.5:
  962. return IntentResult.failure(
  963. "temp_out_of_range",
  964. f"{temp} Grad ist ausserhalb des Bereichs (4,5 bis 30,5 Grad)."
  965. )
  966. if raum:
  967. changed = await self._devices.set_temperature(raum, temp)
  968. raum_display = self._devices.normalize_room(raum).title()
  969. if changed == 0:
  970. return IntentResult.failure("no_thermostat", f"Kein Thermostat im {raum_display} gefunden.")
  971. return IntentResult.success_with_response(f"Heizung im {raum_display} auf {temp:.1f} Grad gestellt.")
  972. else:
  973. # Alle Raeume
  974. total = 0
  975. for room_name in self._devices.get_room_names():
  976. total += await self._devices.set_temperature(room_name, temp)
  977. if total == 0:
  978. return IntentResult.failure("no_thermostat", "Kein Thermostat gefunden.")
  979. return IntentResult.success_with_response(f"Alle Heizungen auf {temp:.1f} Grad gestellt.")
  980. # ========================================================================
  981. # Intent-Handler: Fenster / Tuer
  982. # ========================================================================
  983. @intent(
  984. "homematic_fenster_status",
  985. description="Fenster/Tuer-Status abfragen",
  986. patterns=[
  987. "ist das fenster im {raum} offen",
  988. "ist das fenster im {raum} auf",
  989. "ist die tür im {raum} offen",
  990. "sind die fenster offen",
  991. "sind fenster offen",
  992. "welche fenster sind offen",
  993. "ist ein fenster offen",
  994. "fenster status",
  995. ],
  996. )
  997. async def handle_fenster_status(self, data: IntentReceivedData) -> IntentResult:
  998. """Fragt den Status von Fenstern und Tueren ab."""
  999. await self._devices.ensure_cache()
  1000. raum = data.get_slot("raum", "")
  1001. windows = await self._devices.get_window_states(raum)
  1002. if raum:
  1003. raum_display = self._devices.normalize_room(raum).title()
  1004. if not windows:
  1005. return IntentResult.failure("no_sensor", f"Kein Fenster-/Tuersensor im {raum_display}.")
  1006. offen = [n for n, o in windows.items() if o]
  1007. if offen:
  1008. return IntentResult.success_with_response(f"Im {raum_display} offen: {', '.join(offen)}.")
  1009. return IntentResult.success_with_response(f"Im {raum_display} ist alles geschlossen.")
  1010. else:
  1011. offen = [n for n, o in windows.items() if o]
  1012. if offen:
  1013. return IntentResult.success_with_response(f"{len(offen)} offen: {', '.join(offen)}.")
  1014. return IntentResult.success_with_response("Alle Fenster und Tueren sind geschlossen.")
  1015. # ========================================================================
  1016. # Intent-Handler: Bewegungsmelder
  1017. # ========================================================================
  1018. @intent(
  1019. "homematic_bewegung_status",
  1020. description="Letzte Bewegung abfragen",
  1021. patterns=[
  1022. "wann wurde zuletzt eine bewegung im {raum} erkannt",
  1023. "wann war die letzte bewegung im {raum}",
  1024. "bewegungsmelder {raum}",
  1025. "gab es eine bewegung im {raum}",
  1026. ],
  1027. )
  1028. async def handle_bewegung_status(self, data: IntentReceivedData) -> IntentResult:
  1029. """Fragt den letzten Bewegungszeitpunkt ab."""
  1030. await self._devices.ensure_cache()
  1031. raum = self._get_room(data)
  1032. if not raum:
  1033. return IntentResult.failure("missing_room", "In welchem Raum?")
  1034. motion = await self._devices.get_last_motion(raum)
  1035. raum_display = self._devices.normalize_room(raum).title()
  1036. if not motion:
  1037. return IntentResult.failure("no_motion_sensor", f"Kein Bewegungsmelder im {raum_display}.")
  1038. ts = motion["timestamp"]
  1039. if ts > 0:
  1040. dt = datetime.datetime.fromtimestamp(ts)
  1041. now = datetime.datetime.now()
  1042. diff = (now - dt).total_seconds()
  1043. if diff < 60:
  1044. zeit_text = "vor wenigen Sekunden"
  1045. elif diff < 3600:
  1046. zeit_text = f"vor {int(diff / 60)} Minuten"
  1047. elif diff < 86400:
  1048. zeit_text = f"vor {int(diff / 3600)} Stunden"
  1049. else:
  1050. zeit_text = f"am {dt.strftime('%d.%m. um %H:%M')}"
  1051. return IntentResult.success_with_response(f"Letzte Bewegung im {raum_display}: {zeit_text}.")
  1052. return IntentResult.failure("no_data", f"Keine Bewegungsdaten im {raum_display}.")
  1053. # ========================================================================
  1054. # Intent-Handler: Programme
  1055. # ========================================================================
  1056. @intent(
  1057. "homematic_programm_ausfuehren",
  1058. description="CCU-Programm ausfuehren",
  1059. patterns=[
  1060. "fuehre das programm {programm} aus",
  1061. "führe das programm {programm} aus",
  1062. "starte programm {programm}",
  1063. "starte das programm {programm}",
  1064. "programm {programm} starten",
  1065. "aktiviere {programm}",
  1066. ],
  1067. )
  1068. async def handle_programm_ausfuehren(self, data: IntentReceivedData) -> IntentResult:
  1069. """Fuehrt ein CCU-Programm aus."""
  1070. programm = data.get_slot("programm", "").lower()
  1071. if not programm:
  1072. return IntentResult.failure("missing_program", "Welches Programm?")
  1073. # Exakt oder Teilmatch
  1074. prog = self._programs.get(programm)
  1075. if not prog:
  1076. for name, p in self._programs.items():
  1077. if programm in name:
  1078. prog = p
  1079. break
  1080. if not prog:
  1081. return IntentResult.failure("unknown_program", f"Programm '{programm}' nicht gefunden.")
  1082. try:
  1083. success = await self._api.run_program(prog.ise_id)
  1084. if success:
  1085. return IntentResult.success_with_response(f"Programm '{prog.name}' gestartet.")
  1086. return IntentResult.failure("program_failed", f"Programm '{prog.name}' konnte nicht gestartet werden.")
  1087. except Exception as e:
  1088. return IntentResult.failure("error", f"Fehler: {e}")
  1089. # ========================================================================
  1090. # Intent-Handler: Systemvariablen
  1091. # ========================================================================
  1092. @intent(
  1093. "homematic_sysvar_abfragen",
  1094. description="Systemvariable abfragen",
  1095. patterns=[
  1096. "wie ist der status von {variable}",
  1097. "was ist {variable}",
  1098. "systemvariable {variable}",
  1099. "homematic variable {variable}",
  1100. "sind wir {variable}",
  1101. ],
  1102. )
  1103. async def handle_sysvar_abfragen(self, data: IntentReceivedData) -> IntentResult:
  1104. """Fragt eine Systemvariable ab."""
  1105. variable = data.get_slot("variable", "").lower()
  1106. if not variable:
  1107. return IntentResult.failure("missing_var", "Welche Variable?")
  1108. sv = self._sysvars.get(variable)
  1109. if not sv:
  1110. for name, s in self._sysvars.items():
  1111. if variable in name:
  1112. sv = s
  1113. break
  1114. if not sv:
  1115. return IntentResult.failure("unknown_var", f"Variable '{variable}' nicht gefunden.")
  1116. # Aktuellen Wert abrufen
  1117. try:
  1118. current = await self._api.get_sysvar(sv.ise_id)
  1119. if current:
  1120. sv = current
  1121. except Exception:
  1122. pass
  1123. if sv.var_type == "2": # Boolean
  1124. wert = sv.value_name_1 if sv.value_bool else sv.value_name_0
  1125. wert = wert or ("aktiv" if sv.value_bool else "inaktiv")
  1126. elif sv.unit:
  1127. wert = f"{sv.variable} {sv.unit}"
  1128. else:
  1129. wert = sv.variable or sv.value
  1130. return IntentResult.success_with_response(f"{sv.name}: {wert}")
  1131. # ========================================================================
  1132. # Intent-Handler: Allgemeiner Status
  1133. # ========================================================================
  1134. @intent(
  1135. "homematic_status",
  1136. description="Allgemeiner Smart Home Status",
  1137. patterns=[
  1138. "smart home status",
  1139. "homematic status",
  1140. "wie ist der hausstatus",
  1141. "haus status",
  1142. ],
  1143. )
  1144. async def handle_status(self, data: IntentReceivedData) -> IntentResult:
  1145. """Gibt einen allgemeinen Smart Home Status."""
  1146. await self._devices.ensure_cache()
  1147. parts: list[str] = []
  1148. # Fenster
  1149. try:
  1150. windows = await self._devices.get_window_states()
  1151. offen = [n for n, o in windows.items() if o]
  1152. if offen:
  1153. parts.append(f"{len(offen)} Fenster offen: {', '.join(offen)}")
  1154. else:
  1155. parts.append("Alle Fenster geschlossen")
  1156. except Exception:
  1157. pass
  1158. # Anwesenheit
  1159. anw = self._sysvars.get("anwesenheit")
  1160. if anw:
  1161. try:
  1162. current = await self._api.get_sysvar(anw.ise_id)
  1163. if current:
  1164. parts.append(current.value_name_1 if current.value_bool else current.value_name_0)
  1165. except Exception:
  1166. pass
  1167. # Durchschnittstemperatur
  1168. try:
  1169. temps = await self._devices.get_temperatures()
  1170. if temps:
  1171. avg = sum(t["actual"] for t in temps) / len(temps)
  1172. parts.append(f"Durchschnittstemperatur {avg:.1f} Grad")
  1173. except Exception:
  1174. pass
  1175. if not parts:
  1176. return IntentResult.failure("no_data", "Keine Statusdaten verfuegbar.")
  1177. return IntentResult.success_with_response(". ".join(parts) + ".")
  1178. # Plugin-Export
  1179. Plugin = HomematicPlugin