Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for the Fibaro devices."""
2 
3 from __future__ import annotations
4 
5 from collections import defaultdict
6 from collections.abc import Callable, Mapping
7 import logging
8 from typing import Any
9 
10 from pyfibaro.fibaro_client import FibaroClient
11 from pyfibaro.fibaro_device import DeviceModel
12 from pyfibaro.fibaro_room import RoomModel
13 from pyfibaro.fibaro_scene import SceneModel
14 from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver
15 from requests.exceptions import HTTPError
16 
17 from homeassistant.config_entries import ConfigEntry
18 from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform
19 from homeassistant.core import HomeAssistant
20 from homeassistant.exceptions import (
21  ConfigEntryAuthFailed,
22  ConfigEntryNotReady,
23  HomeAssistantError,
24 )
25 from homeassistant.helpers import device_registry as dr
26 from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo
27 from homeassistant.util import slugify
28 
29 from .const import CONF_IMPORT_PLUGINS, DOMAIN
30 
31 _LOGGER = logging.getLogger(__name__)
32 
33 
34 PLATFORMS = [
35  Platform.BINARY_SENSOR,
36  Platform.CLIMATE,
37  Platform.COVER,
38  Platform.EVENT,
39  Platform.LIGHT,
40  Platform.LOCK,
41  Platform.SCENE,
42  Platform.SENSOR,
43  Platform.SWITCH,
44 ]
45 
46 FIBARO_TYPEMAP = {
47  "com.fibaro.multilevelSensor": Platform.SENSOR,
48  "com.fibaro.binarySwitch": Platform.SWITCH,
49  "com.fibaro.multilevelSwitch": Platform.SWITCH,
50  "com.fibaro.FGD212": Platform.LIGHT,
51  "com.fibaro.FGR": Platform.COVER,
52  "com.fibaro.doorSensor": Platform.BINARY_SENSOR,
53  "com.fibaro.doorWindowSensor": Platform.BINARY_SENSOR,
54  "com.fibaro.FGMS001": Platform.BINARY_SENSOR,
55  "com.fibaro.heatDetector": Platform.BINARY_SENSOR,
56  "com.fibaro.lifeDangerSensor": Platform.BINARY_SENSOR,
57  "com.fibaro.smokeSensor": Platform.BINARY_SENSOR,
58  "com.fibaro.remoteSwitch": Platform.SWITCH,
59  "com.fibaro.sensor": Platform.SENSOR,
60  "com.fibaro.colorController": Platform.LIGHT,
61  "com.fibaro.securitySensor": Platform.BINARY_SENSOR,
62  "com.fibaro.hvac": Platform.CLIMATE,
63  "com.fibaro.hvacSystem": Platform.CLIMATE,
64  "com.fibaro.setpoint": Platform.CLIMATE,
65  "com.fibaro.FGT001": Platform.CLIMATE,
66  "com.fibaro.thermostatDanfoss": Platform.CLIMATE,
67  "com.fibaro.doorLock": Platform.LOCK,
68  "com.fibaro.binarySensor": Platform.BINARY_SENSOR,
69  "com.fibaro.accelerometer": Platform.BINARY_SENSOR,
70 }
71 
72 
74  """Initiate Fibaro Controller Class."""
75 
76  def __init__(self, config: Mapping[str, Any]) -> None:
77  """Initialize the Fibaro controller."""
78 
79  # The FibaroClient uses the correct API version automatically
80  self._client_client = FibaroClient(config[CONF_URL])
81  self._client_client.set_authentication(config[CONF_USERNAME], config[CONF_PASSWORD])
82 
83  # Whether to import devices from plugins
84  self._import_plugins_import_plugins = config[CONF_IMPORT_PLUGINS]
85  self._room_map_room_map: dict[int, RoomModel] # Mapping roomId to room object
86  self._device_map_device_map: dict[int, DeviceModel] # Mapping deviceId to device object
87  self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict(
88  list
89  ) # List of devices by entity platform
90  # All scenes
91  self._scenes_scenes: list[SceneModel] = []
92  self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId
93  # Event callbacks by device id
94  self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {}
95  self.hub_serialhub_serial: str # Unique serial number of the hub
96  self.hub_namehub_name: str # The friendly name of the hub
97  self.hub_modelhub_model: str
98  self.hub_software_versionhub_software_version: str
99  self.hub_api_url: str = config[CONF_URL]
100  # Device infos by fibaro device id
101  self._device_infos: dict[int, DeviceInfo] = {}
102 
103  def connect(self) -> None:
104  """Start the communication with the Fibaro controller."""
105 
106  # Return value doesn't need to be checked,
107  # it is only relevant when connecting without credentials
108  self._client_client.connect()
109  info = self._client_client.read_info()
110  self.hub_serialhub_serial = info.serial_number
111  self.hub_namehub_name = info.hc_name
112  self.hub_modelhub_model = info.platform
113  self.hub_software_versionhub_software_version = info.current_version
114 
115  self._room_map_room_map = {room.fibaro_id: room for room in self._client_client.read_rooms()}
116  self._read_devices_read_devices()
117  self._scenes_scenes = self._client_client.read_scenes()
118 
119  def connect_with_error_handling(self) -> None:
120  """Translate connect errors to easily differentiate auth and connect failures.
121 
122  When there is a better error handling in the used library this can be improved.
123  """
124  try:
125  self.connectconnect()
126  except HTTPError as http_ex:
127  if http_ex.response.status_code == 403:
128  raise FibaroAuthFailed from http_ex
129 
130  raise FibaroConnectFailed from http_ex
131  except Exception as ex:
132  raise FibaroConnectFailed from ex
133 
134  def enable_state_handler(self) -> None:
135  """Start StateHandler thread for monitoring updates."""
136  self._client_client.register_update_handler(self._on_state_change_on_state_change)
137 
138  def disable_state_handler(self) -> None:
139  """Stop StateHandler thread used for monitoring updates."""
140  self._client_client.unregister_update_handler()
141 
142  def _on_state_change(self, state: Any) -> None:
143  """Handle change report received from the HomeCenter."""
144  callback_set = set()
145  for change in state.get("changes", []):
146  try:
147  dev_id = change.pop("id")
148  if dev_id not in self._device_map_device_map:
149  continue
150  device = self._device_map_device_map[dev_id]
151  for property_name, value in change.items():
152  if property_name == "log":
153  if value and value != "transfer OK":
154  _LOGGER.debug("LOG %s: %s", device.friendly_name, value)
155  continue
156  if property_name == "logTemp":
157  continue
158  if property_name in device.properties:
159  device.properties[property_name] = value
160  _LOGGER.debug(
161  "<- %s.%s = %s", device.ha_id, property_name, str(value)
162  )
163  else:
164  _LOGGER.warning("%s.%s not found", device.ha_id, property_name)
165  if dev_id in self._callbacks:
166  callback_set.add(dev_id)
167  except (ValueError, KeyError):
168  pass
169  for item in callback_set:
170  for callback in self._callbacks[item]:
171  callback()
172 
173  resolver = FibaroStateResolver(state)
174  for event in resolver.get_events():
175  # event does not always have a fibaro id, therefore it is
176  # essential that we first check for relevant event type
177  if (
178  event.event_type.lower() == "centralsceneevent"
179  and event.fibaro_id in self._event_callbacks
180  ):
181  for callback in self._event_callbacks[event.fibaro_id]:
182  callback(event)
183 
184  def register(self, device_id: int, callback: Any) -> None:
185  """Register device with a callback for updates."""
186  device_callbacks = self._callbacks.setdefault(device_id, [])
187  device_callbacks.append(callback)
188 
190  self, device_id: int, callback: Callable[[FibaroEvent], None]
191  ) -> None:
192  """Register device with a callback for central scene events.
193 
194  The callback receives one parameter with the event.
195  """
196  device_callbacks = self._event_callbacks.setdefault(device_id, [])
197  device_callbacks.append(callback)
198 
199  def get_children(self, device_id: int) -> list[DeviceModel]:
200  """Get a list of child devices."""
201  return [
202  device
203  for device in self._device_map_device_map.values()
204  if device.parent_fibaro_id == device_id
205  ]
206 
207  def get_children2(self, device_id: int, endpoint_id: int) -> list[DeviceModel]:
208  """Get a list of child devices for the same endpoint."""
209  return [
210  device
211  for device in self._device_map_device_map.values()
212  if device.parent_fibaro_id == device_id
213  and (not device.has_endpoint_id or device.endpoint_id == endpoint_id)
214  ]
215 
216  def get_siblings(self, device: DeviceModel) -> list[DeviceModel]:
217  """Get the siblings of a device."""
218  if device.has_endpoint_id:
219  return self.get_children2get_children2(device.parent_fibaro_id, device.endpoint_id)
220  return self.get_childrenget_children(device.parent_fibaro_id)
221 
222  @staticmethod
223  def _map_device_to_platform(device: DeviceModel) -> Platform | None:
224  """Map device to HA device type."""
225  # Use our lookup table to identify device type
226  platform: Platform | None = None
227  if device.type:
228  platform = FIBARO_TYPEMAP.get(device.type)
229  if platform is None and device.base_type:
230  platform = FIBARO_TYPEMAP.get(device.base_type)
231 
232  # We can also identify device type by its capabilities
233  if platform is None:
234  if "setBrightness" in device.actions:
235  platform = Platform.LIGHT
236  elif "turnOn" in device.actions:
237  platform = Platform.SWITCH
238  elif "open" in device.actions:
239  platform = Platform.COVER
240  elif "secure" in device.actions:
241  platform = Platform.LOCK
242  elif device.has_central_scene_event:
243  platform = Platform.EVENT
244  elif device.value.has_value and device.value.is_bool_value:
245  platform = Platform.BINARY_SENSOR
246  elif (
247  device.value.has_value
248  or "power" in device.properties
249  or "energy" in device.properties
250  ):
251  platform = Platform.SENSOR
252 
253  # Switches that control lights should show up as lights
254  if platform == Platform.SWITCH and device.properties.get("isLight", False):
255  platform = Platform.LIGHT
256  return platform
257 
259  self, device: DeviceModel, devices: list[DeviceModel]
260  ) -> None:
261  """Create the device info. Unrooted entities are directly shown below the home center."""
262 
263  # The home center is always id 1 (z-wave primary controller)
264  if device.parent_fibaro_id <= 1:
265  return
266 
267  master_entity: DeviceModel | None = None
268  if device.parent_fibaro_id == 1:
269  master_entity = device
270  else:
271  for parent in devices:
272  if parent.fibaro_id == device.parent_fibaro_id:
273  master_entity = parent
274  if master_entity is None:
275  _LOGGER.error("Parent with id %s not found", device.parent_fibaro_id)
276  return
277 
278  if "zwaveCompany" in master_entity.properties:
279  manufacturer = master_entity.properties.get("zwaveCompany")
280  else:
281  manufacturer = None
282 
283  self._device_infos[master_entity.fibaro_id] = DeviceInfo(
284  identifiers={(DOMAIN, master_entity.fibaro_id)},
285  manufacturer=manufacturer,
286  name=master_entity.name,
287  via_device=(DOMAIN, self.hub_serialhub_serial),
288  )
289 
290  def get_device_info(self, device: DeviceModel) -> DeviceInfo:
291  """Get the device info by fibaro device id."""
292  if device.fibaro_id in self._device_infos:
293  return self._device_infos[device.fibaro_id]
294  if device.parent_fibaro_id in self._device_infos:
295  return self._device_infos[device.parent_fibaro_id]
296  return DeviceInfo(identifiers={(DOMAIN, self.hub_serialhub_serial)})
297 
298  def get_all_device_identifiers(self) -> list[set[tuple[str, str]]]:
299  """Get all identifiers of fibaro integration."""
300  return [device["identifiers"] for device in self._device_infos.values()]
301 
302  def get_room_name(self, room_id: int) -> str | None:
303  """Get the room name by room id."""
304  assert self._room_map_room_map
305  room = self._room_map_room_map.get(room_id)
306  return room.name if room else None
307 
308  def read_scenes(self) -> list[SceneModel]:
309  """Return list of scenes."""
310  return self._scenes_scenes
311 
312  def _read_devices(self) -> None:
313  """Read and process the device list."""
314  devices = self._client_client.read_devices()
315  self._device_map_device_map = {}
316  last_climate_parent = None
317  last_endpoint = None
318  for device in devices:
319  try:
320  device.fibaro_controller = self
321  if device.room_id == 0:
322  room_name = "Unknown"
323  else:
324  room_name = self._room_map_room_map[device.room_id].name
325  device.room_name = room_name
326  device.friendly_name = f"{room_name} {device.name}"
327  device.ha_id = (
328  f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}"
329  )
330  if device.enabled and (not device.is_plugin or self._import_plugins_import_plugins):
331  device.mapped_platform = self._map_device_to_platform_map_device_to_platform(device)
332  else:
333  device.mapped_platform = None
334  if (platform := device.mapped_platform) is None:
335  continue
336  device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}"
337  self._create_device_info_create_device_info(device, devices)
338  self._device_map_device_map[device.fibaro_id] = device
339  _LOGGER.debug(
340  "%s (%s, %s) -> %s %s",
341  device.ha_id,
342  device.type,
343  device.base_type,
344  platform,
345  str(device),
346  )
347  if platform != Platform.CLIMATE:
348  self.fibaro_devices[platform].append(device)
349  continue
350  # We group climate devices into groups with the same
351  # endPointID belonging to the same parent device.
352  if device.has_endpoint_id:
353  _LOGGER.debug(
354  "climate device: %s, endPointId: %s",
355  device.ha_id,
356  device.endpoint_id,
357  )
358  else:
359  _LOGGER.debug("climate device: %s, no endPointId", device.ha_id)
360  # If a sibling of this device has been added, skip this one
361  # otherwise add the first visible device in the group
362  # which is a hack, but solves a problem with FGT having
363  # hidden compatibility devices before the real device
364  if last_climate_parent != device.parent_fibaro_id or (
365  device.has_endpoint_id and last_endpoint != device.endpoint_id
366  ):
367  _LOGGER.debug("Handle separately")
368  self.fibaro_devices[platform].append(device)
369  last_climate_parent = device.parent_fibaro_id
370  last_endpoint = device.endpoint_id
371  else:
372  _LOGGER.debug("not handling separately")
373  except (KeyError, ValueError):
374  pass
375 
376 
377 def init_controller(data: Mapping[str, Any]) -> FibaroController:
378  """Validate the user input allows us to connect to fibaro."""
379  controller = FibaroController(data)
380  controller.connect_with_error_handling()
381  return controller
382 
383 
384 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
385  """Set up the Fibaro Component.
386 
387  The unique id of the config entry is the serial number of the home center.
388  """
389  try:
390  controller = await hass.async_add_executor_job(init_controller, entry.data)
391  except FibaroConnectFailed as connect_ex:
392  raise ConfigEntryNotReady(
393  f"Could not connect to controller at {entry.data[CONF_URL]}"
394  ) from connect_ex
395  except FibaroAuthFailed as auth_ex:
396  raise ConfigEntryAuthFailed from auth_ex
397 
398  hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller
399 
400  # register the hub device info separately as the hub has sometimes no entities
401  device_registry = dr.async_get(hass)
402  device_registry.async_get_or_create(
403  config_entry_id=entry.entry_id,
404  identifiers={(DOMAIN, controller.hub_serial)},
405  serial_number=controller.hub_serial,
406  manufacturer="Fibaro",
407  name=controller.hub_name,
408  model=controller.hub_model,
409  sw_version=controller.hub_software_version,
410  configuration_url=controller.hub_api_url.removesuffix("/api/"),
411  )
412 
413  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
414 
415  controller.enable_state_handler()
416 
417  return True
418 
419 
420 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
421  """Unload a config entry."""
422  _LOGGER.debug("Shutting down Fibaro connection")
423  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
424 
425  hass.data[DOMAIN][entry.entry_id].disable_state_handler()
426  hass.data[DOMAIN].pop(entry.entry_id)
427 
428  return unload_ok
429 
430 
432  hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
433 ) -> bool:
434  """Remove a device entry from fibaro integration.
435 
436  Only removing devices which are not present anymore are eligible to be removed.
437  """
438  controller: FibaroController = hass.data[DOMAIN][config_entry.entry_id]
439  for identifiers in controller.get_all_device_identifiers():
440  if device_entry.identifiers == identifiers:
441  # Fibaro device is still served by the controller,
442  # do not allow to remove the device entry
443  return False
444 
445  return True
446 
447 
449  """Error to indicate we cannot connect to fibaro home center."""
450 
451 
452 class FibaroAuthFailed(HomeAssistantError):
453  """Error to indicate that authentication failed on fibaro home center."""
None __init__(self, Mapping[str, Any] config)
Definition: __init__.py:76
str|None get_room_name(self, int room_id)
Definition: __init__.py:302
list[DeviceModel] get_siblings(self, DeviceModel device)
Definition: __init__.py:216
DeviceInfo get_device_info(self, DeviceModel device)
Definition: __init__.py:290
None register_event(self, int device_id, Callable[[FibaroEvent], None] callback)
Definition: __init__.py:191
list[set[tuple[str, str]]] get_all_device_identifiers(self)
Definition: __init__.py:298
list[DeviceModel] get_children2(self, int device_id, int endpoint_id)
Definition: __init__.py:207
None register(self, int device_id, Any callback)
Definition: __init__.py:184
list[DeviceModel] get_children(self, int device_id)
Definition: __init__.py:199
Platform|None _map_device_to_platform(DeviceModel device)
Definition: __init__.py:223
None _create_device_info(self, DeviceModel device, list[DeviceModel] devices)
Definition: __init__.py:260
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:384
bool async_remove_config_entry_device(HomeAssistant hass, ConfigEntry config_entry, DeviceEntry device_entry)
Definition: __init__.py:433
FibaroController init_controller(Mapping[str, Any] data)
Definition: __init__.py:377
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:420