api.py 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069
  1. # -*- coding: utf-8 -*-
  2. """
  3. Homematic XML-API Wrapper.
  4. Vollstaendiger async Wrapper fuer die Homematic XML-API.
  5. Unterstuetzt alle Endpoints des XML-API Addons (v2.x).
  6. Verwendung:
  7. api = HomematicApi("http://homematic-raspi", session_id="xxx")
  8. devices = await api.get_device_list()
  9. await api.set_state(ise_id="1445", new_value="true")
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. from dataclasses import dataclass, field
  14. from typing import Any
  15. from xml.etree import ElementTree as ET
  16. import aiohttp
  17. from trixy_core.utils.debug import pdebug, perror, pinfo, pwarn
  18. # ============================================================================
  19. # Datenklassen
  20. # ============================================================================
  21. @dataclass
  22. class Datapoint:
  23. """Ein einzelner Datenpunkt eines Channels."""
  24. ise_id: str
  25. name: str
  26. type: str
  27. value: str
  28. valuetype: str = ""
  29. valueunit: str = ""
  30. timestamp: str = "0"
  31. operations: str = ""
  32. @property
  33. def value_bool(self) -> bool:
  34. """Wert als Boolean."""
  35. return self.value.lower() in ("true", "1")
  36. @property
  37. def value_float(self) -> float:
  38. """Wert als Float."""
  39. try:
  40. return float(self.value)
  41. except (ValueError, TypeError):
  42. return 0.0
  43. @property
  44. def value_int(self) -> int:
  45. """Wert als Integer."""
  46. try:
  47. return int(float(self.value))
  48. except (ValueError, TypeError):
  49. return 0
  50. @property
  51. def is_writable(self) -> bool:
  52. """Prueft ob der Datenpunkt beschreibbar ist (Bit 1 in operations)."""
  53. try:
  54. return bool(int(self.operations) & 2)
  55. except (ValueError, TypeError):
  56. return False
  57. @dataclass
  58. class Channel:
  59. """Ein Channel eines Geraets."""
  60. ise_id: str
  61. name: str
  62. index: str = ""
  63. direction: str = ""
  64. parent_device: str = ""
  65. address: str = ""
  66. channel_type: str = ""
  67. visible: str = ""
  68. operate: str = ""
  69. datapoints: dict[str, Datapoint] = field(default_factory=dict)
  70. @dataclass
  71. class Device:
  72. """Ein Homematic-Geraet."""
  73. ise_id: str
  74. name: str
  75. address: str = ""
  76. interface: str = ""
  77. device_type: str = ""
  78. ready_config: str = ""
  79. channels: dict[str, Channel] = field(default_factory=dict)
  80. unreach: str = ""
  81. config_pending: str = ""
  82. @dataclass
  83. class Room:
  84. """Ein Raum mit zugeordneten Channels."""
  85. ise_id: str
  86. name: str
  87. channel_ids: list[str] = field(default_factory=list)
  88. @dataclass
  89. class Program:
  90. """Ein CCU-Programm."""
  91. ise_id: str
  92. name: str
  93. active: str = ""
  94. timestamp: str = "0"
  95. description: str = ""
  96. visible: str = ""
  97. operate: str = ""
  98. @dataclass
  99. class SystemVariable:
  100. """Eine Systemvariable."""
  101. ise_id: str
  102. name: str
  103. variable: str = ""
  104. value: str = ""
  105. value_list: str = ""
  106. value_text: str = ""
  107. var_type: str = "" # type in XML
  108. subtype: str = ""
  109. unit: str = ""
  110. min_value: str = ""
  111. max_value: str = ""
  112. timestamp: str = "0"
  113. info: str = ""
  114. value_name_0: str = ""
  115. value_name_1: str = ""
  116. logged: str = ""
  117. @property
  118. def value_bool(self) -> bool:
  119. return self.value.lower() in ("true", "1")
  120. @property
  121. def value_float(self) -> float:
  122. try:
  123. return float(self.variable)
  124. except (ValueError, TypeError):
  125. return 0.0
  126. @dataclass
  127. class Favorite:
  128. """Ein Favorit / Favoritenliste."""
  129. ise_id: str
  130. name: str
  131. channels: list[dict[str, str]] = field(default_factory=list)
  132. @dataclass
  133. class RssiEntry:
  134. """RSSI-Wert eines Geraets."""
  135. device: str
  136. address: str
  137. rx: str = ""
  138. tx: str = ""
  139. @dataclass
  140. class ProtocolEntry:
  141. """Ein Protokolleintrag."""
  142. timestamp: str = ""
  143. name: str = ""
  144. value: str = ""
  145. info: str = ""
  146. @dataclass
  147. class Notification:
  148. """Eine Systembenachrichtigung."""
  149. ise_id: str = ""
  150. name: str = ""
  151. type: str = ""
  152. timestamp: str = ""
  153. message: str = ""
  154. # ============================================================================
  155. # API Wrapper
  156. # ============================================================================
  157. class HomematicApi:
  158. """Async Wrapper fuer die Homematic XML-API.
  159. Unterstuetzt alle Endpoints des XML-API Addons:
  160. - Geraete, Channels, Datenpunkte (devicelist, statelist, state, statechange)
  161. - Raeume (roomlist)
  162. - Programme (programlist, runprogram)
  163. - Systemvariablen (sysvarlist, sysvar)
  164. - Favoriten (favoritelist)
  165. - RSSI, Protokoll, Benachrichtigungen
  166. - Mastervalues, Token-Verwaltung
  167. - Version und Updates
  168. """
  169. def __init__(
  170. self,
  171. base_url: str,
  172. session_id: str = "",
  173. username: str = "",
  174. password: str = "",
  175. timeout: int = 30,
  176. ) -> None:
  177. self._base_url = base_url.rstrip("/")
  178. self._api_base = f"{self._base_url}/addons/xmlapi"
  179. self._session_id = session_id
  180. self._username = username
  181. self._password = password
  182. self._timeout = aiohttp.ClientTimeout(total=timeout)
  183. self._session: aiohttp.ClientSession | None = None
  184. # ========================================================================
  185. # Session-Management
  186. # ========================================================================
  187. async def _ensure_session(self) -> aiohttp.ClientSession:
  188. """Stellt sicher, dass eine aiohttp-Session existiert."""
  189. if self._session is None or self._session.closed:
  190. self._session = aiohttp.ClientSession(timeout=self._timeout)
  191. return self._session
  192. async def close(self) -> None:
  193. """Schliesst die HTTP-Session."""
  194. if self._session and not self._session.closed:
  195. await self._session.close()
  196. self._session = None
  197. async def login(self, username: str = "", password: str = "") -> bool:
  198. """Prueft ob die CCU-Zugangsdaten gueltig sind (JSON-API).
  199. Hinweis: Die XML-API nutzt persistente Tokens, die im WebUI unter
  200. Einstellungen → Sicherheit registriert werden muessen.
  201. Ein JSON-API Login kann keine XML-API Tokens erstellen.
  202. Diese Methode prueft nur ob die Zugangsdaten korrekt sind.
  203. Args:
  204. username: Benutzername
  205. password: Passwort
  206. Returns:
  207. True wenn Login erfolgreich
  208. """
  209. user = username or self._username
  210. pw = password or self._password
  211. if not user or not pw:
  212. raise ValueError("Username und Passwort erforderlich fuer Login")
  213. session = await self._ensure_session()
  214. url = f"{self._base_url}/api/homematic.cgi"
  215. # Login pruefen
  216. try:
  217. async with session.post(url, json={
  218. "method": "Session.login",
  219. "params": {"username": user, "password": pw},
  220. }) as resp:
  221. data = await resp.json(content_type=None)
  222. sid = data.get("result", "")
  223. if not sid or sid == "null":
  224. return False
  225. # Session direkt wieder aufraeumen
  226. try:
  227. await session.post(url, json={
  228. "method": "Session.logout",
  229. "params": {"_session_id_": sid},
  230. })
  231. except Exception:
  232. pass
  233. return True
  234. except aiohttp.ClientError as e:
  235. raise RuntimeError(f"Login-Verbindungsfehler: {e}")
  236. async def logout(self) -> bool:
  237. """Meldet die aktuelle Session ab."""
  238. if not self._session_id:
  239. return True
  240. session = await self._ensure_session()
  241. url = f"{self._base_url}/api/homematic.cgi"
  242. payload = {
  243. "method": "Session.logout",
  244. "params": {"_session_id_": self._session_id},
  245. }
  246. try:
  247. async with session.post(url, json=payload) as resp:
  248. data = await resp.json(content_type=None)
  249. self._session_id = ""
  250. return data.get("result", False)
  251. except aiohttp.ClientError:
  252. return False
  253. @property
  254. def session_id(self) -> str:
  255. return self._session_id
  256. @session_id.setter
  257. def session_id(self, value: str) -> None:
  258. self._session_id = value
  259. # ========================================================================
  260. # HTTP-Anfragen
  261. # ========================================================================
  262. async def _request(self, endpoint: str, **params: str) -> ET.Element:
  263. """Fuehrt eine XML-API-Anfrage aus und gibt das XML-Root zurueck.
  264. Args:
  265. endpoint: CGI-Endpoint (z.B. "devicelist.cgi")
  266. **params: Zusaetzliche Query-Parameter
  267. Returns:
  268. XML-Root-Element
  269. """
  270. session = await self._ensure_session()
  271. url = f"{self._api_base}/{endpoint}"
  272. query = {"sid": self._session_id}
  273. query.update(params)
  274. try:
  275. async with session.get(url, params=query) as resp:
  276. if resp.status != 200:
  277. raise RuntimeError(
  278. f"HTTP {resp.status} von {endpoint}"
  279. )
  280. # XML-API liefert ISO-8859-1
  281. raw = await resp.read()
  282. text = raw.decode("iso-8859-1")
  283. return ET.fromstring(text)
  284. except ET.ParseError as e:
  285. raise RuntimeError(f"XML-Parse-Fehler bei {endpoint}: {e}")
  286. except aiohttp.ClientError as e:
  287. raise RuntimeError(f"Verbindungsfehler bei {endpoint}: {e}")
  288. async def _request_raw(self, endpoint: str, **params: str) -> str:
  289. """Fuehrt eine Anfrage aus und gibt den Rohtext zurueck."""
  290. session = await self._ensure_session()
  291. url = f"{self._api_base}/{endpoint}"
  292. query = {"sid": self._session_id}
  293. query.update(params)
  294. try:
  295. async with session.get(url, params=query) as resp:
  296. raw = await resp.read()
  297. return raw.decode("iso-8859-1")
  298. except aiohttp.ClientError as e:
  299. raise RuntimeError(f"Verbindungsfehler bei {endpoint}: {e}")
  300. # ========================================================================
  301. # Geraete
  302. # ========================================================================
  303. async def get_device_list(self) -> list[Device]:
  304. """Gibt alle Geraete mit Channels zurueck (devicelist.cgi)."""
  305. root = await self._request("devicelist.cgi")
  306. devices: list[Device] = []
  307. for dev_elem in root.findall("device"):
  308. device = Device(
  309. ise_id=dev_elem.get("ise_id", ""),
  310. name=dev_elem.get("name", ""),
  311. address=dev_elem.get("address", ""),
  312. interface=dev_elem.get("interface", ""),
  313. device_type=dev_elem.get("device_type", ""),
  314. ready_config=dev_elem.get("ready_config", ""),
  315. )
  316. for ch_elem in dev_elem.findall("channel"):
  317. channel = Channel(
  318. ise_id=ch_elem.get("ise_id", ""),
  319. name=ch_elem.get("name", ""),
  320. index=ch_elem.get("index", ""),
  321. direction=ch_elem.get("direction", ""),
  322. parent_device=ch_elem.get("parent_device", ""),
  323. address=ch_elem.get("address", ""),
  324. channel_type=ch_elem.get("type", ""),
  325. visible=ch_elem.get("visible", ""),
  326. operate=ch_elem.get("operate", ""),
  327. )
  328. device.channels[channel.ise_id] = channel
  329. devices.append(device)
  330. return devices
  331. async def get_device_type_list(self) -> list[dict[str, str]]:
  332. """Gibt alle Geraetetypen zurueck (devicetypelist.cgi)."""
  333. root = await self._request("devicetypelist.cgi")
  334. types: list[dict[str, str]] = []
  335. for dt in root.findall("deviceType"):
  336. types.append({
  337. "name": dt.get("name", ""),
  338. "description": dt.get("description", ""),
  339. "thumbnail": dt.get("thumbnailPath", ""),
  340. "image": dt.get("imagePath", ""),
  341. })
  342. return types
  343. # ========================================================================
  344. # State / Zustaende
  345. # ========================================================================
  346. async def get_state(
  347. self,
  348. device_id: str = "",
  349. channel_id: str = "",
  350. datapoint_id: str = "",
  351. ) -> list[Device]:
  352. """Gibt Zustaende fuer Geraete/Channels/Datenpunkte zurueck (state.cgi).
  353. Mindestens ein Parameter muss angegeben werden.
  354. """
  355. params: dict[str, str] = {}
  356. if device_id:
  357. params["device_id"] = device_id
  358. if channel_id:
  359. params["channel_id"] = channel_id
  360. if datapoint_id:
  361. params["datapoint_id"] = datapoint_id
  362. root = await self._request("state.cgi", **params)
  363. return self._parse_state_devices(root)
  364. async def get_state_list(self) -> list[Device]:
  365. """Gibt alle Geraetezustaende zurueck (statelist.cgi)."""
  366. root = await self._request("statelist.cgi")
  367. return self._parse_state_devices(root)
  368. async def set_state(self, ise_id: str, new_value: str) -> bool:
  369. """Setzt einen Datenpunkt-Wert (statechange.cgi).
  370. Args:
  371. ise_id: ISE-ID des Datenpunkts
  372. new_value: Neuer Wert
  373. Returns:
  374. True bei Erfolg
  375. """
  376. root = await self._request(
  377. "statechange.cgi",
  378. ise_id=ise_id,
  379. new_value=new_value,
  380. )
  381. changed = root.find("changed")
  382. if changed is not None:
  383. return changed.get("new_value", "") == new_value
  384. return False
  385. async def set_states(self, changes: dict[str, str]) -> dict[str, bool]:
  386. """Setzt mehrere Datenpunkt-Werte gleichzeitig.
  387. Args:
  388. changes: Dict von {ise_id: new_value}
  389. Returns:
  390. Dict von {ise_id: success}
  391. """
  392. results: dict[str, bool] = {}
  393. tasks = [self.set_state(ise_id, val) for ise_id, val in changes.items()]
  394. outcomes = await asyncio.gather(*tasks, return_exceptions=True)
  395. for (ise_id, _), result in zip(changes.items(), outcomes):
  396. results[ise_id] = result is True
  397. return results
  398. def _parse_state_devices(self, root: ET.Element) -> list[Device]:
  399. """Parst Geraete aus state/statelist XML."""
  400. devices: list[Device] = []
  401. for dev_elem in root.findall("device"):
  402. device = Device(
  403. ise_id=dev_elem.get("ise_id", ""),
  404. name=dev_elem.get("name", ""),
  405. unreach=dev_elem.get("unreach", ""),
  406. config_pending=dev_elem.get("config_pending", ""),
  407. )
  408. for ch_elem in dev_elem.findall("channel"):
  409. channel = Channel(
  410. ise_id=ch_elem.get("ise_id", ""),
  411. name=ch_elem.get("name", ""),
  412. index=ch_elem.get("index", ""),
  413. visible=ch_elem.get("visible", ""),
  414. operate=ch_elem.get("operate", ""),
  415. )
  416. for dp_elem in ch_elem.findall("datapoint"):
  417. dp = Datapoint(
  418. ise_id=dp_elem.get("ise_id", ""),
  419. name=dp_elem.get("name", ""),
  420. type=dp_elem.get("type", ""),
  421. value=dp_elem.get("value", ""),
  422. valuetype=dp_elem.get("valuetype", ""),
  423. valueunit=dp_elem.get("valueunit", ""),
  424. timestamp=dp_elem.get("timestamp", "0"),
  425. operations=dp_elem.get("operations", ""),
  426. )
  427. channel.datapoints[dp.type] = dp
  428. device.channels[channel.ise_id] = channel
  429. devices.append(device)
  430. return devices
  431. # ========================================================================
  432. # Raeume
  433. # ========================================================================
  434. async def get_room_list(self) -> list[Room]:
  435. """Gibt alle Raeume mit zugeordneten Channels zurueck (roomlist.cgi)."""
  436. root = await self._request("roomlist.cgi")
  437. rooms: list[Room] = []
  438. for room_elem in root.findall("room"):
  439. room = Room(
  440. ise_id=room_elem.get("ise_id", ""),
  441. name=room_elem.get("name", ""),
  442. channel_ids=[
  443. ch.get("ise_id", "")
  444. for ch in room_elem.findall("channel")
  445. ],
  446. )
  447. rooms.append(room)
  448. return rooms
  449. # ========================================================================
  450. # Programme
  451. # ========================================================================
  452. async def get_program_list(self) -> list[Program]:
  453. """Gibt alle CCU-Programme zurueck (programlist.cgi)."""
  454. root = await self._request("programlist.cgi")
  455. programs: list[Program] = []
  456. for prog_elem in root.findall("program"):
  457. programs.append(Program(
  458. ise_id=prog_elem.get("id", ""),
  459. name=prog_elem.get("name", ""),
  460. active=prog_elem.get("active", ""),
  461. timestamp=prog_elem.get("timestamp", "0"),
  462. description=prog_elem.get("description", ""),
  463. visible=prog_elem.get("visible", ""),
  464. operate=prog_elem.get("operate", ""),
  465. ))
  466. return programs
  467. async def run_program(self, program_id: str) -> bool:
  468. """Fuehrt ein CCU-Programm aus (runprogram.cgi).
  469. Args:
  470. program_id: Programm-ID (aus programlist.cgi)
  471. Returns:
  472. True bei Erfolg
  473. """
  474. root = await self._request("runprogram.cgi", program_id=program_id)
  475. started = root.find("started")
  476. return started is not None
  477. async def get_program_actions(self, ise_id: str) -> ET.Element:
  478. """Gibt Programm-Aktionen zurueck (programactions.cgi)."""
  479. return await self._request("programactions.cgi", ise_id=ise_id)
  480. # ========================================================================
  481. # Systemvariablen
  482. # ========================================================================
  483. async def get_sysvar_list(self) -> list[SystemVariable]:
  484. """Gibt alle Systemvariablen zurueck (sysvarlist.cgi)."""
  485. root = await self._request("sysvarlist.cgi")
  486. return self._parse_sysvars(root)
  487. async def get_sysvar(self, ise_id: str) -> SystemVariable | None:
  488. """Gibt eine einzelne Systemvariable zurueck (sysvar.cgi)."""
  489. root = await self._request("sysvar.cgi", ise_id=ise_id)
  490. sysvars = self._parse_sysvars(root)
  491. return sysvars[0] if sysvars else None
  492. async def set_sysvar(self, ise_id: str, value: str) -> bool:
  493. """Setzt den Wert einer Systemvariable (statechange.cgi).
  494. Args:
  495. ise_id: ISE-ID der Variable
  496. value: Neuer Wert
  497. Returns:
  498. True bei Erfolg
  499. """
  500. return await self.set_state(ise_id, value)
  501. def _parse_sysvars(self, root: ET.Element) -> list[SystemVariable]:
  502. """Parst Systemvariablen aus XML."""
  503. sysvars: list[SystemVariable] = []
  504. for sv_elem in root.findall("systemVariable"):
  505. sysvars.append(SystemVariable(
  506. ise_id=sv_elem.get("ise_id", ""),
  507. name=sv_elem.get("name", ""),
  508. variable=sv_elem.get("variable", ""),
  509. value=sv_elem.get("value", ""),
  510. value_list=sv_elem.get("value_list", ""),
  511. value_text=sv_elem.get("value_text", ""),
  512. var_type=sv_elem.get("type", ""),
  513. subtype=sv_elem.get("subtype", ""),
  514. unit=sv_elem.get("unit", ""),
  515. min_value=sv_elem.get("min", ""),
  516. max_value=sv_elem.get("max", ""),
  517. timestamp=sv_elem.get("timestamp", "0"),
  518. info=sv_elem.get("info", ""),
  519. value_name_0=sv_elem.get("value_name_0", ""),
  520. value_name_1=sv_elem.get("value_name_1", ""),
  521. logged=sv_elem.get("logged", ""),
  522. ))
  523. return sysvars
  524. # ========================================================================
  525. # Favoriten
  526. # ========================================================================
  527. async def get_favorite_list(self) -> list[Favorite]:
  528. """Gibt alle Favoriten zurueck (favoritelist.cgi)."""
  529. root = await self._request("favoritelist.cgi")
  530. favorites: list[Favorite] = []
  531. for fav_elem in root.findall("favorite"):
  532. channels: list[dict[str, str]] = []
  533. for ch in fav_elem.findall("channel"):
  534. channels.append({
  535. "ise_id": ch.get("ise_id", ""),
  536. "name": ch.get("name", ""),
  537. "type": ch.get("type", ""),
  538. })
  539. favorites.append(Favorite(
  540. ise_id=fav_elem.get("ise_id", ""),
  541. name=fav_elem.get("name", ""),
  542. channels=channels,
  543. ))
  544. return favorites
  545. # ========================================================================
  546. # Mastervalues
  547. # ========================================================================
  548. async def get_master_value(
  549. self, device_id: str = "", channel_id: str = ""
  550. ) -> ET.Element:
  551. """Gibt Mastervalues zurueck (mastervalue.cgi)."""
  552. params: dict[str, str] = {}
  553. if device_id:
  554. params["device_id"] = device_id
  555. if channel_id:
  556. params["channel_id"] = channel_id
  557. return await self._request("mastervalue.cgi", **params)
  558. async def set_master_value(
  559. self, ise_id: str, name: str, value: str
  560. ) -> bool:
  561. """Setzt einen Mastervalue (mastervaluechange.cgi).
  562. Args:
  563. ise_id: ISE-ID des Channels/Geraets
  564. name: Parameter-Name
  565. value: Neuer Wert
  566. """
  567. root = await self._request(
  568. "mastervaluechange.cgi",
  569. ise_id=ise_id,
  570. name=name,
  571. value=value,
  572. )
  573. changed = root.find("changed")
  574. return changed is not None
  575. # ========================================================================
  576. # RSSI / Signalstaerke
  577. # ========================================================================
  578. async def get_rssi_list(self) -> list[RssiEntry]:
  579. """Gibt RSSI-Werte aller Geraete zurueck (rssilist.cgi)."""
  580. root = await self._request("rssilist.cgi")
  581. entries: list[RssiEntry] = []
  582. for entry in root.findall(".//rssi"):
  583. entries.append(RssiEntry(
  584. device=entry.get("device", ""),
  585. address=entry.get("address", ""),
  586. rx=entry.get("rx", ""),
  587. tx=entry.get("tx", ""),
  588. ))
  589. return entries
  590. # ========================================================================
  591. # Protokoll / Log
  592. # ========================================================================
  593. async def get_protocol(
  594. self,
  595. start: str = "",
  596. show: str = "",
  597. ) -> list[ProtocolEntry]:
  598. """Gibt Protokolleintraege zurueck (protocol.cgi).
  599. Args:
  600. start: Start-Index
  601. show: Anzahl Eintraege
  602. """
  603. params: dict[str, str] = {}
  604. if start:
  605. params["start"] = start
  606. if show:
  607. params["show"] = show
  608. root = await self._request("protocol.cgi", **params)
  609. entries: list[ProtocolEntry] = []
  610. for row in root.findall(".//row"):
  611. entries.append(ProtocolEntry(
  612. timestamp=row.get("timestamp", ""),
  613. name=row.get("name", ""),
  614. value=row.get("value", ""),
  615. info=row.get("info", ""),
  616. ))
  617. return entries
  618. # ========================================================================
  619. # Benachrichtigungen
  620. # ========================================================================
  621. async def get_notifications(self) -> list[Notification]:
  622. """Gibt Systembenachrichtigungen zurueck (systemNotification.cgi)."""
  623. root = await self._request("systemNotification.cgi")
  624. notifications: list[Notification] = []
  625. for notif in root.findall("notification"):
  626. notifications.append(Notification(
  627. ise_id=notif.get("ise_id", ""),
  628. name=notif.get("name", ""),
  629. type=notif.get("type", ""),
  630. timestamp=notif.get("timestamp", ""),
  631. message=notif.get("message", notif.text or ""),
  632. ))
  633. return notifications
  634. async def clear_notifications(self) -> bool:
  635. """Loescht alle Systembenachrichtigungen (systemNotificationClear.cgi)."""
  636. try:
  637. await self._request("systemNotificationClear.cgi")
  638. return True
  639. except Exception:
  640. return False
  641. # ========================================================================
  642. # Script-Fehler
  643. # ========================================================================
  644. async def get_script_errors(self) -> str:
  645. """Gibt Script-Fehler zurueck (scripterrors.cgi)."""
  646. return await self._request_raw("scripterrors.cgi")
  647. # ========================================================================
  648. # ReGaHss-Script ausfuehren
  649. # ========================================================================
  650. async def exec_script(self, script: str) -> str:
  651. """Fuehrt ein ReGaHss-Script auf der CCU aus (exec.cgi).
  652. Args:
  653. script: HM-Script Code
  654. Returns:
  655. Script-Ausgabe als String
  656. """
  657. session = await self._ensure_session()
  658. url = f"{self._api_base}/exec.cgi"
  659. params = {"sid": self._session_id}
  660. data = aiohttp.FormData()
  661. data.add_field("script", script)
  662. try:
  663. async with session.post(url, params=params, data=data) as resp:
  664. raw = await resp.read()
  665. return raw.decode("iso-8859-1")
  666. except aiohttp.ClientError as e:
  667. raise RuntimeError(f"Script-Ausfuehrung fehlgeschlagen: {e}")
  668. # ========================================================================
  669. # Token-Verwaltung
  670. # ========================================================================
  671. async def get_token_list(self) -> list[dict[str, str]]:
  672. """Gibt registrierte Tokens zurueck (tokenlist.cgi).
  673. Returns:
  674. Liste von {token: str, description: str}
  675. """
  676. root = await self._request("tokenlist.cgi")
  677. tokens: list[dict[str, str]] = []
  678. for token_elem in root.findall("token"):
  679. tokens.append({
  680. "token": token_elem.text or "",
  681. "description": token_elem.get("desc", ""),
  682. })
  683. return tokens
  684. async def register_token(self, description: str = "") -> str:
  685. """Registriert einen neuen Token (tokenregister.cgi).
  686. Args:
  687. description: Beschreibung des Tokens
  688. Returns:
  689. Token-Wert als String
  690. """
  691. params: dict[str, str] = {}
  692. if description:
  693. params["desc"] = description
  694. root = await self._request("tokenregister.cgi", **params)
  695. # Response: <tokenregister><token desc="...">TOKEN_VALUE</token></tokenregister>
  696. token = root.find("token")
  697. return token.text if token is not None and token.text else ""
  698. async def revoke_token(self, token: str) -> bool:
  699. """Widerruft einen Token (tokenrevoke.cgi).
  700. Args:
  701. token: Der Token-Wert der widerrufen werden soll
  702. """
  703. try:
  704. await self._request("tokenrevoke.cgi", sid=token)
  705. return True
  706. except Exception:
  707. return False
  708. # ========================================================================
  709. # System
  710. # ========================================================================
  711. async def get_version(self) -> str:
  712. """Gibt die XML-API-Version zurueck (version.cgi)."""
  713. root = await self._request("version.cgi")
  714. return root.text or ""
  715. async def check_update(self) -> dict[str, str]:
  716. """Prueft auf Updates (update.cgi)."""
  717. root = await self._request("update.cgi")
  718. return {
  719. "current": root.get("current_version", root.findtext("current", "")),
  720. "latest": root.get("latest_version", root.findtext("latest", "")),
  721. "update_available": root.get("update_available", ""),
  722. }
  723. # ========================================================================
  724. # Komfort-Methoden
  725. # ========================================================================
  726. async def find_device_by_name(self, name: str) -> Device | None:
  727. """Sucht ein Geraet nach Name (case-insensitive)."""
  728. devices = await self.get_device_list()
  729. name_lower = name.lower()
  730. for device in devices:
  731. if device.name.lower() == name_lower:
  732. return device
  733. return None
  734. async def find_channel_by_name(self, name: str) -> tuple[Device, Channel] | None:
  735. """Sucht einen Channel nach Name (case-insensitive)."""
  736. devices = await self.get_device_list()
  737. name_lower = name.lower()
  738. for device in devices:
  739. for channel in device.channels.values():
  740. if channel.name.lower() == name_lower:
  741. return device, channel
  742. return None
  743. async def find_room_by_name(self, name: str) -> Room | None:
  744. """Sucht einen Raum nach Name (case-insensitive)."""
  745. rooms = await self.get_room_list()
  746. name_lower = name.lower()
  747. for room in rooms:
  748. if room.name.lower() == name_lower:
  749. return room
  750. return None
  751. async def find_program_by_name(self, name: str) -> Program | None:
  752. """Sucht ein Programm nach Name (case-insensitive)."""
  753. programs = await self.get_program_list()
  754. name_lower = name.lower()
  755. for prog in programs:
  756. if prog.name.lower() == name_lower:
  757. return prog
  758. return None
  759. async def find_sysvar_by_name(self, name: str) -> SystemVariable | None:
  760. """Sucht eine Systemvariable nach Name (case-insensitive)."""
  761. sysvars = await self.get_sysvar_list()
  762. name_lower = name.lower()
  763. for sv in sysvars:
  764. if sv.name.lower() == name_lower:
  765. return sv
  766. return None
  767. async def get_channel_state(self, channel_id: str) -> dict[str, Datapoint]:
  768. """Gibt alle Datenpunkte eines Channels zurueck.
  769. Returns:
  770. Dict von {datapoint_type: Datapoint}
  771. """
  772. devices = await self.get_state(channel_id=channel_id)
  773. for device in devices:
  774. for channel in device.channels.values():
  775. if channel.ise_id == channel_id:
  776. return channel.datapoints
  777. return {}
  778. async def get_datapoint_value(
  779. self, channel_id: str, dp_type: str
  780. ) -> str | None:
  781. """Gibt den Wert eines bestimmten Datenpunkts zurueck.
  782. Args:
  783. channel_id: Channel ISE-ID
  784. dp_type: Datenpunkt-Typ (z.B. "STATE", "LEVEL", "ACTUAL_TEMPERATURE")
  785. """
  786. dps = await self.get_channel_state(channel_id)
  787. dp = dps.get(dp_type)
  788. return dp.value if dp else None
  789. async def get_rooms_with_devices(self) -> list[dict[str, Any]]:
  790. """Gibt Raeume mit aufgeloesten Geraete-Informationen zurueck."""
  791. rooms = await self.get_room_list()
  792. devices = await self.get_device_list()
  793. # Channel-ID → Device/Channel Mapping
  794. channel_map: dict[str, tuple[Device, Channel]] = {}
  795. for device in devices:
  796. for channel in device.channels.values():
  797. channel_map[channel.ise_id] = (device, channel)
  798. result: list[dict[str, Any]] = []
  799. for room in rooms:
  800. room_devices: list[dict[str, str]] = []
  801. for ch_id in room.channel_ids:
  802. if ch_id in channel_map:
  803. dev, ch = channel_map[ch_id]
  804. room_devices.append({
  805. "device_name": dev.name,
  806. "device_type": dev.device_type,
  807. "channel_name": ch.name,
  808. "channel_id": ch.ise_id,
  809. })
  810. result.append({
  811. "name": room.name,
  812. "ise_id": room.ise_id,
  813. "devices": room_devices,
  814. })
  815. return result
  816. async def get_temperature(self, channel_id: str) -> float | None:
  817. """Gibt die aktuelle Temperatur eines Thermostats zurueck."""
  818. val = await self.get_datapoint_value(channel_id, "ACTUAL_TEMPERATURE")
  819. if val is not None:
  820. try:
  821. return float(val)
  822. except ValueError:
  823. pass
  824. return None
  825. async def set_temperature(self, channel_id: str, temperature: float) -> bool:
  826. """Setzt die Soll-Temperatur eines Thermostats.
  827. Args:
  828. channel_id: Channel ISE-ID des Thermostat-Kanals
  829. temperature: Zieltemperatur in Grad Celsius
  830. """
  831. dps = await self.get_channel_state(channel_id)
  832. dp = dps.get("SET_TEMPERATURE")
  833. if dp is None:
  834. pwarn(f"[Homematic] Kein SET_TEMPERATURE Datenpunkt fuer Channel {channel_id}")
  835. return False
  836. return await self.set_state(dp.ise_id, str(temperature))
  837. async def switch(self, channel_id: str, on: bool) -> bool:
  838. """Schaltet einen Schaltaktor ein/aus.
  839. Args:
  840. channel_id: Channel ISE-ID
  841. on: True = ein, False = aus
  842. """
  843. dps = await self.get_channel_state(channel_id)
  844. dp = dps.get("STATE")
  845. if dp is None:
  846. pwarn(f"[Homematic] Kein STATE Datenpunkt fuer Channel {channel_id}")
  847. return False
  848. return await self.set_state(dp.ise_id, "true" if on else "false")
  849. async def set_dimmer(self, channel_id: str, level: float) -> bool:
  850. """Setzt den Dimmer-Level (0.0 - 1.0).
  851. Args:
  852. channel_id: Channel ISE-ID des Dimmer-Kanals
  853. level: Helligkeit (0.0 = aus, 1.0 = voll)
  854. """
  855. dps = await self.get_channel_state(channel_id)
  856. dp = dps.get("LEVEL")
  857. if dp is None:
  858. pwarn(f"[Homematic] Kein LEVEL Datenpunkt fuer Channel {channel_id}")
  859. return False
  860. return await self.set_state(dp.ise_id, str(max(0.0, min(1.0, level))))
  861. async def is_window_open(self, channel_id: str) -> bool | None:
  862. """Prueft ob ein Fenster-/Tuerkontakt geoeffnet ist."""
  863. val = await self.get_datapoint_value(channel_id, "STATE")
  864. if val is not None:
  865. return val.lower() in ("true", "1")
  866. return None
  867. async def get_all_temperatures(self) -> dict[str, dict[str, float]]:
  868. """Gibt alle Thermostat-Temperaturen zurueck.
  869. Sucht Channels mit ACTUAL_TEMPERATURE und SET_TEMPERATURE Datenpunkten
  870. (nur Channels die beide haben sind echte Thermostate).
  871. Returns:
  872. Dict von {channel_name: {actual: float, target: float, channel_id: str}}
  873. """
  874. devices = await self.get_state_list()
  875. temps: dict[str, dict[str, float]] = {}
  876. for device in devices:
  877. for channel in device.channels.values():
  878. actual = channel.datapoints.get("ACTUAL_TEMPERATURE")
  879. target = channel.datapoints.get("SET_TEMPERATURE")
  880. # Nur echte Thermostate haben beide Datenpunkte
  881. if actual is not None and target is not None:
  882. temps[channel.name] = {
  883. "actual": actual.value_float,
  884. "target": target.value_float,
  885. "channel_id": channel.ise_id,
  886. }
  887. return temps
  888. async def get_all_window_states(self) -> dict[str, bool]:
  889. """Gibt alle Fenster-/Tuerkontakt-Zustaende zurueck.
  890. Nutzt devicelist fuer Typ-Filterung und statelist fuer Zustaende.
  891. Filtert auf: HM-Sec-SCo, HM-Sec-SC, HM-Sec-RHS, HM-SCI-3-FM, HmIP-SWDO
  892. Returns:
  893. Dict von {channel_name: is_open}
  894. """
  895. contact_types = ("Sec-SC", "Sec-RHS", "SCI-", "SWDO", "SWDM")
  896. # Geraete mit Typen laden + State-Daten
  897. device_list, state_list = await asyncio.gather(
  898. self.get_device_list(),
  899. self.get_state_list(),
  900. )
  901. # ISE-IDs der Kontakt-Geraete sammeln
  902. contact_device_ids: set[str] = set()
  903. for device in device_list:
  904. if any(t in device.device_type for t in contact_types):
  905. contact_device_ids.add(device.ise_id)
  906. # States filtern
  907. windows: dict[str, bool] = {}
  908. for device in state_list:
  909. if device.ise_id not in contact_device_ids:
  910. continue
  911. for channel in device.channels.values():
  912. state_dp = channel.datapoints.get("STATE")
  913. if state_dp and channel.visible == "true":
  914. windows[channel.name] = state_dp.value_bool
  915. return windows