devices.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. # -*- coding: utf-8 -*-
  2. """
  3. Homematic Device Manager.
  4. Kapselt Geraetelogik: Raum→Geraete-Aufloesung, Filterung nach Kategorie,
  5. Caching und Zustandsabfragen/-aenderungen.
  6. Verwendet device_types.py fuer praezise Geraetetyp-Klassifizierung,
  7. sodass z.B. "Licht im Wohnzimmer an" keine Steckdosen mitschaltet.
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. import time
  12. from dataclasses import dataclass, field
  13. from typing import Any
  14. from trixy_core.utils.debug import pdebug, pwarn
  15. from plugins.homematic.api import HomematicApi, Device, Channel, Room
  16. from plugins.homematic.device_types import (
  17. DeviceCategory,
  18. DeviceTypeInfo,
  19. classify_device,
  20. LIGHT_CATEGORIES,
  21. PLUG_CATEGORIES,
  22. CLIMATE_CATEGORIES,
  23. CONTACT_CATEGORIES,
  24. MOTION_CATEGORIES,
  25. )
  26. @dataclass
  27. class ResolvedChannel:
  28. """Ein aufgeloester Channel mit Geraete- und Typ-Informationen."""
  29. device: Device
  30. channel: Channel
  31. type_info: DeviceTypeInfo
  32. room_name: str = ""
  33. @property
  34. def category(self) -> DeviceCategory:
  35. return self.type_info.category
  36. @property
  37. def name(self) -> str:
  38. return self.channel.name
  39. @property
  40. def channel_id(self) -> str:
  41. return self.channel.ise_id
  42. @property
  43. def device_name(self) -> str:
  44. return self.device.name
  45. @property
  46. def device_type(self) -> str:
  47. return self.device.device_type
  48. class DeviceManager:
  49. """Verwaltet Homematic-Geraete mit Raum-Zuordnung und Typ-Klassifizierung.
  50. Laedt Geraete, Raeume und Programme von der CCU,
  51. klassifiziert nach Geraetetyp und bietet Filtermethoden.
  52. """
  53. def __init__(
  54. self,
  55. api: HomematicApi,
  56. room_aliases: dict[str, str] | None = None,
  57. satellite_rooms: dict[str, str] | None = None,
  58. ) -> None:
  59. self._api = api
  60. self._room_aliases = room_aliases or {}
  61. self._satellite_rooms = satellite_rooms or {}
  62. # Caches
  63. self._devices: dict[str, Device] = {} # ise_id → Device
  64. self._device_types: dict[str, DeviceTypeInfo] = {} # device_ise_id → TypeInfo
  65. self._rooms: dict[str, Room] = {} # name_lower → Room
  66. self._room_channel_ids: dict[str, set[str]] = {} # room_name_lower → {channel_ids}
  67. self._channel_to_device: dict[str, str] = {} # channel_ise_id → device_ise_id
  68. self._channel_to_room: dict[str, str] = {} # channel_ise_id → room_name_lower
  69. self._cache_time: float = 0.0
  70. self._cache_ttl: float = 300.0
  71. async def refresh(self) -> None:
  72. """Laedt alle Geraete und Raeume von der CCU."""
  73. devices, rooms = await asyncio.gather(
  74. self._api.get_device_list(),
  75. self._api.get_room_list(),
  76. )
  77. self._devices.clear()
  78. self._device_types.clear()
  79. self._channel_to_device.clear()
  80. for device in devices:
  81. self._devices[device.ise_id] = device
  82. type_info = classify_device(device.device_type)
  83. self._device_types[device.ise_id] = type_info
  84. for channel in device.channels.values():
  85. self._channel_to_device[channel.ise_id] = device.ise_id
  86. self._rooms.clear()
  87. self._room_channel_ids.clear()
  88. self._channel_to_room.clear()
  89. for room in rooms:
  90. key = room.name.lower()
  91. self._rooms[key] = room
  92. ch_ids = set(room.channel_ids)
  93. self._room_channel_ids[key] = ch_ids
  94. for ch_id in ch_ids:
  95. self._channel_to_room[ch_id] = key
  96. self._cache_time = time.time()
  97. pdebug(f"[DeviceManager] {len(self._devices)} Geraete, {len(self._rooms)} Raeume geladen")
  98. async def ensure_cache(self) -> None:
  99. """Stellt sicher dass der Cache aktuell ist."""
  100. if time.time() - self._cache_time > self._cache_ttl:
  101. await self.refresh()
  102. def normalize_room(self, room_name: str) -> str:
  103. """Normalisiert einen Raumnamen (Aliases, Lowercase).
  104. Prueft: Alias-Tabelle → direkt in Raeume vorhanden → unveraendert.
  105. """
  106. lower = room_name.lower().strip()
  107. # 1. Expliziter Alias
  108. if lower in self._room_aliases:
  109. return self._room_aliases[lower]
  110. # 2. Schon ein gueltiger Raumname
  111. if lower in self._rooms:
  112. return lower
  113. return lower
  114. def resolve_room_for_satellite(
  115. self, satellite_id: str, room_id: str, alias: str = ""
  116. ) -> str:
  117. """Loest den Homematic-Raum fuer einen Satellite auf.
  118. Reihenfolge:
  119. 1. Explizites Mapping in satellite_rooms (Satellite-ID oder Alias)
  120. 2. room_id direkt als Raumname pruefen
  121. 3. room_id/alias ueber room_aliases aufloesen
  122. 4. Leer (kein Raum gefunden)
  123. Args:
  124. satellite_id: Satellite-ID (z.B. MAC-Adresse)
  125. room_id: Room-ID des Satellites (z.B. "livingroom")
  126. alias: Satellite-Alias (z.B. "Wohnzimmer")
  127. Returns:
  128. Normalisierter Homematic-Raumname oder ""
  129. """
  130. # 1. Explizites Satellite-Mapping (per ID oder Alias)
  131. for key in (satellite_id, alias.lower(), room_id.lower()):
  132. if not key:
  133. continue
  134. mapped = self._satellite_rooms.get(key)
  135. if mapped:
  136. normalized = mapped.lower()
  137. if normalized in self._rooms:
  138. return normalized
  139. # 2. room_id direkt als Raumname
  140. if room_id:
  141. lower = room_id.lower()
  142. if lower in self._rooms:
  143. return lower
  144. # 3. room_id oder alias ueber Alias-Tabelle
  145. for candidate in (room_id, alias):
  146. if not candidate:
  147. continue
  148. resolved = self._room_aliases.get(candidate.lower())
  149. if resolved and resolved in self._rooms:
  150. return resolved
  151. # 4. Alias direkt pruefen
  152. if alias:
  153. lower = alias.lower()
  154. if lower in self._rooms:
  155. return lower
  156. return ""
  157. def get_room_names(self) -> list[str]:
  158. """Gibt alle Raumnamen zurueck."""
  159. return [r.name for r in self._rooms.values()]
  160. # ========================================================================
  161. # Geraete-Filter
  162. # ========================================================================
  163. def get_channels_in_room(
  164. self,
  165. room_name: str,
  166. categories: frozenset[DeviceCategory] | None = None,
  167. ) -> list[ResolvedChannel]:
  168. """Gibt alle Channels in einem Raum zurueck, optional nach Kategorie gefiltert.
  169. Args:
  170. room_name: Raum (wird normalisiert)
  171. categories: Nur diese Kategorien (None = alle)
  172. Returns:
  173. Liste von ResolvedChannel
  174. """
  175. room_key = self.normalize_room(room_name)
  176. channel_ids = self._room_channel_ids.get(room_key, set())
  177. if not channel_ids:
  178. return []
  179. results: list[ResolvedChannel] = []
  180. for device in self._devices.values():
  181. type_info = self._device_types[device.ise_id]
  182. if categories and type_info.category not in categories:
  183. continue
  184. for channel in device.channels.values():
  185. if channel.ise_id in channel_ids:
  186. results.append(ResolvedChannel(
  187. device=device,
  188. channel=channel,
  189. type_info=type_info,
  190. room_name=room_key,
  191. ))
  192. return results
  193. def get_lights_in_room(self, room_name: str) -> list[ResolvedChannel]:
  194. """Gibt alle Licht-Channels in einem Raum zurueck (keine Steckdosen!)."""
  195. return self.get_channels_in_room(room_name, LIGHT_CATEGORIES)
  196. def get_thermostats_in_room(self, room_name: str) -> list[ResolvedChannel]:
  197. """Gibt alle Thermostat-Channels in einem Raum zurueck."""
  198. return self.get_channels_in_room(room_name, CLIMATE_CATEGORIES)
  199. def get_contacts_in_room(self, room_name: str) -> list[ResolvedChannel]:
  200. """Gibt alle Fenster-/Tuer-Kontakte in einem Raum zurueck."""
  201. return self.get_channels_in_room(room_name, CONTACT_CATEGORIES)
  202. def get_motion_sensors_in_room(self, room_name: str) -> list[ResolvedChannel]:
  203. """Gibt alle Bewegungsmelder in einem Raum zurueck."""
  204. return self.get_channels_in_room(room_name, MOTION_CATEGORIES)
  205. def get_all_channels(
  206. self,
  207. categories: frozenset[DeviceCategory] | None = None,
  208. ) -> list[ResolvedChannel]:
  209. """Gibt alle Channels zurueck, optional nach Kategorie gefiltert."""
  210. results: list[ResolvedChannel] = []
  211. for device in self._devices.values():
  212. type_info = self._device_types[device.ise_id]
  213. if categories and type_info.category not in categories:
  214. continue
  215. for channel in device.channels.values():
  216. room = self._channel_to_room.get(channel.ise_id, "")
  217. results.append(ResolvedChannel(
  218. device=device, channel=channel,
  219. type_info=type_info, room_name=room,
  220. ))
  221. return results
  222. def room_exists(self, room_name: str) -> bool:
  223. """Prueft ob ein Raum existiert."""
  224. return self.normalize_room(room_name) in self._rooms
  225. # ========================================================================
  226. # Aktionen
  227. # ========================================================================
  228. async def switch_lights(self, room_name: str, on: bool) -> int:
  229. """Schaltet alle Lichter in einem Raum ein/aus.
  230. Beruecksichtigt Geraetetyp: Nur LIGHT_SWITCH, DIMMER, LED_RGBW, etc.
  231. Steckdosen (PSM, PS) werden NICHT geschaltet.
  232. Returns:
  233. Anzahl geschalteter Channels
  234. """
  235. lights = self.get_lights_in_room(room_name)
  236. if not lights:
  237. return 0
  238. switched = 0
  239. for resolved in lights:
  240. # Nur Channels mit sichtbarem Schalt-/Dimm-Kanal
  241. if resolved.channel.visible != "true":
  242. continue
  243. try:
  244. dps = await self._api.get_channel_state(resolved.channel_id)
  245. if "STATE" in dps:
  246. await self._api.set_state(
  247. dps["STATE"].ise_id,
  248. "true" if on else "false",
  249. )
  250. switched += 1
  251. elif "LEVEL" in dps:
  252. await self._api.set_state(
  253. dps["LEVEL"].ise_id,
  254. "1.0" if on else "0.0",
  255. )
  256. switched += 1
  257. except Exception as e:
  258. pdebug(f"[DeviceManager] Licht-Fehler {resolved.name}: {e}")
  259. return switched
  260. async def get_light_states(self, room_name: str) -> dict[str, bool]:
  261. """Gibt Licht-Zustaende in einem Raum zurueck.
  262. Returns:
  263. Dict von {channel_name: is_on}
  264. """
  265. lights = self.get_lights_in_room(room_name)
  266. states: dict[str, bool] = {}
  267. for resolved in lights:
  268. if resolved.channel.visible != "true":
  269. continue
  270. try:
  271. dps = await self._api.get_channel_state(resolved.channel_id)
  272. if "STATE" in dps:
  273. states[resolved.name] = dps["STATE"].value_bool
  274. elif "LEVEL" in dps:
  275. states[resolved.name] = dps["LEVEL"].value_float > 0
  276. except Exception:
  277. pass
  278. return states
  279. async def set_led_color(self, room_name: str, color_value: int) -> int:
  280. """Setzt die Farbe aller LED-RGBW-Controller in einem Raum.
  281. Args:
  282. color_value: HM-LC-RGBW-WM: 0-200 (Hue + 200=Weiss)
  283. HmIP-RGBW: HSV-basiert
  284. Returns:
  285. Anzahl geaenderter Channels
  286. """
  287. led_channels = self.get_channels_in_room(
  288. room_name,
  289. frozenset({DeviceCategory.LED_RGBW, DeviceCategory.LED_CONTROLLER}),
  290. )
  291. changed = 0
  292. for resolved in led_channels:
  293. try:
  294. dps = await self._api.get_channel_state(resolved.channel_id)
  295. if "COLOR" in dps:
  296. await self._api.set_state(dps["COLOR"].ise_id, str(color_value))
  297. changed += 1
  298. elif "HUE" in dps:
  299. # HmIP-RGBW: HUE Datenpunkt
  300. await self._api.set_state(dps["HUE"].ise_id, str(color_value))
  301. changed += 1
  302. except Exception as e:
  303. pdebug(f"[DeviceManager] LED-Farbe Fehler {resolved.name}: {e}")
  304. return changed
  305. async def set_led_level(self, room_name: str, level: float) -> int:
  306. """Setzt die Helligkeit aller LED-Controller in einem Raum.
  307. Args:
  308. level: 0.0 (aus) bis 1.0 (voll)
  309. Returns:
  310. Anzahl geaenderter Channels
  311. """
  312. led_channels = self.get_channels_in_room(
  313. room_name,
  314. frozenset({DeviceCategory.LED_RGBW, DeviceCategory.LED_CONTROLLER, DeviceCategory.DIMMER}),
  315. )
  316. changed = 0
  317. for resolved in led_channels:
  318. # Nur Dimmer/LED-Channels, nicht Farb- oder Programm-Channels
  319. if resolved.channel.visible != "true":
  320. continue
  321. try:
  322. dps = await self._api.get_channel_state(resolved.channel_id)
  323. if "LEVEL" in dps:
  324. await self._api.set_state(
  325. dps["LEVEL"].ise_id,
  326. str(max(0.0, min(1.0, level))),
  327. )
  328. changed += 1
  329. except Exception as e:
  330. pdebug(f"[DeviceManager] LED-Level Fehler {resolved.name}: {e}")
  331. return changed
  332. async def get_temperatures(
  333. self, room_name: str = ""
  334. ) -> list[dict[str, Any]]:
  335. """Gibt Thermostat-Temperaturen zurueck.
  336. Args:
  337. room_name: Raum (leer = alle Raeume)
  338. Returns:
  339. Liste von {name, actual, target, channel_id, room}
  340. """
  341. if room_name:
  342. channels = self.get_thermostats_in_room(room_name)
  343. else:
  344. channels = self.get_all_channels(CLIMATE_CATEGORIES)
  345. results: list[dict[str, Any]] = []
  346. for resolved in channels:
  347. try:
  348. dps = await self._api.get_channel_state(resolved.channel_id)
  349. actual = dps.get("ACTUAL_TEMPERATURE")
  350. target = dps.get("SET_TEMPERATURE")
  351. if actual and target:
  352. results.append({
  353. "name": resolved.name,
  354. "actual": actual.value_float,
  355. "target": target.value_float,
  356. "channel_id": resolved.channel_id,
  357. "room": resolved.room_name,
  358. })
  359. except Exception:
  360. pass
  361. return results
  362. async def set_temperature(self, room_name: str, temp: float) -> int:
  363. """Setzt die Soll-Temperatur aller Thermostate in einem Raum.
  364. Returns:
  365. Anzahl geaenderter Thermostate
  366. """
  367. thermostat_cats = frozenset({DeviceCategory.THERMOSTAT, DeviceCategory.WALL_THERMOSTAT})
  368. channels = self.get_channels_in_room(room_name, thermostat_cats)
  369. changed = 0
  370. for resolved in channels:
  371. try:
  372. success = await self._api.set_temperature(resolved.channel_id, temp)
  373. if success:
  374. changed += 1
  375. except Exception as e:
  376. pdebug(f"[DeviceManager] Temperatur-Fehler {resolved.name}: {e}")
  377. return changed
  378. async def get_window_states(
  379. self, room_name: str = ""
  380. ) -> dict[str, bool]:
  381. """Gibt Fenster-/Tuer-Zustaende zurueck.
  382. Returns:
  383. Dict von {channel_name: is_open}
  384. """
  385. if room_name:
  386. channels = self.get_contacts_in_room(room_name)
  387. else:
  388. channels = self.get_all_channels(CONTACT_CATEGORIES)
  389. states: dict[str, bool] = {}
  390. for resolved in channels:
  391. if resolved.channel.visible != "true":
  392. continue
  393. try:
  394. dps = await self._api.get_channel_state(resolved.channel_id)
  395. state = dps.get("STATE")
  396. if state:
  397. states[resolved.name] = state.value_bool
  398. except Exception:
  399. pass
  400. return states
  401. async def get_last_motion(self, room_name: str) -> dict[str, Any] | None:
  402. """Gibt den letzten Bewegungszeitpunkt in einem Raum zurueck.
  403. Returns:
  404. {name, timestamp, brightness} oder None
  405. """
  406. channels = self.get_motion_sensors_in_room(room_name)
  407. for resolved in channels:
  408. try:
  409. dps = await self._api.get_channel_state(resolved.channel_id)
  410. motion = dps.get("MOTION")
  411. if motion:
  412. result: dict[str, Any] = {
  413. "name": resolved.name,
  414. "timestamp": int(motion.timestamp),
  415. }
  416. brightness = dps.get("BRIGHTNESS") or dps.get("ILLUMINATION")
  417. if brightness:
  418. result["brightness"] = brightness.value_float
  419. return result
  420. except Exception:
  421. pass
  422. return None