"""Representation of a deCONZ gateway.""" from __future__ import annotations import asyncio from types import MappingProxyType from typing import Any, cast import async_timeout from pydeconz import DeconzSession, errors, group, light, sensor from pydeconz.alarm_system import AlarmSystem as DeconzAlarmSystem from pydeconz.group import Group as DeconzGroup from pydeconz.light import DeconzLight from pydeconz.sensor import DeconzSensor from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( aiohttp_client, device_registry as dr, entity_registry as er, ) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_DECONZ_GROUPS, CONF_ALLOW_NEW_DEVICES, CONF_MASTER_GATEWAY, DEFAULT_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_NEW_DEVICES, DOMAIN as DECONZ_DOMAIN, HASSIO_CONFIGURATION_URL, LOGGER, PLATFORMS, ) from .deconz_event import DeconzAlarmEvent, DeconzEvent from .errors import AuthenticationRequired, CannotConnect class DeconzGateway: """Manages a single deCONZ gateway.""" def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, api: DeconzSession ) -> None: """Initialize the system.""" self.hass = hass self.config_entry = config_entry self.api = api api.add_device_callback = self.async_add_device_callback api.connection_status_callback = self.async_connection_status_callback self.available = True self.ignore_state_updates = False self.signal_reachable = f"deconz-reachable-{config_entry.entry_id}" self.signal_new_group = f"deconz_new_group_{config_entry.entry_id}" self.signal_new_light = f"deconz_new_light_{config_entry.entry_id}" self.signal_new_scene = f"deconz_new_scene_{config_entry.entry_id}" self.signal_new_sensor = f"deconz_new_sensor_{config_entry.entry_id}" self.deconz_resource_type_to_signal_new_device = { group.RESOURCE_TYPE: self.signal_new_group, light.RESOURCE_TYPE: self.signal_new_light, group.RESOURCE_TYPE_SCENE: self.signal_new_scene, sensor.RESOURCE_TYPE: self.signal_new_sensor, } self.deconz_ids: dict[str, str] = {} self.entities: dict[str, set[str]] = {} self.events: list[DeconzAlarmEvent | DeconzEvent] = [] @property def bridgeid(self) -> str: """Return the unique identifier of the gateway.""" return cast(str, self.config_entry.unique_id) @property def host(self) -> str: """Return the host of the gateway.""" return cast(str, self.config_entry.data[CONF_HOST]) @property def master(self) -> bool: """Gateway which is used with deCONZ services without defining id.""" return cast(bool, self.config_entry.options[CONF_MASTER_GATEWAY]) # Options @property def option_allow_clip_sensor(self) -> bool: """Allow loading clip sensor from gateway.""" return self.config_entry.options.get( CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR ) @property def option_allow_deconz_groups(self) -> bool: """Allow loading deCONZ groups from gateway.""" return self.config_entry.options.get( CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS ) @property def option_allow_new_devices(self) -> bool: """Allow automatic adding of new devices.""" return self.config_entry.options.get( CONF_ALLOW_NEW_DEVICES, DEFAULT_ALLOW_NEW_DEVICES ) # Callbacks @callback def async_connection_status_callback(self, available: bool) -> None: """Handle signals of gateway connection status.""" self.available = available self.ignore_state_updates = False async_dispatcher_send(self.hass, self.signal_reachable) @callback def async_add_device_callback( self, resource_type: str, device: DeconzAlarmSystem | DeconzGroup | DeconzLight | DeconzSensor | list[DeconzAlarmSystem | DeconzGroup | DeconzLight | DeconzSensor] | None = None, force: bool = False, ) -> None: """Handle event of new device creation in deCONZ.""" if ( not force and not self.option_allow_new_devices or resource_type not in self.deconz_resource_type_to_signal_new_device ): return args = [] if device is not None and not isinstance(device, list): args.append([device]) async_dispatcher_send( self.hass, self.deconz_resource_type_to_signal_new_device[resource_type], *args, # Don't send device if None, it would override default value in listeners ) async def async_update_device_registry(self) -> None: """Update device registry.""" device_registry = dr.async_get(self.hass) # Host device device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, connections={(CONNECTION_NETWORK_MAC, self.api.config.mac)}, ) # Gateway service configuration_url = f"http://{self.host}:{self.config_entry.data[CONF_PORT]}" if self.config_entry.source == SOURCE_HASSIO: configuration_url = HASSIO_CONFIGURATION_URL device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, configuration_url=configuration_url, entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)}, manufacturer="Dresden Elektronik", model=self.api.config.model_id, name=self.api.config.name, sw_version=self.api.config.software_version, via_device=(CONNECTION_NETWORK_MAC, self.api.config.mac), ) @staticmethod async def async_config_entry_updated( hass: HomeAssistant, entry: ConfigEntry ) -> None: """Handle signals of config entry being updated. This is a static method because a class method (bound method), can not be used with weak references. Causes for this is either discovery updating host address or config entry options changing. """ gateway = get_gateway_from_config_entry(hass, entry) if gateway.api.host != gateway.host: gateway.api.close() gateway.api.host = gateway.host gateway.api.start() return await gateway.options_updated() async def options_updated(self) -> None: """Manage entities affected by config entry options.""" deconz_ids = [] if self.option_allow_clip_sensor: self.async_add_device_callback(sensor.RESOURCE_TYPE) else: deconz_ids += [ sensor.deconz_id for sensor in self.api.sensors.values() if sensor.type.startswith("CLIP") ] if self.option_allow_deconz_groups: self.async_add_device_callback(group.RESOURCE_TYPE) else: deconz_ids += [group.deconz_id for group in self.api.groups.values()] entity_registry = er.async_get(self.hass) for entity_id, deconz_id in self.deconz_ids.items(): if deconz_id in deconz_ids and entity_registry.async_is_registered( entity_id ): # Removing an entity from the entity registry will also remove them # from Home Assistant entity_registry.async_remove(entity_id) @callback def shutdown(self, event: Event) -> None: """Wrap the call to deconz.close. Used as an argument to EventBus.async_listen_once. """ self.api.close() async def async_reset(self) -> bool: """Reset this gateway to default state.""" self.api.async_connection_status_callback = None self.api.close() await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS ) self.deconz_ids = {} return True @callback def get_gateway_from_config_entry( hass: HomeAssistant, config_entry: ConfigEntry ) -> DeconzGateway: """Return gateway with a matching config entry ID.""" return cast(DeconzGateway, hass.data[DECONZ_DOMAIN][config_entry.entry_id]) async def get_deconz_session( hass: HomeAssistant, config: MappingProxyType[str, Any], ) -> DeconzSession: """Create a gateway object and verify configuration.""" session = aiohttp_client.async_get_clientsession(hass) deconz_session = DeconzSession( session, config[CONF_HOST], config[CONF_PORT], config[CONF_API_KEY], ) try: async with async_timeout.timeout(10): await deconz_session.refresh_state() return deconz_session except errors.Unauthorized as err: LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) raise AuthenticationRequired from err except (asyncio.TimeoutError, errors.RequestError) as err: LOGGER.error("Error connecting to deCONZ gateway at %s", config[CONF_HOST]) raise CannotConnect from err