Files
Victor Alexandrovich Tsyrenschikov 373ed28445 python
2026-03-30 20:25:42 +05:00

521 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Yandex Smart Home user device."""
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING, Any
from homeassistant.components import (
air_quality,
automation,
binary_sensor,
button,
camera,
climate,
cover,
event,
fan,
group,
humidifier,
input_boolean,
input_button,
input_text,
light,
lock,
media_player,
remote,
scene,
script,
sensor,
switch,
vacuum,
valve,
water_heater,
)
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.cover import CoverDeviceClass
from homeassistant.components.event import EventDeviceClass
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
CONF_DEVICE_CLASS,
CONF_NAME,
CONF_ROOM,
CONF_STATE_TEMPLATE,
CONF_TYPE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Context, HomeAssistant, State, callback
from homeassistant.helpers.area_registry import AreaEntry
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.template import Template
from . import ( # noqa: F401
capability_color,
capability_custom,
capability_mode,
capability_onoff,
capability_range,
capability_toggle,
capability_video,
property_custom,
property_event,
property_float,
)
from .capability import STATE_CAPABILITIES_REGISTRY, Capability, DummyCapability, StateCapability
from .capability_custom import get_custom_capability
from .capability_toggle import BacklightCapability
from .const import (
CONF_BACKLIGHT_ENTITY_ID,
CONF_ENTITY_CUSTOM_MODES,
CONF_ENTITY_CUSTOM_RANGES,
CONF_ENTITY_CUSTOM_TOGGLES,
CONF_ENTITY_PROPERTIES,
CONF_ERROR_CODE_TEMPLATE,
)
from .helpers import ActionNotAllowed, APIError, _get_registry_entries
from .property import STATE_PROPERTIES_REGISTRY, Property, StateProperty
from .property_custom import get_custom_property, get_event_platform_custom_property_type
from .schema import (
CapabilityDescription,
CapabilityInstanceAction,
CapabilityInstanceActionResultValue,
CapabilityInstanceState,
CapabilityType,
DeviceDescription,
DeviceInfo,
DeviceState,
DeviceType,
OnOffCapabilityInstance,
PropertyDescription,
PropertyInstanceState,
ResponseCode,
)
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
_LOGGER = logging.getLogger(__name__)
_DOMAIN_TO_DEVICE_TYPES: dict[str, DeviceType] = {
air_quality.DOMAIN: DeviceType.SENSOR,
automation.DOMAIN: DeviceType.OTHER,
binary_sensor.DOMAIN: DeviceType.SENSOR,
button.DOMAIN: DeviceType.OTHER,
camera.DOMAIN: DeviceType.CAMERA,
climate.DOMAIN: DeviceType.THERMOSTAT,
cover.DOMAIN: DeviceType.OPENABLE,
event.DOMAIN: DeviceType.SENSOR,
fan.DOMAIN: DeviceType.VENTILATION_FAN,
group.DOMAIN: DeviceType.SWITCH,
humidifier.DOMAIN: DeviceType.HUMIDIFIER,
input_boolean.DOMAIN: DeviceType.SWITCH,
input_button.DOMAIN: DeviceType.OTHER,
input_text.DOMAIN: DeviceType.SENSOR,
light.DOMAIN: DeviceType.LIGHT,
lock.DOMAIN: DeviceType.OPENABLE,
media_player.DOMAIN: DeviceType.MEDIA_DEVICE,
remote.DOMAIN: DeviceType.SWITCH,
scene.DOMAIN: DeviceType.OTHER,
script.DOMAIN: DeviceType.OTHER,
sensor.DOMAIN: DeviceType.SENSOR,
switch.DOMAIN: DeviceType.SWITCH,
vacuum.DOMAIN: DeviceType.VACUUM_CLEANER,
valve.DOMAIN: DeviceType.OPENABLE_VALVE,
water_heater.DOMAIN: DeviceType.KETTLE,
}
_DEVICE_CLASS_TO_DEVICE_TYPES: dict[tuple[str, str], DeviceType] = {
(binary_sensor.DOMAIN, BinarySensorDeviceClass.DOOR): DeviceType.SENSOR_OPEN,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.GARAGE_DOOR): DeviceType.SENSOR_OPEN,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.GAS): DeviceType.SENSOR_GAS,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.MOISTURE): DeviceType.SENSOR_WATER_LEAK,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.MOTION): DeviceType.SENSOR_MOTION,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.MOVING): DeviceType.SENSOR_MOTION,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.OCCUPANCY): DeviceType.SENSOR_MOTION,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.OPENING): DeviceType.SENSOR_OPEN,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.PRESENCE): DeviceType.SENSOR_MOTION,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.SMOKE): DeviceType.SENSOR_SMOKE,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.VIBRATION): DeviceType.SENSOR_VIBRATION,
(binary_sensor.DOMAIN, BinarySensorDeviceClass.WINDOW): DeviceType.SENSOR_OPEN,
(cover.DOMAIN, CoverDeviceClass.CURTAIN): DeviceType.OPENABLE_CURTAIN,
(media_player.DOMAIN, MediaPlayerDeviceClass.RECEIVER): DeviceType.MEDIA_DEVICE_RECIEVER,
(media_player.DOMAIN, MediaPlayerDeviceClass.TV): DeviceType.MEDIA_DEVICE_TV,
(sensor.DOMAIN, EventDeviceClass.BUTTON): DeviceType.SENSOR_BUTTON,
(sensor.DOMAIN, SensorDeviceClass.CO): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.CO2): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.ENERGY): DeviceType.SMART_METER_ELECTRICITY,
(sensor.DOMAIN, SensorDeviceClass.GAS): DeviceType.SMART_METER_GAS,
(sensor.DOMAIN, SensorDeviceClass.HUMIDITY): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.ILLUMINANCE): DeviceType.SENSOR_ILLUMINATION,
(sensor.DOMAIN, SensorDeviceClass.PM1): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.PM10): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.PM25): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.PRESSURE): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.TEMPERATURE): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS): DeviceType.SENSOR_CLIMATE,
(sensor.DOMAIN, SensorDeviceClass.WATER): DeviceType.SMART_METER_COLD_WATER,
(switch.DOMAIN, SwitchDeviceClass.OUTLET): DeviceType.SOCKET,
(event.DOMAIN, EventDeviceClass.BUTTON): DeviceType.SENSOR_BUTTON,
(event.DOMAIN, EventDeviceClass.DOORBELL): DeviceType.SENSOR_BUTTON,
(event.DOMAIN, EventDeviceClass.MOTION): DeviceType.SENSOR_MOTION,
}
type DeviceId = str
class Device:
"""Represent user device."""
__slots__ = ("_hass", "_entry_data", "_state", "_config", "id")
id: str
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State | None):
"""Initialize a device for the state."""
self.id = device_id
self._hass = hass
self._entry_data = entry_data
self._state = state or State(entity_id=device_id, state=STATE_UNAVAILABLE)
self._config = self._entry_data.get_entity_config(self.id)
@callback
def get_capabilities(self) -> list[Capability[Any]]:
"""Return all capabilities of the device."""
capabilities: list[Capability[Any]] = []
disabled_capabilities: list[Capability[Any]] = []
def _append_capabilities(_capability: Capability[Any]) -> None:
if _capability.supported and _capability not in capabilities and _capability not in disabled_capabilities:
capabilities.append(_capability)
if (state_template := self._config.get(CONF_STATE_TEMPLATE)) is not None:
capabilities.append(
get_custom_capability(
self._hass,
self._entry_data,
{CONF_STATE_TEMPLATE: state_template},
CapabilityType.ON_OFF,
OnOffCapabilityInstance.ON,
self.id,
)
)
for capability_type, config_key in (
(CapabilityType.MODE, CONF_ENTITY_CUSTOM_MODES),
(CapabilityType.TOGGLE, CONF_ENTITY_CUSTOM_TOGGLES),
(CapabilityType.RANGE, CONF_ENTITY_CUSTOM_RANGES),
):
if config_key in self._config:
for instance in self._config[config_key]:
capability_config = self._config[config_key][instance]
match capability_config:
case False:
disabled_capabilities.append(
DummyCapability(self._hass, self._entry_data, capability_type, instance, self.id)
)
case dict():
custom_capability = get_custom_capability(
self._hass,
self._entry_data,
capability_config,
capability_type,
instance,
self.id,
)
_append_capabilities(custom_capability)
for CapabilityT in STATE_CAPABILITIES_REGISTRY:
state_capability = CapabilityT(self._hass, self._entry_data, self.id, self._state)
_append_capabilities(state_capability)
if backlight_entity_id := self._config.get(CONF_BACKLIGHT_ENTITY_ID):
backlight_state = self._hass.states.get(backlight_entity_id)
if backlight_state and backlight_entity_id != self.id:
backlight_device = Device(self._hass, self._entry_data, backlight_state.entity_id, backlight_state)
for capability in backlight_device.get_capabilities():
if capability.type != CapabilityType.ON_OFF:
_append_capabilities(capability)
backlight_capability = BacklightCapability(self._hass, self._entry_data, self.id, backlight_state)
_append_capabilities(backlight_capability)
return capabilities
@callback
def get_state_capabilities(self) -> list[StateCapability[Any]]:
"""Return capabilities of the device based on the state."""
return [c for c in self.get_capabilities() if isinstance(c, StateCapability)]
@callback
def get_properties(self) -> list[Property]:
"""Return all properties for the device."""
properties: list[Property] = []
for property_config in self._config.get(CONF_ENTITY_PROPERTIES, []):
try:
custom_property = get_custom_property(self._hass, self._entry_data, property_config, self.id)
except APIError as e:
_LOGGER.error(e)
continue
if custom_property and custom_property.supported and custom_property not in properties:
properties.append(custom_property)
continue
if event_platform_property_type := get_event_platform_custom_property_type(property_config):
event_platform_property = event_platform_property_type(
self._hass, self._entry_data, self.id, State(self.id, STATE_UNKNOWN)
)
if event_platform_property.supported and event_platform_property not in properties:
properties.append(event_platform_property)
for PropertyT in STATE_PROPERTIES_REGISTRY:
device_property = PropertyT(self._hass, self._entry_data, self.id, self._state)
if device_property.supported and device_property not in properties:
properties.append(device_property)
return properties
@callback
def get_state_properties(self) -> list[StateProperty]:
"""Return properties for the device based on the state."""
return [p for p in self.get_properties() if isinstance(p, StateProperty)]
@property
def should_expose(self) -> bool:
"""Test if the device should be exposed."""
return self._entry_data.should_expose(self.id)
@property
@callback
def unavailable(self) -> bool:
"""Test if the device is unavailable."""
state_template: Template | None
if (state_template := self._config.get(CONF_STATE_TEMPLATE)) is not None:
return bool(state_template.async_render() == STATE_UNAVAILABLE)
return self._state.state == STATE_UNAVAILABLE
@property
def type(self) -> DeviceType:
"""Return device type."""
if user_type := self._config.get(CONF_TYPE):
return DeviceType(user_type)
domain = self._state.domain
device_class: str = self._config.get(CONF_DEVICE_CLASS, self._state.attributes.get(ATTR_DEVICE_CLASS, ""))
if device_class_type := _DEVICE_CLASS_TO_DEVICE_TYPES.get((domain, device_class)):
return device_class_type
if domain_type := _DOMAIN_TO_DEVICE_TYPES.get(domain):
return domain_type
return DeviceType.OTHER
async def describe(self) -> DeviceDescription | None:
"""Return description of the device."""
capabilities: list[CapabilityDescription] = []
for c in self.get_capabilities():
if c_description := c.get_description():
capabilities.append(c_description)
properties: list[PropertyDescription] = []
for p in self.get_properties():
if p_description := p.get_description():
properties.append(p_description)
if not capabilities and not properties:
return None
entity_entry, device_entry, area_entry = _get_registry_entries(self._hass, self.id)
device_info = DeviceInfo(model=self.id)
if device_entry is not None:
if device_entry.model:
device_model = f"{device_entry.model} | {self.id}"
else:
device_model = self.id
device_info = DeviceInfo(
manufacturer=device_entry.manufacturer,
model=device_model,
sw_version=device_entry.sw_version,
)
if (room := self._get_room(area_entry)) is not None:
room = room.strip()
assert self.type
return DeviceDescription(
id=self.id,
name=self._get_name(entity_entry).strip(),
room=room,
type=self.type,
capabilities=capabilities or None,
properties=properties or None,
device_info=device_info,
)
@callback
def query(self) -> DeviceState:
"""Return state of the device."""
check_availability = True
if self.unavailable:
return DeviceState(id=self.id, error_code=ResponseCode.DEVICE_UNREACHABLE)
capabilities: list[CapabilityInstanceState] = []
for c in self.get_capabilities():
if c.retrievable:
try:
if (capability_state := c.get_instance_state()) is not None:
capabilities.append(capability_state)
except APIError as e:
_LOGGER.error(e)
else:
check_availability = False
properties: list[PropertyInstanceState] = []
for p in self.get_properties():
if p.retrievable:
try:
if (property_state := p.get_instance_state()) is not None:
properties.append(property_state)
except APIError as e:
_LOGGER.error(e)
else:
check_availability = False
if check_availability and not capabilities and not properties:
return DeviceState(id=self.id, error_code=ResponseCode.DEVICE_UNREACHABLE)
return DeviceState(
id=self.id,
capabilities=capabilities or None,
properties=properties or None,
)
async def execute(
self, context: Context, action: CapabilityInstanceAction
) -> CapabilityInstanceActionResultValue | None:
"""Execute an action to change capability state."""
target_capability: Capability[Any] | None = None
for capability in self.get_capabilities():
if capability.type == action.type and capability.instance == action.state.instance:
target_capability = capability
break
if not target_capability:
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
f"Device {self.id} doesn't support instance {action.state.instance} of {action.type.short} capability",
)
if error_code_template := self._error_code_template:
if error_code := error_code_template.async_render(
capability=action.as_dict(), entity_id=self.id, parse_result=False
):
try:
code = ResponseCode(error_code)
except ValueError:
raise APIError(ResponseCode.INTERNAL_ERROR, f"Error code '{error_code}' is invalid for {self.id}")
raise ActionNotAllowed(code)
try:
return await target_capability.set_instance_state(context, action.state)
except (APIError, ActionNotAllowed):
raise
except Exception as e:
raise APIError(ResponseCode.INTERNAL_ERROR, f"Failed to execute action for {target_capability}: {e!r}")
def _get_name(self, entity_entry: RegistryEntry | None) -> str:
"""Return the device name."""
if name := self._config.get(CONF_NAME):
return str(name)
if entity_entry:
if alias := self._get_entry_alias(entity_entry.aliases):
return alias
return self._state.name or self.id
def _get_room(self, area: AreaEntry | None) -> str | None:
"""Return room of the device."""
if room := self._config.get(CONF_ROOM):
return str(room)
if area:
if alias := self._get_entry_alias(area.aliases):
return alias
return area.name
return None
def _get_entry_alias(self, aliases: set[str] | None) -> str | None:
"""Return best matched entry alias."""
filtered_aliases: set[str] = set()
for alias in aliases or []:
if "алиса:" in alias.lower():
filtered_aliases.add(alias.split(":", 1)[1].strip())
elif self._entry_data.use_entry_aliases and re.search(r"^[а-яё0-9 ]+$", alias, flags=re.IGNORECASE):
filtered_aliases.add(alias)
if not filtered_aliases:
return None
return sorted(filtered_aliases)[0]
@property
def _error_code_template(self) -> Template | None:
"""Prepare template for error code."""
return self._config.get(CONF_ERROR_CODE_TEMPLATE)
async def async_get_devices(hass: HomeAssistant, entry_data: ConfigEntryData) -> list[Device]:
"""Return list of supported user devices."""
devices: list[Device] = []
for state in hass.states.async_all():
device = Device(hass, entry_data, state.entity_id, state)
if device.should_expose and not device.unavailable:
devices.append(device)
return devices
async def async_get_device_description(hass: HomeAssistant, device: Device) -> DeviceDescription | None:
"""Return description for a user device."""
if (description := await device.describe()) is not None:
return description
_LOGGER.debug(f"Missing capabilities and properties for {device.id}")
return None
async def async_get_device_states(
hass: HomeAssistant, entry_data: ConfigEntryData, device_ids: list[str]
) -> list[DeviceState]:
"""Return list of the states of user devices."""
states: list[DeviceState] = []
for device_id in device_ids:
state = hass.states.get(device_id)
device = Device(hass, entry_data, device_id, state)
if state and not device.should_expose:
entry_data.mark_entity_unexposed(state.entity_id)
states.append(device.query())
return states