from __future__ import annotations from typing import Any from homeassistant.const import ATTR_DEVICE_CLASS, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.area_registry import AreaEntry, AreaRegistry from homeassistant.helpers.device_registry import DeviceEntry, DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntry from . import capability as caps, const, prop from .capability import AbstractCapability from .capability_custom import CustomModeCapability, CustomRangeCapability, CustomToggleCapability from .const import ( CONF_ENTITY_PROPERTIES, CONF_ROOM, CONF_TYPE, DEVICE_CLASS_TO_YANDEX_TYPES, DOMAIN_TO_YANDEX_TYPES, ERR_DEVICE_UNREACHABLE, ERR_INTERNAL_ERROR, ERR_NOT_SUPPORTED_IN_CURRENT_MODE, ) from .error import SmartHomeError from .helpers import Config, RequestData from .prop import AbstractProperty from .prop_custom import CustomEntityProperty from .prop_event import EventProperty class YandexEntity: """Adaptation of Entity expressed in Yandex's terms.""" def __init__(self, hass: HomeAssistant, config: Config, state: State): """Initialize a Yandex Smart Home entity.""" self.hass = hass self.config = config self.state = state self._capabilities: list[AbstractCapability] | None = None self._properties: list[AbstractProperty] | None = None @property def entity_id(self) -> str: """Return entity ID.""" return self.state.entity_id @callback def capabilities(self) -> list[AbstractCapability]: """Return capabilities for entity.""" if self._capabilities is not None: return self._capabilities self._capabilities = [] state = self.state entity_config = self.config.get_entity_config(state.entity_id) for capability_class, config_key in ( (CustomModeCapability, const.CONF_ENTITY_CUSTOM_MODES), (CustomToggleCapability, const.CONF_ENTITY_CUSTOM_TOGGLES), (CustomRangeCapability, const.CONF_ENTITY_CUSTOM_RANGES)): if config_key in entity_config: for instance in entity_config[config_key]: capability = capability_class( self.hass, self.config, state, instance, entity_config[config_key][instance] ) if capability.supported(): self._capabilities.append(capability) for Capability in caps.CAPABILITIES: capability = Capability(self.hass, self.config, state) if capability.supported() and capability.instance not in [c.instance for c in self._capabilities]: self._capabilities.append(capability) return self._capabilities @callback def properties(self) -> list[AbstractProperty]: """Return properties for entity.""" if self._properties is not None: return self._properties self._properties = [] state = self.state entity_config = self.config.get_entity_config(state.entity_id) for property_config in entity_config.get(CONF_ENTITY_PROPERTIES, []): self._properties.append( CustomEntityProperty.get(self.hass, self.config, state, property_config) ) for Property in prop.PROPERTIES: entity_property = Property(self.hass, self.config, state) if entity_property.supported(): if entity_property.instance not in [p.instance for p in self._properties]: self._properties.append(entity_property) return self._properties @property def should_expose(self) -> bool: """If device should be exposed.""" if self.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False if not self.yandex_device_type: return False return self.config.should_expose(self.entity_id) @property def yandex_device_type(self) -> str | None: """Yandex type based on domain and device class.""" entity_config = self.config.get_entity_config(self.entity_id) if CONF_TYPE in entity_config: return entity_config[CONF_TYPE] device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) domain = self.state.domain return DEVICE_CLASS_TO_YANDEX_TYPES.get((domain, device_class), DOMAIN_TO_YANDEX_TYPES.get(domain)) async def devices_serialize(self, ent_reg: EntityRegistry, dev_reg: DeviceRegistry, area_reg: AreaRegistry) -> dict[str, Any] | None: """Serialize entity for a devices response. https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/get-devices-docpage/ """ if self.state.state == STATE_UNAVAILABLE: return None if not self.capabilities() and not self.properties(): return None entity_config = self.config.get_entity_config(self.entity_id) name = (entity_config.get(CONF_NAME) or self.state.name).strip() or self.entity_id entity_entry, device_entry = await self._get_entity_and_device(ent_reg, dev_reg) device = { 'id': self.entity_id, 'name': name, 'type': self.yandex_device_type, 'capabilities': [], 'properties': [], 'device_info': { 'model': self.entity_id, }, } room = entity_config.get(CONF_ROOM) if room: device['room'] = room else: area = await self._get_area(entity_entry, device_entry, area_reg) if area and area.name: device['room'] = area.name if device_entry: if device_entry.manufacturer: device['device_info']['manufacturer'] = device_entry.manufacturer if device_entry.model: device['device_info']['model'] = f'{device_entry.model} | {self.entity_id}' if device_entry.sw_version: device['device_info']['sw_version'] = str(device_entry.sw_version) for item in [c.description() for c in self.capabilities()]: if item not in device['capabilities']: device['capabilities'].append(item) for item in [p.description() for p in self.properties()]: if item not in device['properties']: device['properties'].append(item) return device @callback def query_serialize(self) -> dict[str, Any]: """Serialize entity for a query response. https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-devices-query-docpage/ """ if self.state.state == STATE_UNAVAILABLE: return { 'id': self.entity_id, 'error_code': ERR_DEVICE_UNREACHABLE } device = { 'id': self.entity_id, 'capabilities': [], 'properties': [], } for item in [c for c in self.capabilities() if c.retrievable]: state = item.get_state() if state is not None: device['capabilities'].append(state) for item in [p for p in self.properties() if p.retrievable]: state = item.get_state() if state is not None: device['properties'].append(state) return device async def execute(self, data: RequestData, capability_type: str, instance: str, state: dict[str, str | int | bool]) -> dict[str, Any] | None: """Execute action. https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-action-docpage/ """ target_capabilities = [c for c in self.capabilities() if c.type == capability_type and c.instance == instance] if not target_capabilities: raise SmartHomeError( ERR_NOT_SUPPORTED_IN_CURRENT_MODE, f'Capability not found for instance {instance} ({capability_type}) of {self.state.entity_id}' ) for capability in target_capabilities: try: return await capability.set_state(data, state) except SmartHomeError: raise except Exception as e: raise SmartHomeError( ERR_INTERNAL_ERROR, f'Failed to execute action for instance {instance} ({capability_type}) of {self.state.entity_id}: ' f'{e!r}' ) async def _get_entity_and_device(self, ent_reg: EntityRegistry, dev_reg: DeviceRegistry) -> \ tuple[RegistryEntry, DeviceEntry] | tuple[None, None]: """Fetch the entity and device entries.""" entity_entry = ent_reg.async_get(self.entity_id) if not entity_entry: return None, None device_entry = dev_reg.devices.get(entity_entry.device_id) return entity_entry, device_entry @staticmethod async def _get_area(entity_entry: RegistryEntry | None, device_entry: DeviceEntry | None, area_reg: AreaRegistry) -> AreaEntry | None: """Calculate the area for an entity.""" if entity_entry and entity_entry.area_id: area_id = entity_entry.area_id elif device_entry and device_entry.area_id: area_id = device_entry.area_id else: return None return area_reg.areas.get(area_id) class YandexEntityCallbackState: def __init__(self, entity: YandexEntity, event_entity_id: str, initial_report: bool = False): self.device_id: str = entity.entity_id self.old_state: YandexEntityCallbackState | None = None self.capabilities: list[dict] = [] self.properties: list[dict] = [] self.should_report_immediately = False if entity.state.state == STATE_UNAVAILABLE: return for item in [c for c in entity.capabilities() if c.reportable]: state = item.get_state() if state is not None: self.capabilities.append(state) for item in [c for c in entity.properties() if c.reportable]: if isinstance(item, CustomEntityProperty): if item.property_entity_id != event_entity_id: continue elif item.state.entity_id != event_entity_id: continue if initial_report: if isinstance(item, EventProperty): continue if item.report_immediately: self.should_report_immediately = True state = item.get_state() if state is not None: self.properties.append(state) @property def should_report(self) -> bool: if not self.capabilities and not self.properties: return False if self.old_state: if self.properties == self.old_state.properties and self.capabilities == self.old_state.capabilities: return False return True