| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069 |
- # -*- coding: utf-8 -*-
- """
- Homematic XML-API Wrapper.
- Vollstaendiger async Wrapper fuer die Homematic XML-API.
- Unterstuetzt alle Endpoints des XML-API Addons (v2.x).
- Verwendung:
- api = HomematicApi("http://homematic-raspi", session_id="xxx")
- devices = await api.get_device_list()
- await api.set_state(ise_id="1445", new_value="true")
- """
- from __future__ import annotations
- import asyncio
- from dataclasses import dataclass, field
- from typing import Any
- from xml.etree import ElementTree as ET
- import aiohttp
- from trixy_core.utils.debug import pdebug, perror, pinfo, pwarn
- # ============================================================================
- # Datenklassen
- # ============================================================================
- @dataclass
- class Datapoint:
- """Ein einzelner Datenpunkt eines Channels."""
- ise_id: str
- name: str
- type: str
- value: str
- valuetype: str = ""
- valueunit: str = ""
- timestamp: str = "0"
- operations: str = ""
- @property
- def value_bool(self) -> bool:
- """Wert als Boolean."""
- return self.value.lower() in ("true", "1")
- @property
- def value_float(self) -> float:
- """Wert als Float."""
- try:
- return float(self.value)
- except (ValueError, TypeError):
- return 0.0
- @property
- def value_int(self) -> int:
- """Wert als Integer."""
- try:
- return int(float(self.value))
- except (ValueError, TypeError):
- return 0
- @property
- def is_writable(self) -> bool:
- """Prueft ob der Datenpunkt beschreibbar ist (Bit 1 in operations)."""
- try:
- return bool(int(self.operations) & 2)
- except (ValueError, TypeError):
- return False
- @dataclass
- class Channel:
- """Ein Channel eines Geraets."""
- ise_id: str
- name: str
- index: str = ""
- direction: str = ""
- parent_device: str = ""
- address: str = ""
- channel_type: str = ""
- visible: str = ""
- operate: str = ""
- datapoints: dict[str, Datapoint] = field(default_factory=dict)
- @dataclass
- class Device:
- """Ein Homematic-Geraet."""
- ise_id: str
- name: str
- address: str = ""
- interface: str = ""
- device_type: str = ""
- ready_config: str = ""
- channels: dict[str, Channel] = field(default_factory=dict)
- unreach: str = ""
- config_pending: str = ""
- @dataclass
- class Room:
- """Ein Raum mit zugeordneten Channels."""
- ise_id: str
- name: str
- channel_ids: list[str] = field(default_factory=list)
- @dataclass
- class Program:
- """Ein CCU-Programm."""
- ise_id: str
- name: str
- active: str = ""
- timestamp: str = "0"
- description: str = ""
- visible: str = ""
- operate: str = ""
- @dataclass
- class SystemVariable:
- """Eine Systemvariable."""
- ise_id: str
- name: str
- variable: str = ""
- value: str = ""
- value_list: str = ""
- value_text: str = ""
- var_type: str = "" # type in XML
- subtype: str = ""
- unit: str = ""
- min_value: str = ""
- max_value: str = ""
- timestamp: str = "0"
- info: str = ""
- value_name_0: str = ""
- value_name_1: str = ""
- logged: str = ""
- @property
- def value_bool(self) -> bool:
- return self.value.lower() in ("true", "1")
- @property
- def value_float(self) -> float:
- try:
- return float(self.variable)
- except (ValueError, TypeError):
- return 0.0
- @dataclass
- class Favorite:
- """Ein Favorit / Favoritenliste."""
- ise_id: str
- name: str
- channels: list[dict[str, str]] = field(default_factory=list)
- @dataclass
- class RssiEntry:
- """RSSI-Wert eines Geraets."""
- device: str
- address: str
- rx: str = ""
- tx: str = ""
- @dataclass
- class ProtocolEntry:
- """Ein Protokolleintrag."""
- timestamp: str = ""
- name: str = ""
- value: str = ""
- info: str = ""
- @dataclass
- class Notification:
- """Eine Systembenachrichtigung."""
- ise_id: str = ""
- name: str = ""
- type: str = ""
- timestamp: str = ""
- message: str = ""
- # ============================================================================
- # API Wrapper
- # ============================================================================
- class HomematicApi:
- """Async Wrapper fuer die Homematic XML-API.
- Unterstuetzt alle Endpoints des XML-API Addons:
- - Geraete, Channels, Datenpunkte (devicelist, statelist, state, statechange)
- - Raeume (roomlist)
- - Programme (programlist, runprogram)
- - Systemvariablen (sysvarlist, sysvar)
- - Favoriten (favoritelist)
- - RSSI, Protokoll, Benachrichtigungen
- - Mastervalues, Token-Verwaltung
- - Version und Updates
- """
- def __init__(
- self,
- base_url: str,
- session_id: str = "",
- username: str = "",
- password: str = "",
- timeout: int = 30,
- ) -> None:
- self._base_url = base_url.rstrip("/")
- self._api_base = f"{self._base_url}/addons/xmlapi"
- self._session_id = session_id
- self._username = username
- self._password = password
- self._timeout = aiohttp.ClientTimeout(total=timeout)
- self._session: aiohttp.ClientSession | None = None
- # ========================================================================
- # Session-Management
- # ========================================================================
- async def _ensure_session(self) -> aiohttp.ClientSession:
- """Stellt sicher, dass eine aiohttp-Session existiert."""
- if self._session is None or self._session.closed:
- self._session = aiohttp.ClientSession(timeout=self._timeout)
- return self._session
- async def close(self) -> None:
- """Schliesst die HTTP-Session."""
- if self._session and not self._session.closed:
- await self._session.close()
- self._session = None
- async def login(self, username: str = "", password: str = "") -> bool:
- """Prueft ob die CCU-Zugangsdaten gueltig sind (JSON-API).
- Hinweis: Die XML-API nutzt persistente Tokens, die im WebUI unter
- Einstellungen → Sicherheit registriert werden muessen.
- Ein JSON-API Login kann keine XML-API Tokens erstellen.
- Diese Methode prueft nur ob die Zugangsdaten korrekt sind.
- Args:
- username: Benutzername
- password: Passwort
- Returns:
- True wenn Login erfolgreich
- """
- user = username or self._username
- pw = password or self._password
- if not user or not pw:
- raise ValueError("Username und Passwort erforderlich fuer Login")
- session = await self._ensure_session()
- url = f"{self._base_url}/api/homematic.cgi"
- # Login pruefen
- try:
- async with session.post(url, json={
- "method": "Session.login",
- "params": {"username": user, "password": pw},
- }) as resp:
- data = await resp.json(content_type=None)
- sid = data.get("result", "")
- if not sid or sid == "null":
- return False
- # Session direkt wieder aufraeumen
- try:
- await session.post(url, json={
- "method": "Session.logout",
- "params": {"_session_id_": sid},
- })
- except Exception:
- pass
- return True
- except aiohttp.ClientError as e:
- raise RuntimeError(f"Login-Verbindungsfehler: {e}")
- async def logout(self) -> bool:
- """Meldet die aktuelle Session ab."""
- if not self._session_id:
- return True
- session = await self._ensure_session()
- url = f"{self._base_url}/api/homematic.cgi"
- payload = {
- "method": "Session.logout",
- "params": {"_session_id_": self._session_id},
- }
- try:
- async with session.post(url, json=payload) as resp:
- data = await resp.json(content_type=None)
- self._session_id = ""
- return data.get("result", False)
- except aiohttp.ClientError:
- return False
- @property
- def session_id(self) -> str:
- return self._session_id
- @session_id.setter
- def session_id(self, value: str) -> None:
- self._session_id = value
- # ========================================================================
- # HTTP-Anfragen
- # ========================================================================
- async def _request(self, endpoint: str, **params: str) -> ET.Element:
- """Fuehrt eine XML-API-Anfrage aus und gibt das XML-Root zurueck.
- Args:
- endpoint: CGI-Endpoint (z.B. "devicelist.cgi")
- **params: Zusaetzliche Query-Parameter
- Returns:
- XML-Root-Element
- """
- session = await self._ensure_session()
- url = f"{self._api_base}/{endpoint}"
- query = {"sid": self._session_id}
- query.update(params)
- try:
- async with session.get(url, params=query) as resp:
- if resp.status != 200:
- raise RuntimeError(
- f"HTTP {resp.status} von {endpoint}"
- )
- # XML-API liefert ISO-8859-1
- raw = await resp.read()
- text = raw.decode("iso-8859-1")
- return ET.fromstring(text)
- except ET.ParseError as e:
- raise RuntimeError(f"XML-Parse-Fehler bei {endpoint}: {e}")
- except aiohttp.ClientError as e:
- raise RuntimeError(f"Verbindungsfehler bei {endpoint}: {e}")
- async def _request_raw(self, endpoint: str, **params: str) -> str:
- """Fuehrt eine Anfrage aus und gibt den Rohtext zurueck."""
- session = await self._ensure_session()
- url = f"{self._api_base}/{endpoint}"
- query = {"sid": self._session_id}
- query.update(params)
- try:
- async with session.get(url, params=query) as resp:
- raw = await resp.read()
- return raw.decode("iso-8859-1")
- except aiohttp.ClientError as e:
- raise RuntimeError(f"Verbindungsfehler bei {endpoint}: {e}")
- # ========================================================================
- # Geraete
- # ========================================================================
- async def get_device_list(self) -> list[Device]:
- """Gibt alle Geraete mit Channels zurueck (devicelist.cgi)."""
- root = await self._request("devicelist.cgi")
- devices: list[Device] = []
- for dev_elem in root.findall("device"):
- device = Device(
- ise_id=dev_elem.get("ise_id", ""),
- name=dev_elem.get("name", ""),
- address=dev_elem.get("address", ""),
- interface=dev_elem.get("interface", ""),
- device_type=dev_elem.get("device_type", ""),
- ready_config=dev_elem.get("ready_config", ""),
- )
- for ch_elem in dev_elem.findall("channel"):
- channel = Channel(
- ise_id=ch_elem.get("ise_id", ""),
- name=ch_elem.get("name", ""),
- index=ch_elem.get("index", ""),
- direction=ch_elem.get("direction", ""),
- parent_device=ch_elem.get("parent_device", ""),
- address=ch_elem.get("address", ""),
- channel_type=ch_elem.get("type", ""),
- visible=ch_elem.get("visible", ""),
- operate=ch_elem.get("operate", ""),
- )
- device.channels[channel.ise_id] = channel
- devices.append(device)
- return devices
- async def get_device_type_list(self) -> list[dict[str, str]]:
- """Gibt alle Geraetetypen zurueck (devicetypelist.cgi)."""
- root = await self._request("devicetypelist.cgi")
- types: list[dict[str, str]] = []
- for dt in root.findall("deviceType"):
- types.append({
- "name": dt.get("name", ""),
- "description": dt.get("description", ""),
- "thumbnail": dt.get("thumbnailPath", ""),
- "image": dt.get("imagePath", ""),
- })
- return types
- # ========================================================================
- # State / Zustaende
- # ========================================================================
- async def get_state(
- self,
- device_id: str = "",
- channel_id: str = "",
- datapoint_id: str = "",
- ) -> list[Device]:
- """Gibt Zustaende fuer Geraete/Channels/Datenpunkte zurueck (state.cgi).
- Mindestens ein Parameter muss angegeben werden.
- """
- params: dict[str, str] = {}
- if device_id:
- params["device_id"] = device_id
- if channel_id:
- params["channel_id"] = channel_id
- if datapoint_id:
- params["datapoint_id"] = datapoint_id
- root = await self._request("state.cgi", **params)
- return self._parse_state_devices(root)
- async def get_state_list(self) -> list[Device]:
- """Gibt alle Geraetezustaende zurueck (statelist.cgi)."""
- root = await self._request("statelist.cgi")
- return self._parse_state_devices(root)
- async def set_state(self, ise_id: str, new_value: str) -> bool:
- """Setzt einen Datenpunkt-Wert (statechange.cgi).
- Args:
- ise_id: ISE-ID des Datenpunkts
- new_value: Neuer Wert
- Returns:
- True bei Erfolg
- """
- root = await self._request(
- "statechange.cgi",
- ise_id=ise_id,
- new_value=new_value,
- )
- changed = root.find("changed")
- if changed is not None:
- return changed.get("new_value", "") == new_value
- return False
- async def set_states(self, changes: dict[str, str]) -> dict[str, bool]:
- """Setzt mehrere Datenpunkt-Werte gleichzeitig.
- Args:
- changes: Dict von {ise_id: new_value}
- Returns:
- Dict von {ise_id: success}
- """
- results: dict[str, bool] = {}
- tasks = [self.set_state(ise_id, val) for ise_id, val in changes.items()]
- outcomes = await asyncio.gather(*tasks, return_exceptions=True)
- for (ise_id, _), result in zip(changes.items(), outcomes):
- results[ise_id] = result is True
- return results
- def _parse_state_devices(self, root: ET.Element) -> list[Device]:
- """Parst Geraete aus state/statelist XML."""
- devices: list[Device] = []
- for dev_elem in root.findall("device"):
- device = Device(
- ise_id=dev_elem.get("ise_id", ""),
- name=dev_elem.get("name", ""),
- unreach=dev_elem.get("unreach", ""),
- config_pending=dev_elem.get("config_pending", ""),
- )
- for ch_elem in dev_elem.findall("channel"):
- channel = Channel(
- ise_id=ch_elem.get("ise_id", ""),
- name=ch_elem.get("name", ""),
- index=ch_elem.get("index", ""),
- visible=ch_elem.get("visible", ""),
- operate=ch_elem.get("operate", ""),
- )
- for dp_elem in ch_elem.findall("datapoint"):
- dp = Datapoint(
- ise_id=dp_elem.get("ise_id", ""),
- name=dp_elem.get("name", ""),
- type=dp_elem.get("type", ""),
- value=dp_elem.get("value", ""),
- valuetype=dp_elem.get("valuetype", ""),
- valueunit=dp_elem.get("valueunit", ""),
- timestamp=dp_elem.get("timestamp", "0"),
- operations=dp_elem.get("operations", ""),
- )
- channel.datapoints[dp.type] = dp
- device.channels[channel.ise_id] = channel
- devices.append(device)
- return devices
- # ========================================================================
- # Raeume
- # ========================================================================
- async def get_room_list(self) -> list[Room]:
- """Gibt alle Raeume mit zugeordneten Channels zurueck (roomlist.cgi)."""
- root = await self._request("roomlist.cgi")
- rooms: list[Room] = []
- for room_elem in root.findall("room"):
- room = Room(
- ise_id=room_elem.get("ise_id", ""),
- name=room_elem.get("name", ""),
- channel_ids=[
- ch.get("ise_id", "")
- for ch in room_elem.findall("channel")
- ],
- )
- rooms.append(room)
- return rooms
- # ========================================================================
- # Programme
- # ========================================================================
- async def get_program_list(self) -> list[Program]:
- """Gibt alle CCU-Programme zurueck (programlist.cgi)."""
- root = await self._request("programlist.cgi")
- programs: list[Program] = []
- for prog_elem in root.findall("program"):
- programs.append(Program(
- ise_id=prog_elem.get("id", ""),
- name=prog_elem.get("name", ""),
- active=prog_elem.get("active", ""),
- timestamp=prog_elem.get("timestamp", "0"),
- description=prog_elem.get("description", ""),
- visible=prog_elem.get("visible", ""),
- operate=prog_elem.get("operate", ""),
- ))
- return programs
- async def run_program(self, program_id: str) -> bool:
- """Fuehrt ein CCU-Programm aus (runprogram.cgi).
- Args:
- program_id: Programm-ID (aus programlist.cgi)
- Returns:
- True bei Erfolg
- """
- root = await self._request("runprogram.cgi", program_id=program_id)
- started = root.find("started")
- return started is not None
- async def get_program_actions(self, ise_id: str) -> ET.Element:
- """Gibt Programm-Aktionen zurueck (programactions.cgi)."""
- return await self._request("programactions.cgi", ise_id=ise_id)
- # ========================================================================
- # Systemvariablen
- # ========================================================================
- async def get_sysvar_list(self) -> list[SystemVariable]:
- """Gibt alle Systemvariablen zurueck (sysvarlist.cgi)."""
- root = await self._request("sysvarlist.cgi")
- return self._parse_sysvars(root)
- async def get_sysvar(self, ise_id: str) -> SystemVariable | None:
- """Gibt eine einzelne Systemvariable zurueck (sysvar.cgi)."""
- root = await self._request("sysvar.cgi", ise_id=ise_id)
- sysvars = self._parse_sysvars(root)
- return sysvars[0] if sysvars else None
- async def set_sysvar(self, ise_id: str, value: str) -> bool:
- """Setzt den Wert einer Systemvariable (statechange.cgi).
- Args:
- ise_id: ISE-ID der Variable
- value: Neuer Wert
- Returns:
- True bei Erfolg
- """
- return await self.set_state(ise_id, value)
- def _parse_sysvars(self, root: ET.Element) -> list[SystemVariable]:
- """Parst Systemvariablen aus XML."""
- sysvars: list[SystemVariable] = []
- for sv_elem in root.findall("systemVariable"):
- sysvars.append(SystemVariable(
- ise_id=sv_elem.get("ise_id", ""),
- name=sv_elem.get("name", ""),
- variable=sv_elem.get("variable", ""),
- value=sv_elem.get("value", ""),
- value_list=sv_elem.get("value_list", ""),
- value_text=sv_elem.get("value_text", ""),
- var_type=sv_elem.get("type", ""),
- subtype=sv_elem.get("subtype", ""),
- unit=sv_elem.get("unit", ""),
- min_value=sv_elem.get("min", ""),
- max_value=sv_elem.get("max", ""),
- timestamp=sv_elem.get("timestamp", "0"),
- info=sv_elem.get("info", ""),
- value_name_0=sv_elem.get("value_name_0", ""),
- value_name_1=sv_elem.get("value_name_1", ""),
- logged=sv_elem.get("logged", ""),
- ))
- return sysvars
- # ========================================================================
- # Favoriten
- # ========================================================================
- async def get_favorite_list(self) -> list[Favorite]:
- """Gibt alle Favoriten zurueck (favoritelist.cgi)."""
- root = await self._request("favoritelist.cgi")
- favorites: list[Favorite] = []
- for fav_elem in root.findall("favorite"):
- channels: list[dict[str, str]] = []
- for ch in fav_elem.findall("channel"):
- channels.append({
- "ise_id": ch.get("ise_id", ""),
- "name": ch.get("name", ""),
- "type": ch.get("type", ""),
- })
- favorites.append(Favorite(
- ise_id=fav_elem.get("ise_id", ""),
- name=fav_elem.get("name", ""),
- channels=channels,
- ))
- return favorites
- # ========================================================================
- # Mastervalues
- # ========================================================================
- async def get_master_value(
- self, device_id: str = "", channel_id: str = ""
- ) -> ET.Element:
- """Gibt Mastervalues zurueck (mastervalue.cgi)."""
- params: dict[str, str] = {}
- if device_id:
- params["device_id"] = device_id
- if channel_id:
- params["channel_id"] = channel_id
- return await self._request("mastervalue.cgi", **params)
- async def set_master_value(
- self, ise_id: str, name: str, value: str
- ) -> bool:
- """Setzt einen Mastervalue (mastervaluechange.cgi).
- Args:
- ise_id: ISE-ID des Channels/Geraets
- name: Parameter-Name
- value: Neuer Wert
- """
- root = await self._request(
- "mastervaluechange.cgi",
- ise_id=ise_id,
- name=name,
- value=value,
- )
- changed = root.find("changed")
- return changed is not None
- # ========================================================================
- # RSSI / Signalstaerke
- # ========================================================================
- async def get_rssi_list(self) -> list[RssiEntry]:
- """Gibt RSSI-Werte aller Geraete zurueck (rssilist.cgi)."""
- root = await self._request("rssilist.cgi")
- entries: list[RssiEntry] = []
- for entry in root.findall(".//rssi"):
- entries.append(RssiEntry(
- device=entry.get("device", ""),
- address=entry.get("address", ""),
- rx=entry.get("rx", ""),
- tx=entry.get("tx", ""),
- ))
- return entries
- # ========================================================================
- # Protokoll / Log
- # ========================================================================
- async def get_protocol(
- self,
- start: str = "",
- show: str = "",
- ) -> list[ProtocolEntry]:
- """Gibt Protokolleintraege zurueck (protocol.cgi).
- Args:
- start: Start-Index
- show: Anzahl Eintraege
- """
- params: dict[str, str] = {}
- if start:
- params["start"] = start
- if show:
- params["show"] = show
- root = await self._request("protocol.cgi", **params)
- entries: list[ProtocolEntry] = []
- for row in root.findall(".//row"):
- entries.append(ProtocolEntry(
- timestamp=row.get("timestamp", ""),
- name=row.get("name", ""),
- value=row.get("value", ""),
- info=row.get("info", ""),
- ))
- return entries
- # ========================================================================
- # Benachrichtigungen
- # ========================================================================
- async def get_notifications(self) -> list[Notification]:
- """Gibt Systembenachrichtigungen zurueck (systemNotification.cgi)."""
- root = await self._request("systemNotification.cgi")
- notifications: list[Notification] = []
- for notif in root.findall("notification"):
- notifications.append(Notification(
- ise_id=notif.get("ise_id", ""),
- name=notif.get("name", ""),
- type=notif.get("type", ""),
- timestamp=notif.get("timestamp", ""),
- message=notif.get("message", notif.text or ""),
- ))
- return notifications
- async def clear_notifications(self) -> bool:
- """Loescht alle Systembenachrichtigungen (systemNotificationClear.cgi)."""
- try:
- await self._request("systemNotificationClear.cgi")
- return True
- except Exception:
- return False
- # ========================================================================
- # Script-Fehler
- # ========================================================================
- async def get_script_errors(self) -> str:
- """Gibt Script-Fehler zurueck (scripterrors.cgi)."""
- return await self._request_raw("scripterrors.cgi")
- # ========================================================================
- # ReGaHss-Script ausfuehren
- # ========================================================================
- async def exec_script(self, script: str) -> str:
- """Fuehrt ein ReGaHss-Script auf der CCU aus (exec.cgi).
- Args:
- script: HM-Script Code
- Returns:
- Script-Ausgabe als String
- """
- session = await self._ensure_session()
- url = f"{self._api_base}/exec.cgi"
- params = {"sid": self._session_id}
- data = aiohttp.FormData()
- data.add_field("script", script)
- try:
- async with session.post(url, params=params, data=data) as resp:
- raw = await resp.read()
- return raw.decode("iso-8859-1")
- except aiohttp.ClientError as e:
- raise RuntimeError(f"Script-Ausfuehrung fehlgeschlagen: {e}")
- # ========================================================================
- # Token-Verwaltung
- # ========================================================================
- async def get_token_list(self) -> list[dict[str, str]]:
- """Gibt registrierte Tokens zurueck (tokenlist.cgi).
- Returns:
- Liste von {token: str, description: str}
- """
- root = await self._request("tokenlist.cgi")
- tokens: list[dict[str, str]] = []
- for token_elem in root.findall("token"):
- tokens.append({
- "token": token_elem.text or "",
- "description": token_elem.get("desc", ""),
- })
- return tokens
- async def register_token(self, description: str = "") -> str:
- """Registriert einen neuen Token (tokenregister.cgi).
- Args:
- description: Beschreibung des Tokens
- Returns:
- Token-Wert als String
- """
- params: dict[str, str] = {}
- if description:
- params["desc"] = description
- root = await self._request("tokenregister.cgi", **params)
- # Response: <tokenregister><token desc="...">TOKEN_VALUE</token></tokenregister>
- token = root.find("token")
- return token.text if token is not None and token.text else ""
- async def revoke_token(self, token: str) -> bool:
- """Widerruft einen Token (tokenrevoke.cgi).
- Args:
- token: Der Token-Wert der widerrufen werden soll
- """
- try:
- await self._request("tokenrevoke.cgi", sid=token)
- return True
- except Exception:
- return False
- # ========================================================================
- # System
- # ========================================================================
- async def get_version(self) -> str:
- """Gibt die XML-API-Version zurueck (version.cgi)."""
- root = await self._request("version.cgi")
- return root.text or ""
- async def check_update(self) -> dict[str, str]:
- """Prueft auf Updates (update.cgi)."""
- root = await self._request("update.cgi")
- return {
- "current": root.get("current_version", root.findtext("current", "")),
- "latest": root.get("latest_version", root.findtext("latest", "")),
- "update_available": root.get("update_available", ""),
- }
- # ========================================================================
- # Komfort-Methoden
- # ========================================================================
- async def find_device_by_name(self, name: str) -> Device | None:
- """Sucht ein Geraet nach Name (case-insensitive)."""
- devices = await self.get_device_list()
- name_lower = name.lower()
- for device in devices:
- if device.name.lower() == name_lower:
- return device
- return None
- async def find_channel_by_name(self, name: str) -> tuple[Device, Channel] | None:
- """Sucht einen Channel nach Name (case-insensitive)."""
- devices = await self.get_device_list()
- name_lower = name.lower()
- for device in devices:
- for channel in device.channels.values():
- if channel.name.lower() == name_lower:
- return device, channel
- return None
- async def find_room_by_name(self, name: str) -> Room | None:
- """Sucht einen Raum nach Name (case-insensitive)."""
- rooms = await self.get_room_list()
- name_lower = name.lower()
- for room in rooms:
- if room.name.lower() == name_lower:
- return room
- return None
- async def find_program_by_name(self, name: str) -> Program | None:
- """Sucht ein Programm nach Name (case-insensitive)."""
- programs = await self.get_program_list()
- name_lower = name.lower()
- for prog in programs:
- if prog.name.lower() == name_lower:
- return prog
- return None
- async def find_sysvar_by_name(self, name: str) -> SystemVariable | None:
- """Sucht eine Systemvariable nach Name (case-insensitive)."""
- sysvars = await self.get_sysvar_list()
- name_lower = name.lower()
- for sv in sysvars:
- if sv.name.lower() == name_lower:
- return sv
- return None
- async def get_channel_state(self, channel_id: str) -> dict[str, Datapoint]:
- """Gibt alle Datenpunkte eines Channels zurueck.
- Returns:
- Dict von {datapoint_type: Datapoint}
- """
- devices = await self.get_state(channel_id=channel_id)
- for device in devices:
- for channel in device.channels.values():
- if channel.ise_id == channel_id:
- return channel.datapoints
- return {}
- async def get_datapoint_value(
- self, channel_id: str, dp_type: str
- ) -> str | None:
- """Gibt den Wert eines bestimmten Datenpunkts zurueck.
- Args:
- channel_id: Channel ISE-ID
- dp_type: Datenpunkt-Typ (z.B. "STATE", "LEVEL", "ACTUAL_TEMPERATURE")
- """
- dps = await self.get_channel_state(channel_id)
- dp = dps.get(dp_type)
- return dp.value if dp else None
- async def get_rooms_with_devices(self) -> list[dict[str, Any]]:
- """Gibt Raeume mit aufgeloesten Geraete-Informationen zurueck."""
- rooms = await self.get_room_list()
- devices = await self.get_device_list()
- # Channel-ID → Device/Channel Mapping
- channel_map: dict[str, tuple[Device, Channel]] = {}
- for device in devices:
- for channel in device.channels.values():
- channel_map[channel.ise_id] = (device, channel)
- result: list[dict[str, Any]] = []
- for room in rooms:
- room_devices: list[dict[str, str]] = []
- for ch_id in room.channel_ids:
- if ch_id in channel_map:
- dev, ch = channel_map[ch_id]
- room_devices.append({
- "device_name": dev.name,
- "device_type": dev.device_type,
- "channel_name": ch.name,
- "channel_id": ch.ise_id,
- })
- result.append({
- "name": room.name,
- "ise_id": room.ise_id,
- "devices": room_devices,
- })
- return result
- async def get_temperature(self, channel_id: str) -> float | None:
- """Gibt die aktuelle Temperatur eines Thermostats zurueck."""
- val = await self.get_datapoint_value(channel_id, "ACTUAL_TEMPERATURE")
- if val is not None:
- try:
- return float(val)
- except ValueError:
- pass
- return None
- async def set_temperature(self, channel_id: str, temperature: float) -> bool:
- """Setzt die Soll-Temperatur eines Thermostats.
- Args:
- channel_id: Channel ISE-ID des Thermostat-Kanals
- temperature: Zieltemperatur in Grad Celsius
- """
- dps = await self.get_channel_state(channel_id)
- dp = dps.get("SET_TEMPERATURE")
- if dp is None:
- pwarn(f"[Homematic] Kein SET_TEMPERATURE Datenpunkt fuer Channel {channel_id}")
- return False
- return await self.set_state(dp.ise_id, str(temperature))
- async def switch(self, channel_id: str, on: bool) -> bool:
- """Schaltet einen Schaltaktor ein/aus.
- Args:
- channel_id: Channel ISE-ID
- on: True = ein, False = aus
- """
- dps = await self.get_channel_state(channel_id)
- dp = dps.get("STATE")
- if dp is None:
- pwarn(f"[Homematic] Kein STATE Datenpunkt fuer Channel {channel_id}")
- return False
- return await self.set_state(dp.ise_id, "true" if on else "false")
- async def set_dimmer(self, channel_id: str, level: float) -> bool:
- """Setzt den Dimmer-Level (0.0 - 1.0).
- Args:
- channel_id: Channel ISE-ID des Dimmer-Kanals
- level: Helligkeit (0.0 = aus, 1.0 = voll)
- """
- dps = await self.get_channel_state(channel_id)
- dp = dps.get("LEVEL")
- if dp is None:
- pwarn(f"[Homematic] Kein LEVEL Datenpunkt fuer Channel {channel_id}")
- return False
- return await self.set_state(dp.ise_id, str(max(0.0, min(1.0, level))))
- async def is_window_open(self, channel_id: str) -> bool | None:
- """Prueft ob ein Fenster-/Tuerkontakt geoeffnet ist."""
- val = await self.get_datapoint_value(channel_id, "STATE")
- if val is not None:
- return val.lower() in ("true", "1")
- return None
- async def get_all_temperatures(self) -> dict[str, dict[str, float]]:
- """Gibt alle Thermostat-Temperaturen zurueck.
- Sucht Channels mit ACTUAL_TEMPERATURE und SET_TEMPERATURE Datenpunkten
- (nur Channels die beide haben sind echte Thermostate).
- Returns:
- Dict von {channel_name: {actual: float, target: float, channel_id: str}}
- """
- devices = await self.get_state_list()
- temps: dict[str, dict[str, float]] = {}
- for device in devices:
- for channel in device.channels.values():
- actual = channel.datapoints.get("ACTUAL_TEMPERATURE")
- target = channel.datapoints.get("SET_TEMPERATURE")
- # Nur echte Thermostate haben beide Datenpunkte
- if actual is not None and target is not None:
- temps[channel.name] = {
- "actual": actual.value_float,
- "target": target.value_float,
- "channel_id": channel.ise_id,
- }
- return temps
- async def get_all_window_states(self) -> dict[str, bool]:
- """Gibt alle Fenster-/Tuerkontakt-Zustaende zurueck.
- Nutzt devicelist fuer Typ-Filterung und statelist fuer Zustaende.
- Filtert auf: HM-Sec-SCo, HM-Sec-SC, HM-Sec-RHS, HM-SCI-3-FM, HmIP-SWDO
- Returns:
- Dict von {channel_name: is_open}
- """
- contact_types = ("Sec-SC", "Sec-RHS", "SCI-", "SWDO", "SWDM")
- # Geraete mit Typen laden + State-Daten
- device_list, state_list = await asyncio.gather(
- self.get_device_list(),
- self.get_state_list(),
- )
- # ISE-IDs der Kontakt-Geraete sammeln
- contact_device_ids: set[str] = set()
- for device in device_list:
- if any(t in device.device_type for t in contact_types):
- contact_device_ids.add(device.ise_id)
- # States filtern
- windows: dict[str, bool] = {}
- for device in state_list:
- if device.ise_id not in contact_device_ids:
- continue
- for channel in device.channels.values():
- state_dp = channel.datapoints.get("STATE")
- if state_dp and channel.visible == "true":
- windows[channel.name] = state_dp.value_bool
- return windows
|