Initialize docker stack repo

This commit is contained in:
g4st3r
2026-03-07 17:28:59 +00:00
commit 319d4eeb3e
458 changed files with 67215 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
"""The Yandex Smart Home component."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, cast
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_ID, CONF_PLATFORM, CONF_TOKEN, SERVICE_RELOAD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from .config_schema import YANDEX_SMART_HOME_SCHEMA
from .const import (
CONF_CLOUD_INSTANCE,
CONF_CONNECTION_TYPE,
CONF_DEVICES_DISCOVERED,
CONF_ENTITY_CONFIG,
CONF_FILTER,
CONF_FILTER_SOURCE,
CONF_LINKED_PLATFORMS,
CONF_NOTIFIER,
CONF_NOTIFIER_OAUTH_TOKEN,
CONF_NOTIFIER_SKILL_ID,
CONF_NOTIFIER_USER_ID,
CONF_SKILL,
CONF_USER_ID,
DOMAIN,
ConnectionType,
EntityFilterSource,
)
from .entry_data import ConfigEntryData
from .helpers import SmartHomePlatform
from .http import async_register_http
from .repairs import delete_unexposed_entity_found_issues
if TYPE_CHECKING:
from .cloud_stream import CloudStreamManager
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({DOMAIN: YANDEX_SMART_HOME_SCHEMA}, extra=vol.ALLOW_EXTRA)
class YandexSmartHome:
"""Yandex Smart Home component main class."""
def __init__(self, hass: HomeAssistant, yaml_config: ConfigType):
"""Initialize the Yandex Smart Home from yaml configuration."""
self.cloud_streams: dict[str, CloudStreamManager] = {}
self._hass = hass
self._yaml_config = yaml_config
self._entry_datas: dict[str, ConfigEntryData] = {}
async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, self._handle_yaml_config_reload)
async def _handle_yaml_config_reload(self, _: Any) -> None:
"""Handle yaml configuration reloading."""
if config := await async_integration_yaml_config(self._hass, DOMAIN):
self._yaml_config = config.get(DOMAIN, {})
for entry in self._hass.config_entries.async_entries(DOMAIN):
await _async_entry_update_listener(self._hass, entry)
return None
def get_entry_data(self, entry: ConfigEntry) -> ConfigEntryData:
"""Return a config entry data for a config entry."""
return self._entry_datas[entry.entry_id]
def get_direct_connection_entry_data(
self, platform: SmartHomePlatform, user_id: str | None
) -> ConfigEntryData | None:
"""Return a config entry data with direct connection config entry."""
for data in self._entry_datas.values():
if (
data.connection_type == ConnectionType.DIRECT
and data.entry.state == ConfigEntryState.LOADED
and data.platform == platform
):
if user_id and data.skill and data.skill.user_id == user_id:
return data
if not user_id:
return data
return None
def get_diagnostics(self) -> ConfigType:
"""Return diagnostics for the component."""
from homeassistant.components.diagnostics import async_redact_data
return {"yaml_config": async_redact_data(self._yaml_config, [CONF_NOTIFIER])}
def get_entity_filter_from_yaml(self) -> EntityFilter | None:
"""Return entity filter from yaml configuration."""
if entity_filter_config := self._yaml_config.get(CONF_FILTER):
return cast(EntityFilter, FILTER_SCHEMA(entity_filter_config))
return None
async def async_setup_entry(self, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
entity_config = self._yaml_config.get(CONF_ENTITY_CONFIG)
entity_filter: EntityFilter | None = None
if entry.options.get(CONF_FILTER_SOURCE) == EntityFilterSource.YAML:
entity_filter = self.get_entity_filter_from_yaml()
else:
entity_filter = FILTER_SCHEMA(entry.options.get(CONF_FILTER, {}))
data = ConfigEntryData(
hass=self._hass,
entry=entry,
yaml_config=self._yaml_config,
entity_config=entity_config,
entity_filter=entity_filter,
)
self._entry_datas[entry.entry_id] = await data.async_setup()
entry.async_on_unload(entry.add_update_listener(_async_entry_update_listener))
delete_unexposed_entity_found_issues(self._hass)
return True
async def async_unload_entry(self, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
delete_unexposed_entity_found_issues(self._hass)
data = self.get_entry_data(entry)
await data.async_unload()
return True
async def async_remove_entry(self, entry: ConfigEntry) -> None:
"""Remove a config entry."""
try:
del self._entry_datas[entry.entry_id]
except KeyError:
pass
return None
async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
"""Activate Yandex Smart Home component."""
hass.data[DOMAIN] = component = YandexSmartHome(hass, yaml_config.get(DOMAIN, {}))
async_register_http(hass, component)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
component: YandexSmartHome = hass.data[DOMAIN]
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: YandexSmartHome = hass.data[DOMAIN]
return await component.async_unload_entry(entry)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate the config entry upon new versions."""
version = entry.version
component: YandexSmartHome = hass.data[DOMAIN]
data: ConfigType = {**entry.data}
options: ConfigType = {**entry.options}
_LOGGER.debug(f"Migrating from version {version}")
if version == 1:
preserve_keys = [
CONF_CONNECTION_TYPE,
CONF_CLOUD_INSTANCE,
CONF_DEVICES_DISCOVERED,
CONF_FILTER,
CONF_USER_ID,
]
for store in [data, options]:
for key in list(store.keys()):
if key not in preserve_keys:
store.pop(key, None)
data.setdefault(CONF_CONNECTION_TYPE, ConnectionType.DIRECT)
data.setdefault(CONF_DEVICES_DISCOVERED, True)
version = 2
hass.config_entries.async_update_entry(entry, data=data, options=options, version=version)
_LOGGER.debug(f"Migration to version {version} successful")
if version == 2:
version = 3
_LOGGER.debug(f"Migration to version {version} successful")
if version == 3:
options[CONF_FILTER_SOURCE] = EntityFilterSource.CONFIG_ENTRY
if CONF_FILTER in component._yaml_config:
options[CONF_FILTER_SOURCE] = EntityFilterSource.YAML
version = 4
hass.config_entries.async_update_entry(entry, data=data, options=options, version=version)
_LOGGER.debug(f"Migration to version {version} successful")
if version == 4:
from .config_flow import DEFAULT_CONFIG_ENTRY_TITLE, PRE_V1_DIRECT_CONFIG_ENTRY_TITLE, async_config_entry_title
title = entry.title
data.setdefault(CONF_PLATFORM, SmartHomePlatform.YANDEX)
if len(hass.config_entries.async_entries(DOMAIN)) == 1 and data[CONF_CONNECTION_TYPE] == ConnectionType.DIRECT:
for notifier_config in component._yaml_config.get(CONF_NOTIFIER, []):
options.setdefault(
CONF_SKILL,
{
CONF_USER_ID: notifier_config[CONF_NOTIFIER_USER_ID],
CONF_ID: notifier_config[CONF_NOTIFIER_SKILL_ID],
CONF_TOKEN: notifier_config[CONF_NOTIFIER_OAUTH_TOKEN],
},
)
break
if entry.title in (DEFAULT_CONFIG_ENTRY_TITLE, PRE_V1_DIRECT_CONFIG_ENTRY_TITLE):
title = await async_config_entry_title(hass, data, options)
version = 5
hass.config_entries.async_update_entry(
entry,
title=title,
data=data,
options=options,
version=version,
)
_LOGGER.debug(f"Migration to version {version} successful")
if version == 5:
if data.get(CONF_DEVICES_DISCOVERED):
data[CONF_LINKED_PLATFORMS] = [SmartHomePlatform.YANDEX]
version = 6
hass.config_entries.async_update_entry(entry, data=data, version=version)
_LOGGER.debug(f"Migration to version {version} successful")
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
component: YandexSmartHome | None = hass.data.get(DOMAIN)
if component:
await component.async_remove_entry(entry)
return None
async def _async_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle config entry options update."""
await hass.config_entries.async_reload(entry.entry_id)
return None

View File

@@ -0,0 +1,26 @@
"""Backports from newer Home Assistant versions."""
from enum import StrEnum
class LockState(StrEnum):
"""State of lock entities (2024.10+)."""
JAMMED = "jammed"
OPENING = "opening"
LOCKING = "locking"
OPEN = "open"
UNLOCKING = "unlocking"
LOCKED = "locked"
UNLOCKED = "unlocked"
class VacuumActivity(StrEnum):
"""Vacuum activity states (2025.1+)."""
CLEANING = "cleaning"
DOCKED = "docked"
IDLE = "idle"
PAUSED = "paused"
RETURNING = "returning"
ERROR = "error"

View File

@@ -0,0 +1,233 @@
"""Implement the Yandex Smart Home base device capability."""
from __future__ import annotations
from abc import abstractmethod
from functools import cached_property
from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
from homeassistant.const import ATTR_SUPPORTED_FEATURES
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.helpers.typing import ConfigType
from .const import CONF_SLOW
from .helpers import CacheStore, ListRegistry
from .schema import (
CapabilityDescription,
CapabilityInstance,
CapabilityInstanceActionResultValue,
CapabilityInstanceActionState,
CapabilityInstanceState,
CapabilityInstanceStateValue,
CapabilityParameters,
CapabilityType,
)
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
@runtime_checkable
class Capability(Protocol[CapabilityInstanceActionState]):
"""Base class for a device capability."""
device_id: str
type: CapabilityType
instance: CapabilityInstance
_hass: HomeAssistant
_entry_data: ConfigEntryData
@property
@abstractmethod
def supported(self) -> bool:
"""Test if the capability is supported."""
...
@property
def retrievable(self) -> bool:
"""Test if the capability can return the current value."""
return True
@property
def reportable(self) -> bool:
"""Test if the capability can report value changes."""
return self._entry_data.is_reporting_states
@property
def time_sensitive(self) -> bool:
"""Test if value changes should be reported immediately."""
return False
@property
@abstractmethod
def parameters(self) -> CapabilityParameters | None:
"""Return parameters for a devices list request."""
...
def get_description(self) -> CapabilityDescription | None:
"""Return a description for a device list request. Capability with an empty description isn't discoverable."""
return CapabilityDescription(
type=self.type, retrievable=self.retrievable, reportable=self.reportable, parameters=self.parameters
)
@abstractmethod
def get_value(self) -> Any:
"""Return the current capability value."""
...
def get_instance_state(self) -> CapabilityInstanceState | None:
"""Return a state for a state query request."""
if (value := self.get_value()) is not None:
return CapabilityInstanceState(
type=self.type, state=CapabilityInstanceStateValue(instance=self.instance, value=value)
)
return None
@abstractmethod
async def set_instance_state(
self, context: Context, state: CapabilityInstanceActionState
) -> CapabilityInstanceActionResultValue:
"""Change the capability state."""
...
def check_value_change(self, other: Self | None) -> bool:
"""Test if the capability value differs from other capability."""
if other is None:
return True
value, other_value = self.get_value(), other.get_value()
if value is None:
return False
if other_value is None or value != other_value:
return True
return False
@cached_property
def _entity_config(self) -> ConfigType:
"""Return additional configuration for the device."""
return self._entry_data.get_entity_config(self.device_id)
@property
def _wait_for_service_call(self) -> bool:
"""Check if service should be run in blocking mode."""
if self._entity_config.get(CONF_SLOW) is True:
return False
return True
def __str__(self) -> str:
"""Return string representation."""
return f"instance {self.instance} of {self.type.short} capability of {self.device_id}"
def __repr__(self) -> str:
"""Return the representation."""
return (
f"<{self.__class__.__name__}"
f" device_id={self.device_id }"
f" type={self.type}"
f" instance={self.instance}"
f">"
)
def __eq__(self, other: Any) -> bool:
"""Compare capabilities."""
return bool(
isinstance(other, Capability)
and self.type == other.type
and self.instance == other.instance
and self.device_id == other.device_id
)
class ActionOnlyCapabilityMixin:
"""Represents a capability that can only execute an action."""
@property
def retrievable(self) -> bool:
"""Test if the capability can return the current value."""
return False
@property
def reportable(self) -> bool:
"""Test if the capability can report value changes."""
return False
def get_value(self) -> None:
"""Return the current capability value."""
return None
class DummyCapability(Capability[CapabilityInstanceActionState]):
"""Represents a capability that user has disabled."""
def __init__(
self,
hass: HomeAssistant,
entry_data: ConfigEntryData,
type: CapabilityType,
instance: CapabilityInstance,
device_id: str,
):
"""Initialize a dummy capability."""
self._hass = hass
self._entry_data = entry_data
self.type = type
self.instance = instance
self.device_id = device_id
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
raise NotImplementedError
@property
def parameters(self) -> CapabilityParameters | None:
"""Return parameters for a devices list request."""
raise NotImplementedError
def get_value(self) -> Any:
"""Return the current capability value."""
raise NotImplementedError
async def set_instance_state(
self, context: Context, state: CapabilityInstanceActionState
) -> CapabilityInstanceActionResultValue:
"""Change the capability state."""
raise NotImplementedError
@runtime_checkable
class StateCapability(Capability[CapabilityInstanceActionState], Protocol):
"""Base class for a device capability based on the state."""
state: State
device_id: str
_hass: HomeAssistant
_entry_data: ConfigEntryData
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State):
"""Initialize a capability for the state."""
self._hass = hass
self._entry_data = entry_data
self.device_id = device_id
self.state = state
@property
def _state_features(self) -> int:
"""Return supported features for the state."""
return int(self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0))
@property
def _cache(self) -> CacheStore:
"""Return cache storage."""
return self._entry_data.cache
STATE_CAPABILITIES_REGISTRY = ListRegistry[type[StateCapability[Any]]]()

View File

@@ -0,0 +1,446 @@
"""Implement the Yandex Smart Home color_setting capability."""
from __future__ import annotations
from abc import abstractmethod
from functools import cached_property
from typing import TYPE_CHECKING, Any, cast
from homeassistant.components import light
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_EFFECT_LIST,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
ATTR_WHITE,
ColorMode,
LightEntityFeature,
color_temp_supported,
)
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.util.color import RGBColor
from .capability import STATE_CAPABILITIES_REGISTRY, Capability, StateCapability
from .color import SOLID_LIGHT_EFFECT, ColorConverter, ColorTemperatureConverter, LightState
from .const import CONF_COLOR_PROFILE, CONF_ENTITY_CUSTOM_MODES, CONF_ENTITY_MODE_MAP
from .helpers import APIError
from .schema import (
CapabilityInstance,
CapabilityParameterColorModel,
CapabilityParameterColorScene,
CapabilityParameterTemperatureK,
CapabilityType,
ColorScene,
ColorSettingCapabilityInstance,
ColorSettingCapabilityInstanceActionState,
ColorSettingCapabilityParameters,
ResponseCode,
RGBInstanceActionState,
SceneInstanceActionState,
TemperatureKInstanceActionState,
)
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
class ColorSettingCapability(StateCapability[ColorSettingCapabilityInstanceActionState]):
"""Capability to discover another color_setting capabilities.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/color_setting.html
"""
type: CapabilityType = CapabilityType.COLOR_SETTING
instance: CapabilityInstance = ColorSettingCapabilityInstance.BASE
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State):
"""Initialize a capability for the state."""
super().__init__(hass, entry_data, device_id, state)
self._color = RGBColorCapability(hass, entry_data, device_id, state)
self._temperature = ColorTemperatureCapability(hass, entry_data, device_id, state)
self._scene = self._get_scene_capability()
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
for capability in self._capabilities:
if capability.supported:
return True
return False
@property
def parameters(self) -> ColorSettingCapabilityParameters:
"""Return parameters for a devices list request."""
return ColorSettingCapabilityParameters(
color_model=self._color.parameters.color_model if self._color.supported else None,
temperature_k=self._temperature.parameters.temperature_k if self._temperature.supported else None,
color_scene=self._scene.parameters.color_scene if self._scene.supported else None,
)
def get_value(self) -> None:
"""Return the current capability value."""
return None
async def set_instance_state(self, context: Context, state: ColorSettingCapabilityInstanceActionState) -> None:
"""Change the capability state."""
raise APIError(ResponseCode.INTERNAL_ERROR, "No instance")
def _get_scene_capability(self) -> ColorSceneCapability:
"""Return scene capability."""
scene_instance = ColorSettingCapabilityInstance.SCENE
if custom_scene_config := self._entity_config.get(CONF_ENTITY_CUSTOM_MODES, {}).get(scene_instance):
from .capability_custom import get_custom_capability
custom_capability = get_custom_capability(
self._hass, self._entry_data, custom_scene_config, CapabilityType.MODE, scene_instance, self.device_id
)
return cast(ColorSceneCapability, custom_capability)
return ColorSceneStateCapability(self._hass, self._entry_data, self.device_id, self.state)
@property
def _capabilities(self) -> list[Capability[Any]]:
"""Return all child capabilities."""
return [self._color, self._temperature, self._scene]
class RGBColorCapability(StateCapability[RGBInstanceActionState], LightState):
"""Capability to control color of a light device."""
type = CapabilityType.COLOR_SETTING
instance = ColorSettingCapabilityInstance.RGB
@property
def supported(self) -> bool:
"""Test if capability is supported."""
return self.state.domain == light.DOMAIN and bool(
{
ColorMode.RGB,
ColorMode.RGBW,
ColorMode.RGBWW,
ColorMode.HS,
ColorMode.XY,
}
& self._supported_color_modes
)
@property
def parameters(self) -> ColorSettingCapabilityParameters:
"""Return parameters for a devices list request."""
return ColorSettingCapabilityParameters(color_model=CapabilityParameterColorModel.RGB)
def get_description(self) -> None:
"""Return a description for a device list request. Capability with an empty description isn't discoverable."""
return None
def get_value(self) -> int | None:
"""Return the current capability value."""
if self.state.attributes.get(ATTR_COLOR_MODE) == ColorMode.COLOR_TEMP:
return None
if self._rgb_color:
if self._rgb_color in (RGBColor(255, 255, 255), RGBColor(0, 0, 0)):
return None
return self._converter.get_yandex_color(self._rgb_color)
return None
async def set_instance_state(self, context: Context, state: RGBInstanceActionState) -> None:
"""Change the capability state."""
color = self._converter.get_ha_color(state.value)
service_data: dict[str, Any] = {ATTR_ENTITY_ID: self.state.entity_id}
if ColorMode.RGBWW in self._supported_color_modes:
service_data[ATTR_RGBWW_COLOR] = tuple(color) + (
self._white_brightness or 0,
self._warm_white_brightness or 0,
)
elif ColorMode.RGBW in self._supported_color_modes:
service_data[ATTR_RGBW_COLOR] = tuple(color) + (self._white_brightness or 0,)
else:
service_data[ATTR_RGB_COLOR] = tuple(color)
await self._hass.services.async_call(
light.DOMAIN, SERVICE_TURN_ON, service_data, blocking=self._wait_for_service_call, context=context
)
@cached_property
def _converter(self) -> ColorConverter:
"""Return the color converter."""
if color_profile_name := self._entity_config.get(CONF_COLOR_PROFILE):
try:
return ColorConverter(self._entry_data.color_profiles[color_profile_name])
except KeyError:
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
f"Color profile '{color_profile_name}' not found for {self}",
)
return ColorConverter()
class ColorTemperatureCapability(StateCapability[TemperatureKInstanceActionState], LightState):
"""Capability to control color temperature of a light device."""
type = CapabilityType.COLOR_SETTING
instance = ColorSettingCapabilityInstance.TEMPERATURE_K
_default_white_temperature = ColorTemperatureConverter.default_white_temperature
_cold_white_temperature = 6500
_color_modes_temp_to_white = {ColorMode.RGBW, ColorMode.RGB, ColorMode.HS, ColorMode.XY}
@property
def supported(self) -> bool:
"""Test if capability is supported."""
if self.state.domain == light.DOMAIN:
if color_temp_supported(self._supported_color_modes):
return True
if self._color_modes_temp_to_white & self._supported_color_modes:
return True
return False
@property
def parameters(self) -> ColorSettingCapabilityParameters:
"""Return parameters for a devices list request."""
if color_temp_supported(self._supported_color_modes):
min_temp, max_temp = self._converter.supported_range
return ColorSettingCapabilityParameters(
temperature_k=CapabilityParameterTemperatureK(min=min_temp, max=max_temp)
)
min_temp = self._default_white_temperature
max_temp = self._default_white_temperature
if {ColorMode.RGBW, ColorMode.WHITE} & self._supported_color_modes:
max_temp = self._cold_white_temperature
return ColorSettingCapabilityParameters(
temperature_k=CapabilityParameterTemperatureK(min=min_temp, max=max_temp)
)
def get_description(self) -> None:
"""Return a description for a device list request. Capability with an empty description isn't discoverable."""
return None
def get_value(self) -> int | None:
"""Return the current capability value."""
color_temperature = self.state.attributes.get(ATTR_COLOR_TEMP_KELVIN)
if color_temperature is not None:
return self._converter.get_yandex_color_temperature(int(color_temperature))
color_mode = self.state.attributes.get(ATTR_COLOR_MODE)
match color_mode:
case ColorMode.WHITE:
return self._default_white_temperature
case ColorMode.RGBW:
if self._rgb_color == RGBColor(0, 0, 0) and (self._white_brightness or 0) > 0:
return self._default_white_temperature
elif self._rgb_color == RGBColor(255, 255, 255):
return self._cold_white_temperature
case _:
if self._rgb_color == RGBColor(255, 255, 255):
if ColorMode.WHITE in self._supported_color_modes:
return self._cold_white_temperature
return self._default_white_temperature
return None
async def set_instance_state(self, context: Context, state: TemperatureKInstanceActionState) -> None:
"""Change the capability state."""
service_data: dict[str, Any] = {ATTR_ENTITY_ID: self.state.entity_id}
if color_temp_supported(self._supported_color_modes):
service_data[ATTR_COLOR_TEMP_KELVIN] = self._converter.get_ha_color_temperature(state.value)
elif ColorMode.WHITE in self._supported_color_modes and state.value == self._default_white_temperature:
service_data[ATTR_WHITE] = self.state.attributes.get(ATTR_BRIGHTNESS, 255)
elif ColorMode.RGBW in self._supported_color_modes:
if state.value == self._default_white_temperature:
service_data[ATTR_RGBW_COLOR] = (0, 0, 0, self.state.attributes.get(ATTR_BRIGHTNESS, 255))
if self._solid_effect_supported:
service_data[ATTR_EFFECT] = SOLID_LIGHT_EFFECT
else:
service_data[ATTR_RGBW_COLOR] = (255, 255, 255, 0)
else:
service_data[ATTR_RGB_COLOR] = (255, 255, 255)
await self._hass.services.async_call(
light.DOMAIN, SERVICE_TURN_ON, service_data, blocking=self._wait_for_service_call, context=context
)
@cached_property
def _converter(self) -> ColorTemperatureConverter:
"""Return the color temperature converter."""
if color_profile_name := self._entity_config.get(CONF_COLOR_PROFILE):
try:
return ColorTemperatureConverter(self._entry_data.color_profiles[color_profile_name], self.state)
except KeyError:
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
f"Color profile '{color_profile_name}' not found for {self}",
)
return ColorTemperatureConverter(None, self.state)
class ColorSceneCapability(Capability[SceneInstanceActionState]):
"""Base class for capability to control color scene."""
type: CapabilityType = CapabilityType.COLOR_SETTING
instance: CapabilityInstance = ColorSettingCapabilityInstance.SCENE
_scenes_map_default: dict[ColorScene, list[str]] = {}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return bool(self.supported_yandex_scenes)
@property
def parameters(self) -> ColorSettingCapabilityParameters:
"""Return parameters for a devices list request."""
return ColorSettingCapabilityParameters(
color_scene=CapabilityParameterColorScene.from_list(self.supported_yandex_scenes)
)
@property
def supported_yandex_scenes(self) -> list[ColorScene]:
"""Returns a list of supported Yandex scenes."""
scenes = set()
for ha_value in self.supported_ha_scenes:
if value := self.get_yandex_scene_by_ha_scene(ha_value):
scenes.add(value)
return sorted(list(scenes))
@property
@abstractmethod
def supported_ha_scenes(self) -> list[str]:
"""Returns a list of supported HA scenes."""
...
@cached_property
def scenes_map(self) -> dict[ColorScene, list[str]]:
"""Return scene mapping between Yandex and HA."""
scenes_map = self._scenes_map_default.copy()
if CONF_ENTITY_MODE_MAP in self._entity_config:
scenes_map.update(
{ColorScene(k): v for k, v in self._entity_config[CONF_ENTITY_MODE_MAP].get(self.instance, {}).items()}
)
return scenes_map
def get_yandex_scene_by_ha_scene(self, ha_scene: str) -> ColorScene | None:
"""Return Yandex scene for HA scene."""
for scene, names in self.scenes_map.items():
if ha_scene.lower() in [n.lower() for n in names]:
return scene
return None
def get_ha_scene_by_yandex_scene(self, yandex_scene: ColorScene) -> str:
"""Return HA scene for Yandex scene."""
ha_scenes = self.scenes_map.get(yandex_scene, [])
for ha_scene in ha_scenes:
for sc in self.supported_ha_scenes:
if sc.lower() == ha_scene.lower():
return sc
raise APIError(
ResponseCode.INVALID_VALUE,
f"Unsupported scene '{yandex_scene}' for {self}, see https://docs.yaha-cloud.ru/v1.0.x/config/modes/",
)
def get_description(self) -> None:
"""Return a description for a device list request. Capability with an empty description isn't discoverable."""
return None
@abstractmethod
def get_value(self) -> ColorScene | None:
"""Return the current capability value."""
...
@abstractmethod
async def set_instance_state(self, context: Context, state: SceneInstanceActionState) -> None:
"""Change the capability state."""
...
class ColorSceneStateCapability(ColorSceneCapability, StateCapability[SceneInstanceActionState]):
"""Capability to control effect of a light device."""
_scenes_map_default = {
ColorScene.ALARM: ["Тревога", "Alarm", "Shine", "Strobe Mega"],
ColorScene.ALICE: ["Алиса", "Alice", "Meeting"],
ColorScene.CANDLE: ["Свеча", "Огонь", "Candle", "Fire"],
ColorScene.DINNER: ["Ужин", "Dinner"],
ColorScene.FANTASY: ["Фантазия", "Fantasy", "Random", "Beautiful", "Sinelon Rainbow"],
ColorScene.GARLAND: ["Гирлянда", "Garland", "Dynamic"],
ColorScene.JUNGLE: ["Джунгли", "Jungle"],
ColorScene.MOVIE: ["Кино", "Movie"],
ColorScene.NEON: ["Неон", "Neon", "Breath"],
ColorScene.NIGHT: ["Ночь", "Night", "Aurora"],
ColorScene.OCEAN: ["Океан", "Ocean", "Pacifica"],
ColorScene.PARTY: ["Вечеринка", "Party", "Juggle"],
ColorScene.READING: ["Чтение", "Reading", "Read"],
ColorScene.REST: ["Отдых", "Rest", "Soft"],
ColorScene.ROMANCE: ["Романтика", "Romance", "Leasure", "Lake"],
ColorScene.SIREN: ["Сирена", "Siren", "Police", "Rainbow"],
ColorScene.SUNRISE: ["Рассвет", "Sunrise"],
ColorScene.SUNSET: ["Закат", "Sunset"],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == light.DOMAIN and self._state_features & LightEntityFeature.EFFECT:
return super().supported
return False
@property
def supported_ha_scenes(self) -> list[str]:
"""Returns a list of supported Yandex scenes."""
return list(map(str, self.state.attributes.get(ATTR_EFFECT_LIST, []) or []))
def get_value(self) -> ColorScene | None:
"""Return the current capability value."""
if (effect := self.state.attributes.get(ATTR_EFFECT)) is not None:
return self.get_yandex_scene_by_ha_scene(str(effect))
return None
async def set_instance_state(self, context: Context, state: SceneInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
light.DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: self.state.entity_id,
ATTR_EFFECT: self.get_ha_scene_by_yandex_scene(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
STATE_CAPABILITIES_REGISTRY.register(ColorSettingCapability)
STATE_CAPABILITIES_REGISTRY.register(RGBColorCapability)
STATE_CAPABILITIES_REGISTRY.register(ColorTemperatureCapability)
STATE_CAPABILITIES_REGISTRY.register(ColorSceneStateCapability)

View File

@@ -0,0 +1,447 @@
"""Implement the Yandex Smart Home user specific capabilities."""
from __future__ import annotations
from functools import cached_property
import itertools
import logging
from typing import TYPE_CHECKING, Any, Iterable, Protocol, Self, cast
from homeassistant.const import CONF_STATE_TEMPLATE, STATE_OFF, STATE_ON, STATE_UNKNOWN
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.service import async_call_from_config
from homeassistant.helpers.template import Template, forgiving_boolean
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .capability import Capability
from .capability_color import ColorSceneCapability
from .capability_mode import ModeCapability
from .capability_onoff import OnOffCapability, OnOffCapabilityInstanceActionState
from .capability_range import RangeCapability
from .capability_toggle import ToggleCapability
from .const import (
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE,
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID,
CONF_ENTITY_CUSTOM_MODE_SET_MODE,
CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE,
CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE,
CONF_ENTITY_CUSTOM_RANGE_SET_VALUE,
CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF,
CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON,
CONF_ENTITY_MODE_MAP,
CONF_ENTITY_RANGE,
CONF_ENTITY_RANGE_MAX,
CONF_ENTITY_RANGE_MIN,
CONF_ENTITY_RANGE_PRECISION,
CONF_STATE_UNKNOWN,
)
from .helpers import ActionNotAllowed, APIError
from .schema import (
CapabilityInstance,
CapabilityType,
ColorScene,
ColorSettingCapabilityInstance,
ModeCapabilityInstance,
ModeCapabilityInstanceActionState,
ModeCapabilityMode,
OnOffCapabilityInstance,
RangeCapabilityInstance,
RangeCapabilityInstanceActionState,
RangeCapabilityRange,
ResponseCode,
SceneInstanceActionState,
ToggleCapabilityInstance,
ToggleCapabilityInstanceActionState,
)
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
_LOGGER = logging.getLogger(__name__)
class CustomCapability(Capability[Any], Protocol):
"""Base class for a capability that user can set up using yaml configuration."""
device_id: str
instance: CapabilityInstance
_hass: HomeAssistant
_entry_data: ConfigEntryData
_config: ConfigType
_value_template: Template | None
_value: Any | UndefinedType
def __init__(
self,
hass: HomeAssistant,
entry_data: ConfigEntryData,
config: ConfigType,
instance: CapabilityInstance,
device_id: str,
value_template: Template | None,
value: Any | UndefinedType = UNDEFINED,
):
"""Initialize a custom capability."""
self._hass = hass
self._entry_data = entry_data
self._config = config
self._value_template = value_template
self._value = value
self.device_id = device_id
self.instance = instance
# noinspection PyProtocol
@property
def retrievable(self) -> bool:
"""Test if the capability can return the current value."""
return self._value_template is not None
@property
def reportable(self) -> bool:
"""Test if the capability can report value changes."""
if not self.retrievable:
return False
return super().reportable
def new_with_value(self, value: Any) -> Self:
"""Return copy of the state with new value."""
return self.__class__(
self._hass,
self._entry_data,
self._config,
self.instance,
self.device_id,
self._value_template,
value,
)
@callback
def _get_source_value(self) -> Any:
"""Return the current capability value (unprocessed)."""
if self._value_template is None:
return None
if self._value is not UNDEFINED:
return self._value
try:
return self._value_template.async_render()
except TemplateError as exc:
raise APIError(ResponseCode.INVALID_VALUE, f"Failed to get current value for {self}: {exc!r}")
def __repr__(self) -> str:
"""Return the representation."""
return (
f"<{self.__class__.__name__}"
f" device_id={self.device_id }"
f" instance={self.instance}"
f" value_template={self._value_template}"
f" value={self._value}"
f">"
)
class CustomOnOffCapability(CustomCapability, OnOffCapability):
"""OnOff capability that user can set up using yaml configuration."""
instance: OnOffCapabilityInstance
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return True
@property
def retrievable(self) -> bool:
"""Test if the capability can return the current value."""
if self._entity_config.get(CONF_STATE_UNKNOWN):
return False
return True
def get_value(self) -> bool | None:
"""Return the current capability value."""
if not self.retrievable:
return None
if self._value_template is not None:
return bool(self._get_source_value() == STATE_ON)
return False
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
raise ActionNotAllowed
class CustomModeCapability(CustomCapability, ModeCapability):
"""Mode capability that user can set up using yaml configuration."""
instance: ModeCapabilityInstance
def get_value(self) -> ModeCapabilityMode | None:
"""Return the current capability value."""
if not self.retrievable:
return None
if (value := self._get_source_value()) is not None:
return self.get_yandex_mode_by_ha_mode(str(value))
return None
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
service_config = self._config.get(CONF_ENTITY_CUSTOM_MODE_SET_MODE)
if not service_config:
raise ActionNotAllowed
await async_call_from_config(
self._hass,
service_config,
validate_config=False,
variables={"mode": self.get_ha_mode_by_yandex_mode(state.value)},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
modes = self._entity_config.get(CONF_ENTITY_MODE_MAP, {}).get(self.instance, {})
return itertools.chain(*modes.values())
class CustomToggleCapability(CustomCapability, ToggleCapability):
"""Toggle capability that user can set up using yaml configuration."""
instance: ToggleCapabilityInstance
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return True
def get_value(self) -> bool | None:
"""Return the current capability value."""
if not self.retrievable:
return None
value = self._get_source_value()
if value is None:
return None
return forgiving_boolean(value, None)
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if state.value:
service_config = self._config.get(CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON)
else:
service_config = self._config.get(CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF)
if not service_config:
raise ActionNotAllowed
await async_call_from_config(
self._hass,
service_config,
validate_config=False,
blocking=self._wait_for_service_call,
context=context,
)
class CustomRangeCapability(CustomCapability, RangeCapability):
"""Range capability that user can set up using yaml configuration."""
instance: RangeCapabilityInstance
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return True
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
for key in [CONF_ENTITY_RANGE_MIN, CONF_ENTITY_RANGE_MAX]:
if key not in self._config.get(CONF_ENTITY_RANGE, {}):
return False
return self._set_value_service_config is not None
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
service_config = self._set_value_service_config
value = state.value
if state.relative:
if self._increase_value_service_config or self._decrease_value_service_config:
if state.value > 0:
service_config = self._increase_value_service_config
else:
service_config = self._decrease_value_service_config
else:
if not self.retrievable:
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
f"Unable to set relative value for {self}: no current value source or service found",
)
value = self._get_absolute_value(state.value)
if not service_config:
raise ActionNotAllowed
await async_call_from_config(
self._hass,
service_config,
validate_config=False,
variables={"value": value},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
if not self.retrievable:
return None
return self._convert_to_float(self._get_source_value())
def _get_absolute_value(self, relative_value: float) -> float:
"""Return the absolute value for a relative value."""
value = self._get_value()
if value is None:
if self._value_template is not None:
info = self._value_template.async_render_to_info()
for entity_id in info.entities:
state = self._hass.states.get(entity_id)
if state is None:
raise APIError(ResponseCode.DEVICE_OFF, f"Entity {entity_id} not found")
elif state.state in (STATE_OFF, STATE_UNKNOWN):
raise APIError(ResponseCode.DEVICE_OFF, f"Device {entity_id} probably turned off")
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Missing current value for {self}")
return max(min(value + relative_value, self._range.max), self._range.min)
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(
min=self._config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MIN, super()._range.min),
max=self._config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MAX, super()._range.max),
precision=self._config.get(CONF_ENTITY_RANGE, {}).get(
CONF_ENTITY_RANGE_PRECISION, super()._range.precision
),
)
@property
def _set_value_service_config(self) -> ConfigType | None:
"""Return service configuration for setting value action."""
return self._config.get(CONF_ENTITY_CUSTOM_RANGE_SET_VALUE)
@property
def _increase_value_service_config(self) -> ConfigType | None:
"""Return service configuration for setting increase value action."""
return self._config.get(CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE)
@property
def _decrease_value_service_config(self) -> ConfigType | None:
"""Return service configuration for setting decrease value action."""
return self._config.get(CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE)
class CustomColorSceneCapability(CustomCapability, ColorSceneCapability):
"""Custom scene capability that user can set up using yaml configuration."""
@property
def supported_ha_scenes(self) -> list[str]:
"""Returns a list of supported HA scenes."""
modes = self._entity_config.get(CONF_ENTITY_MODE_MAP, {}).get(self.instance, {})
return list(itertools.chain(*modes.values()))
def get_value(self) -> ColorScene | None:
"""Return the current capability value."""
if not self.retrievable:
return None
if (value := self._get_source_value()) is not None:
return self.get_yandex_scene_by_ha_scene(str(value))
return None
async def set_instance_state(self, context: Context, state: SceneInstanceActionState) -> None:
"""Change the capability state."""
service_config = self._config.get(CONF_ENTITY_CUSTOM_MODE_SET_MODE)
if not service_config:
raise ActionNotAllowed
await async_call_from_config(
self._hass,
service_config,
validate_config=False,
variables={"mode": self.get_ha_scene_by_yandex_scene(state.value)},
blocking=self._wait_for_service_call,
context=context,
)
def get_custom_capability(
hass: HomeAssistant,
entry_data: ConfigEntryData,
capability_config: ConfigType,
capability_type: CapabilityType,
instance: str,
device_id: str,
) -> CustomCapability:
"""Return initialized custom capability based on parameters."""
value_template = get_value_template(hass, device_id, capability_config)
match capability_type:
case CapabilityType.ON_OFF:
return CustomOnOffCapability(
hass, entry_data, capability_config, OnOffCapabilityInstance(instance), device_id, value_template
)
case CapabilityType.MODE:
if instance == ColorSettingCapabilityInstance.SCENE:
return CustomColorSceneCapability(
hass, entry_data, capability_config, ColorSettingCapabilityInstance.SCENE, device_id, value_template
)
return CustomModeCapability(
hass, entry_data, capability_config, ModeCapabilityInstance(instance), device_id, value_template
)
case CapabilityType.TOGGLE:
return CustomToggleCapability(
hass, entry_data, capability_config, ToggleCapabilityInstance(instance), device_id, value_template
)
case CapabilityType.RANGE:
return CustomRangeCapability(
hass, entry_data, capability_config, RangeCapabilityInstance(instance), device_id, value_template
)
raise APIError(ResponseCode.INTERNAL_ERROR, f"Unsupported capability type: {capability_type}")
def get_value_template(hass: HomeAssistant, device_id: str, capability_config: ConfigType) -> Template | None:
"""Return capability value template from capability configuration."""
if template := capability_config.get(CONF_STATE_TEMPLATE):
return cast(Template, template)
entity_id = capability_config.get(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID)
attribute = capability_config.get(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE)
if attribute:
return Template("{{ state_attr('%s', '%s') }}" % (entity_id or device_id, attribute), hass)
elif entity_id:
return Template("{{ states('%s') }}" % entity_id, hass)
return None

View File

@@ -0,0 +1,971 @@
"""Implement the Yandex Smart Home mode capabilities."""
from abc import ABC, abstractmethod
from contextlib import suppress
from enum import StrEnum
import logging
import math
from typing import Any, Iterable, Protocol
from homeassistant.components import climate, fan, humidifier, media_player, vacuum
from homeassistant.components.climate import ClimateEntityFeature, HVACMode
from homeassistant.components.fan import FanEntityFeature
from homeassistant.components.humidifier import HumidifierEntityFeature
from homeassistant.components.media_player import MediaPlayerEntityFeature
from homeassistant.components.vacuum import VacuumEntityFeature
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import Context
from homeassistant.util.percentage import ordered_list_item_to_percentage, percentage_to_ordered_list_item
from .capability import STATE_CAPABILITIES_REGISTRY, Capability, StateCapability
from .const import CONF_ENTITY_MODE_MAP, CONF_FEATURES, STATE_NONE, MediaPlayerFeature
from .helpers import APIError
from .schema import (
CapabilityType,
ModeCapabilityInstance,
ModeCapabilityInstanceActionState,
ModeCapabilityMode,
ModeCapabilityParameters,
ResponseCode,
)
_LOGGER = logging.getLogger(__name__)
class GenericMode(StrEnum):
"""Generic HA mode for various devices."""
GENTLE = "gentle" # tuya vacuum?
MAX_PLUS_SIGN = "max+" # deebot?
HIGHEST = "highest" # smartir
class SmartThinQFanMode(StrEnum):
"""Fan mode for ollo69/ha-smartthinq-sensors integration."""
LOW_MID = "low_mid"
MID_HIGH = "mid_high"
class RoborockCleanupMode(StrEnum):
"""Cleanup mode for humbertogontijo/python-roborock library.
https://github.com/humbertogontijo/python-roborock/blob/1616217a06e20d51921de984134555bcc0775a92/roborock/code_mappings.py#L61
"""
OFF = "off"
SILENT = "silent"
BALANCED = "balanced"
TURBO = "turbo"
MAX = "max"
MAX_PLUS = "max_plus"
CUSTOM = "custom"
class RoombaCleanupMode(StrEnum):
"""Cleanup mode for roomba integration."""
AUTOMATIC = "Automatic"
ECO = "Eco"
PERFORMANCE = "Performance"
STANDARD = "Standard"
class TionFanSpeed(StrEnum):
"""Fan speed for airens/tion_home_assistant integration.
https://github.com/airens/tion_home_assistant#climateset_fan_mode
"""
S1 = "1"
S2 = "2"
S3 = "3"
S4 = "4"
S5 = "5"
S6 = "6"
class XiaomiHumidifierMode(StrEnum):
"""Humidifer mode for xiaomi_miio integration."""
MID = "mid"
class XiaomiMiotHumidifierMode(StrEnum):
"""Humidifer mode for al-one/hass-xiaomi-miot integration."""
CONST_HUMIDITY = "Const Humidity" # leshow.humidifier.jsq1
class XiaomiFanMode(StrEnum):
"""Fan mode for xiaomi_miio integration."""
AUTO = "Auto"
SILENT = "Silent"
LOW = "Low"
FAVORITE = "Favorite"
IDLE = "Idle"
MEDIUM = "Medium"
MIDDLE = "Middle"
HIGH = "High"
STRONG = "Strong"
FAN = "Fan"
NATURE = "Nature"
class XiaomiMiotFanMode(StrEnum):
"""Fan mode for al-one/hass-xiaomi-miot and syssi/xiaomi_airpurifier integrations.
https://github.com/syssi/xiaomi_airpurifier#service-fanset_preset_mode
https://github.com/al-one/hass-xiaomi-miot/blob/fdca601c409f619b1c98a20e6ea990317cce20c7/custom_components/xiaomi_miot/core/templates.py#L102
"""
LEVEL_1 = "Level 1"
LEVEL_2 = "Level 2"
LEVEL_3 = "Level 3"
LEVEL_4 = "Level 4"
LEVEL_5 = "Level 5"
class XiaomiMiotCleanupMode(StrEnum):
"""Cleanup mode for al-one/hass-xiaomi-miot integration.
https://github.com/al-one/hass-xiaomi-miot/blob/fdca601c409f619b1c98a20e6ea990317cce20c7/custom_components/xiaomi_miot/core/miot_specs_extend.json#L641
"""
SILENT = "Silent"
SLIENT = "slient" # https://github.com/al-one/hass-xiaomi-miot/issues/1605
BASIC = "Basic"
STRONG = "Strong"
FULL_SPEED = "Full Speed"
MOP_ONLY = "Mop Only"
CUSTOM = "Custom"
class ModeCapability(Capability[ModeCapabilityInstanceActionState], Protocol):
"""Base class for capabilities with mode functionality like thermostat mode or fan speed.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-docpage/
"""
type: CapabilityType = CapabilityType.MODE
instance: ModeCapabilityInstance
_modes_map_default: dict[ModeCapabilityMode, list[str]] = {}
_modes_map_index_fallback: dict[int, ModeCapabilityMode] = {
0: ModeCapabilityMode.ONE,
1: ModeCapabilityMode.TWO,
2: ModeCapabilityMode.THREE,
3: ModeCapabilityMode.FOUR,
4: ModeCapabilityMode.FIVE,
5: ModeCapabilityMode.SIX,
6: ModeCapabilityMode.SEVEN,
7: ModeCapabilityMode.EIGHT,
8: ModeCapabilityMode.NINE,
9: ModeCapabilityMode.TEN,
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return bool(self.supported_yandex_modes)
@property
def parameters(self) -> ModeCapabilityParameters:
"""Return parameters for a devices list request."""
return ModeCapabilityParameters.from_list(self.instance, self.supported_yandex_modes)
@property
def supported_yandex_modes(self) -> list[ModeCapabilityMode]:
"""Returns a list of supported Yandex modes."""
modes = set()
for ha_value in self.supported_ha_modes:
if value := self.get_yandex_mode_by_ha_mode(ha_value, hide_warnings=True):
modes.add(value)
return sorted(modes)
@property
def supported_ha_modes(self) -> list[str]:
"""Returns list of supported HA modes."""
return list(map(str, self._ha_modes))
@property
def modes_map(self) -> dict[ModeCapabilityMode, list[str]]:
"""Return a modes mapping between Yandex and HA."""
return self.modes_map_config or self._modes_map_default
@property
def modes_map_config(self) -> dict[ModeCapabilityMode, list[str]]:
"""Return a modes mapping from a entity configuration."""
if CONF_ENTITY_MODE_MAP in self._entity_config:
return {
ModeCapabilityMode(k): v
for k, v in self._entity_config[CONF_ENTITY_MODE_MAP].get(self.instance, {}).items()
}
return {}
def get_yandex_mode_by_ha_mode(self, ha_mode: str, hide_warnings: bool = False) -> ModeCapabilityMode | None:
"""Return Yandex mode for HA mode."""
mode = None
for yandex_mode, names in self.modes_map.items():
if ha_mode.lower() in [n.lower() for n in names]:
mode = yandex_mode
break
if mode is not None and ha_mode not in self.supported_ha_modes:
raise APIError(
ResponseCode.INVALID_VALUE,
f"Unsupported HA mode '{ha_mode}' for {self}: not in {self.supported_ha_modes}",
)
if not self.modes_map_config:
if mode is None:
with suppress(ValueError):
mode = ModeCapabilityMode(ha_mode.lower())
if mode is None and ha_mode.lower() != STATE_OFF:
try:
mode = self._modes_map_index_fallback[self.supported_ha_modes.index(ha_mode)]
except (IndexError, ValueError, KeyError):
pass
if mode is None and not hide_warnings:
if ha_mode.lower() not in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE):
if ha_mode.lower() in [m.lower() for m in self.supported_ha_modes]:
_LOGGER.warning(
f"Failed to get Yandex mode for mode '{ha_mode}' for {self}. "
f"It may cause inconsistencies between Yandex and HA. "
f"See https://docs.yaha-cloud.ru/v1.0.x/config/modes/"
)
return mode
def get_ha_mode_by_yandex_mode(self, yandex_mode: ModeCapabilityMode) -> str:
"""Return HA mode for Yandex mode."""
ha_modes = self.modes_map.get(yandex_mode, [])
if not self.modes_map_config:
ha_modes.append(yandex_mode.value)
for ha_mode in ha_modes:
for am in self.supported_ha_modes:
if am.lower() == ha_mode.lower():
return am
if not self.modes_map_config:
for ha_idx, yandex_mode_idx in self._modes_map_index_fallback.items():
if yandex_mode_idx == yandex_mode:
return self.supported_ha_modes[ha_idx]
raise APIError(
ResponseCode.INVALID_VALUE,
f"Unsupported mode '{yandex_mode}' for {self}, see https://docs.yaha-cloud.ru/v1.0.x/config/modes/",
)
@abstractmethod
def get_value(self) -> ModeCapabilityMode | None:
"""Return the current capability value."""
...
@property
@abstractmethod
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
...
class StateModeCapability(ModeCapability, StateCapability[ModeCapabilityInstanceActionState], Protocol):
"""Base class for a mode capability based on the state."""
def get_value(self) -> ModeCapabilityMode | None:
"""Return the current capability value."""
if self._ha_value is None:
return None
return self.get_yandex_mode_by_ha_mode(str(self._ha_value), False)
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.state
class ThermostatCapability(StateModeCapability):
"""Capability to control mode of a climate device."""
instance = ModeCapabilityInstance.THERMOSTAT
_modes_map_default = {
ModeCapabilityMode.HEAT: [HVACMode.HEAT],
ModeCapabilityMode.COOL: [HVACMode.COOL],
ModeCapabilityMode.AUTO: [HVACMode.HEAT_COOL, HVACMode.AUTO],
ModeCapabilityMode.DRY: [HVACMode.DRY],
ModeCapabilityMode.FAN_ONLY: [HVACMode.FAN_ONLY],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == climate.DOMAIN:
return super().supported
return False
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
climate.DOMAIN,
climate.SERVICE_SET_HVAC_MODE,
{
ATTR_ENTITY_ID: self.state.entity_id,
climate.ATTR_HVAC_MODE: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
return self.state.attributes.get(climate.ATTR_HVAC_MODES, []) or []
class SwingCapability(StateModeCapability):
"""Capability to control swing mode of a climate device."""
instance = ModeCapabilityInstance.SWING
_modes_map_default = {
ModeCapabilityMode.VERTICAL: ["ud"],
ModeCapabilityMode.HORIZONTAL: ["lr"],
ModeCapabilityMode.STATIONARY: [climate.SWING_OFF],
ModeCapabilityMode.AUTO: [climate.SWING_BOTH, "all"],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == climate.DOMAIN and self._state_features & ClimateEntityFeature.SWING_MODE:
return super().supported
return False
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
climate.DOMAIN,
climate.SERVICE_SET_SWING_MODE,
{
ATTR_ENTITY_ID: self.state.entity_id,
climate.ATTR_SWING_MODE: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
return self.state.attributes.get(climate.ATTR_SWING_MODES, []) or []
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(climate.ATTR_SWING_MODE)
class ProgramCapability(StateModeCapability, ABC):
"""Base capability to control a device program."""
instance = ModeCapabilityInstance.PROGRAM
class ProgramCapabilityClimate(ProgramCapability):
"""Capability to control the mode preset of a climate device."""
_modes_map_default = {
ModeCapabilityMode.AUTO: [
climate.const.PRESET_NONE,
],
ModeCapabilityMode.ECO: [
climate.const.PRESET_ECO,
],
ModeCapabilityMode.MIN: [
climate.const.PRESET_AWAY,
],
ModeCapabilityMode.TURBO: [
climate.const.PRESET_BOOST,
],
ModeCapabilityMode.MEDIUM: [
climate.const.PRESET_COMFORT,
],
ModeCapabilityMode.MAX: [
climate.const.PRESET_HOME,
],
ModeCapabilityMode.QUIET: [
climate.const.PRESET_SLEEP,
],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == climate.DOMAIN and self._state_features & ClimateEntityFeature.PRESET_MODE:
return super().supported
return False
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
climate.DOMAIN,
climate.SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: self.state.entity_id,
climate.ATTR_PRESET_MODE: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
return self.state.attributes.get(climate.ATTR_PRESET_MODES, []) or []
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(climate.ATTR_PRESET_MODE)
class ProgramCapabilityHumidifier(ProgramCapability):
"""Capability to control the mode of a humidifier device."""
_modes_map_default = {
ModeCapabilityMode.FAN_ONLY: [
XiaomiFanMode.FAN,
],
ModeCapabilityMode.AUTO: [
humidifier.const.MODE_AUTO,
XiaomiMiotHumidifierMode.CONST_HUMIDITY,
],
ModeCapabilityMode.ECO: [
humidifier.const.MODE_ECO,
XiaomiFanMode.IDLE,
],
ModeCapabilityMode.QUIET: [
humidifier.const.MODE_SLEEP,
XiaomiFanMode.SILENT,
],
ModeCapabilityMode.MIN: [
humidifier.const.MODE_AWAY,
],
ModeCapabilityMode.MEDIUM: [
humidifier.const.MODE_COMFORT,
XiaomiFanMode.MIDDLE,
XiaomiHumidifierMode.MID,
],
ModeCapabilityMode.NORMAL: [
humidifier.const.MODE_NORMAL,
XiaomiFanMode.FAVORITE,
],
ModeCapabilityMode.MAX: [
humidifier.const.MODE_HOME,
],
ModeCapabilityMode.HIGH: [
humidifier.const.MODE_BABY,
],
ModeCapabilityMode.TURBO: [
humidifier.const.MODE_BOOST,
XiaomiFanMode.STRONG,
],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == humidifier.DOMAIN and self._state_features & HumidifierEntityFeature.MODES:
return super().supported
return False
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
humidifier.DOMAIN,
humidifier.SERVICE_SET_MODE,
{
ATTR_ENTITY_ID: self.state.entity_id,
humidifier.ATTR_MODE: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
return self.state.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []) or []
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(humidifier.ATTR_MODE)
class ProgramCapabilityFan(ProgramCapability):
"""Capability to control the mode preset of a fan device."""
_modes_map_default = {
ModeCapabilityMode.ECO: [
XiaomiFanMode.IDLE,
],
ModeCapabilityMode.QUIET: [
XiaomiFanMode.SILENT,
XiaomiFanMode.NATURE,
XiaomiMiotFanMode.LEVEL_1,
],
ModeCapabilityMode.LOW: [
XiaomiMiotFanMode.LEVEL_2,
],
ModeCapabilityMode.MEDIUM: [
XiaomiHumidifierMode.MID,
XiaomiMiotFanMode.LEVEL_3,
],
ModeCapabilityMode.NORMAL: [
XiaomiFanMode.FAVORITE,
],
ModeCapabilityMode.HIGH: [
XiaomiMiotFanMode.LEVEL_4,
],
ModeCapabilityMode.TURBO: [
XiaomiFanMode.STRONG,
XiaomiMiotFanMode.LEVEL_5,
],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == fan.DOMAIN:
if self._state_features & FanEntityFeature.PRESET_MODE:
if (
self._state_features & FanEntityFeature.SET_SPEED
and fan.ATTR_PERCENTAGE_STEP in self.state.attributes
):
return super().supported
return False
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: self.state.entity_id,
fan.ATTR_PRESET_MODE: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
return self.state.attributes.get(fan.ATTR_PRESET_MODES, []) or []
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(fan.ATTR_PRESET_MODE)
class InputSourceCapability(StateModeCapability):
"""Capability to control the input source of a media player device."""
instance = ModeCapabilityInstance.INPUT_SOURCE
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == media_player.DOMAIN:
if MediaPlayerFeature.SELECT_SOURCE in self._entity_config.get(CONF_FEATURES, []):
return super().supported
if self._state_features & MediaPlayerEntityFeature.SELECT_SOURCE:
return super().supported
return False
def get_yandex_mode_by_ha_mode(self, ha_mode: str, hide_warnings: bool = False) -> ModeCapabilityMode | None:
"""Return Yandex mode for HA mode."""
return super().get_yandex_mode_by_ha_mode(ha_mode, hide_warnings=True)
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_SELECT_SOURCE,
{
ATTR_ENTITY_ID: self.state.entity_id,
media_player.ATTR_INPUT_SOURCE: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
modes = self.state.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, []) or []
filtered_modes = list(filter(lambda m: m not in ["Live TV"], modes)) # #418
if filtered_modes or self.state.state not in (STATE_OFF, STATE_UNKNOWN):
self._cache.save_attr_value(self.state.entity_id, media_player.ATTR_INPUT_SOURCE_LIST, modes)
return modes
return self._cache.get_attr_value(self.state.entity_id, media_player.ATTR_INPUT_SOURCE_LIST) or []
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(media_player.ATTR_INPUT_SOURCE)
class FanSpeedCapability(StateModeCapability, ABC):
"""Base capability to control a device fan speed."""
instance = ModeCapabilityInstance.FAN_SPEED
class FanSpeedCapabilityClimate(FanSpeedCapability):
"""Capability to control the fan speed of a climate device."""
_modes_map_default = {
ModeCapabilityMode.AUTO: [
climate.FAN_AUTO,
climate.FAN_ON,
XiaomiFanMode.NATURE,
],
ModeCapabilityMode.QUIET: [
climate.FAN_OFF,
climate.FAN_DIFFUSE,
],
ModeCapabilityMode.MIN: [
TionFanSpeed.S1,
SmartThinQFanMode.LOW_MID,
],
ModeCapabilityMode.LOW: [
climate.FAN_LOW,
TionFanSpeed.S2,
],
ModeCapabilityMode.MEDIUM: [
climate.FAN_MEDIUM,
climate.FAN_MIDDLE,
XiaomiHumidifierMode.MID,
TionFanSpeed.S3,
],
ModeCapabilityMode.HIGH: [
climate.FAN_HIGH,
TionFanSpeed.S4,
],
ModeCapabilityMode.TURBO: [
climate.FAN_FOCUS,
GenericMode.HIGHEST,
TionFanSpeed.S5,
],
ModeCapabilityMode.MAX: [
TionFanSpeed.S6,
SmartThinQFanMode.MID_HIGH,
],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == climate.DOMAIN and self._state_features & ClimateEntityFeature.FAN_MODE:
return super().supported
return False
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
climate.DOMAIN,
climate.SERVICE_SET_FAN_MODE,
{
ATTR_ENTITY_ID: self.state.entity_id,
climate.ATTR_FAN_MODE: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) or []
# esphome default state for some devices
if self._ha_value == climate.FAN_ON and climate.FAN_ON not in modes:
modes.append(climate.FAN_ON)
return modes
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(climate.ATTR_FAN_MODE)
class FanSpeedCapabilityFanViaPreset(FanSpeedCapability):
"""Capability to control the fan speed of a fan device via preset."""
_modes_map_default = {
ModeCapabilityMode.AUTO: [
climate.FAN_AUTO,
climate.FAN_ON,
],
ModeCapabilityMode.ECO: [
XiaomiFanMode.IDLE,
],
ModeCapabilityMode.QUIET: [
climate.FAN_OFF,
XiaomiFanMode.SILENT,
XiaomiMiotFanMode.LEVEL_1,
],
ModeCapabilityMode.LOW: [
XiaomiMiotFanMode.LEVEL_2,
],
ModeCapabilityMode.MEDIUM: [
XiaomiHumidifierMode.MID,
XiaomiMiotFanMode.LEVEL_3,
],
ModeCapabilityMode.NORMAL: [
XiaomiFanMode.FAVORITE,
],
ModeCapabilityMode.HIGH: [
XiaomiMiotFanMode.LEVEL_4,
],
ModeCapabilityMode.TURBO: [
XiaomiFanMode.STRONG,
XiaomiMiotFanMode.LEVEL_5,
],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == fan.DOMAIN:
if self._state_features & FanEntityFeature.PRESET_MODE:
if (
self._state_features & FanEntityFeature.SET_SPEED
and fan.ATTR_PERCENTAGE_STEP in self.state.attributes
):
return False
return super().supported
return False
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: self.state.entity_id,
fan.ATTR_PRESET_MODE: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
return self.state.attributes.get(fan.ATTR_PRESET_MODES, []) or []
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(fan.ATTR_PRESET_MODE)
class FanSpeedCapabilityFanViaPercentage(FanSpeedCapability):
"""Capability to control the fan speed in percents of a fan device."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == fan.DOMAIN:
if (
self._state_features & fan.FanEntityFeature.SET_SPEED
and fan.ATTR_PERCENTAGE_STEP in self.state.attributes
):
return super().supported
return False
@property
def supported_yandex_modes(self) -> list[ModeCapabilityMode]:
"""Returns a list of supported Yandex modes."""
return [ModeCapabilityMode(m) for m in self.supported_ha_modes]
def get_value(self) -> ModeCapabilityMode | None:
"""Return the current capability value."""
if not self._ha_value:
return None
value = int(self._ha_value)
if self.modes_map:
for yandex_mode, values in self.modes_map.items():
for str_value in values:
if value == self._convert_mapping_speed_value(str_value):
return yandex_mode
return None
return ModeCapabilityMode(percentage_to_ordered_list_item(self.supported_ha_modes, value))
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if self.modes_map:
ha_modes = self.modes_map.get(state.value)
if not ha_modes:
raise APIError(
ResponseCode.INVALID_VALUE,
f"Unsupported mode '{state.value}' for {self}, see https://docs.yaha-cloud.ru/v1.0.x/config/modes/",
)
ha_mode = self._convert_mapping_speed_value(ha_modes[0])
else:
ha_mode = ordered_list_item_to_percentage(self.supported_ha_modes, state.value)
await self._hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_PERCENTAGE: ha_mode},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
if self.modes_map:
return self.modes_map.keys()
percentage_step = self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP, 100)
speed_count = math.ceil(100 / percentage_step)
if speed_count == 1:
return []
modes = [ModeCapabilityMode.LOW, ModeCapabilityMode.HIGH]
if speed_count >= 3:
modes.insert(modes.index(ModeCapabilityMode.HIGH), ModeCapabilityMode.MEDIUM)
if speed_count >= 4:
modes.insert(modes.index(ModeCapabilityMode.MEDIUM), ModeCapabilityMode.NORMAL)
if speed_count >= 5:
modes.insert(0, ModeCapabilityMode.ECO)
if speed_count >= 6:
modes.insert(modes.index(ModeCapabilityMode.LOW), ModeCapabilityMode.QUIET)
if speed_count >= 7:
modes.append(ModeCapabilityMode.TURBO)
return modes
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(fan.ATTR_PERCENTAGE)
def _convert_mapping_speed_value(self, value: str) -> int:
try:
return int(value.replace("%", ""))
except ValueError:
raise APIError(ResponseCode.INVALID_VALUE, f"Unsupported speed value '{value}' for {self}")
class CleanupModeCapability(StateModeCapability):
"""Capability to control the program of a vacuum."""
instance = ModeCapabilityInstance.CLEANUP_MODE
_modes_map_default = {
ModeCapabilityMode.ECO: [
RoborockCleanupMode.OFF,
],
ModeCapabilityMode.AUTO: [
RoombaCleanupMode.AUTOMATIC,
RoborockCleanupMode.BALANCED,
],
ModeCapabilityMode.TURBO: [
GenericMode.MAX_PLUS_SIGN,
RoborockCleanupMode.TURBO,
RoombaCleanupMode.PERFORMANCE,
XiaomiMiotCleanupMode.FULL_SPEED,
],
ModeCapabilityMode.LOW: [
GenericMode.GENTLE,
],
ModeCapabilityMode.MAX: [
RoborockCleanupMode.MAX,
XiaomiMiotCleanupMode.STRONG,
],
ModeCapabilityMode.FAST: [
RoborockCleanupMode.MAX_PLUS,
],
ModeCapabilityMode.NORMAL: [
RoombaCleanupMode.STANDARD,
XiaomiMiotCleanupMode.BASIC,
],
ModeCapabilityMode.QUIET: [
RoborockCleanupMode.SILENT,
RoombaCleanupMode.ECO,
],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == vacuum.DOMAIN and self._state_features & VacuumEntityFeature.FAN_SPEED:
return super().supported
return False
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
vacuum.DOMAIN,
vacuum.SERVICE_SET_FAN_SPEED,
{
ATTR_ENTITY_ID: self.state.entity_id,
vacuum.ATTR_FAN_SPEED: self.get_ha_mode_by_yandex_mode(state.value),
},
blocking=self._wait_for_service_call,
context=context,
)
@property
def _ha_modes(self) -> Iterable[Any]:
"""Returns list of HA modes."""
return self.state.attributes.get(vacuum.ATTR_FAN_SPEED_LIST, []) or []
@property
def _ha_value(self) -> Any:
"""Return the current unmapped capability value."""
return self.state.attributes.get(vacuum.ATTR_FAN_SPEED)
STATE_CAPABILITIES_REGISTRY.register(ThermostatCapability)
STATE_CAPABILITIES_REGISTRY.register(SwingCapability)
STATE_CAPABILITIES_REGISTRY.register(ProgramCapabilityClimate)
STATE_CAPABILITIES_REGISTRY.register(ProgramCapabilityHumidifier)
STATE_CAPABILITIES_REGISTRY.register(ProgramCapabilityFan)
STATE_CAPABILITIES_REGISTRY.register(InputSourceCapability)
STATE_CAPABILITIES_REGISTRY.register(FanSpeedCapabilityClimate)
STATE_CAPABILITIES_REGISTRY.register(FanSpeedCapabilityFanViaPreset)
STATE_CAPABILITIES_REGISTRY.register(FanSpeedCapabilityFanViaPercentage)
STATE_CAPABILITIES_REGISTRY.register(CleanupModeCapability)

View File

@@ -0,0 +1,569 @@
"""Implement the Yandex Smart Home on_off capability."""
from abc import ABC, abstractmethod
from typing import Protocol
from homeassistant.components import (
automation,
button,
climate,
cover,
fan,
group,
humidifier,
input_boolean,
input_button,
light,
lock,
media_player,
remote,
scene,
script,
switch,
vacuum,
valve,
water_heater,
)
from homeassistant.components.climate import HVACMode
from homeassistant.components.media_player import MediaPlayerEntityFeature
from homeassistant.components.vacuum import VacuumEntityFeature
from homeassistant.components.water_heater import WaterHeaterEntityFeature
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_CLOSE_COVER,
SERVICE_CLOSE_VALVE,
SERVICE_LOCK,
SERVICE_OPEN_COVER,
SERVICE_OPEN_VALVE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_UNLOCK,
STATE_OFF,
STATE_ON,
STATE_OPEN,
)
from homeassistant.core import DOMAIN as HA_DOMAIN, Context
from homeassistant.helpers.service import async_call_from_config
from .backports import LockState, VacuumActivity
from .capability import STATE_CAPABILITIES_REGISTRY, ActionOnlyCapabilityMixin, StateCapability
from .const import (
CONF_FEATURES,
CONF_STATE_UNKNOWN,
CONF_TURN_OFF,
CONF_TURN_ON,
SKYKETTLE_MODE_BOIL,
MediaPlayerFeature,
)
from .helpers import ActionNotAllowed, APIError
from .schema import (
CapabilityType,
OnOffCapabilityInstance,
OnOffCapabilityInstanceActionState,
OnOffCapabilityParameters,
ResponseCode,
)
class OnOffCapability(StateCapability[OnOffCapabilityInstanceActionState], Protocol):
"""Base class for capabilitity to turn on and off a device.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/on_off-docpage/
"""
type: CapabilityType = CapabilityType.ON_OFF
instance: OnOffCapabilityInstance = OnOffCapabilityInstance.ON
@abstractmethod
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
...
@property
def retrievable(self) -> bool:
"""Test if the capability can return the current value."""
if self._entity_config.get(CONF_STATE_UNKNOWN):
return False
return True
@property
def parameters(self) -> OnOffCapabilityParameters | None:
"""Return parameters for a devices list request."""
if not self.retrievable:
return OnOffCapabilityParameters(split=True)
return None
def get_value(self) -> bool | None:
"""Return the current capability value."""
return self.state.state != STATE_OFF
async def set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state."""
for key, call in ((CONF_TURN_ON, state.value), (CONF_TURN_OFF, not state.value)):
if key in self._entity_config and call:
if self._entity_config[key] is False:
raise ActionNotAllowed
await async_call_from_config(
self._hass, self._entity_config[key], blocking=self._wait_for_service_call, context=context
)
return
await self._set_instance_state(context, state)
@staticmethod
def _get_service(state: OnOffCapabilityInstanceActionState) -> str:
"""Return the service to be called for a new state."""
if state.value:
return SERVICE_TURN_ON
return SERVICE_TURN_OFF
def __str__(self) -> str:
"""Return string representation."""
return f"{self.type.short} capability of {self.device_id}"
class OnlyOnCapability(ActionOnlyCapabilityMixin, OnOffCapability, ABC):
"""Capability to only turn on a device."""
@property
def parameters(self) -> OnOffCapabilityParameters | None:
"""Return parameters for a devices list request."""
return None
class OnOffCapabilityBasic(OnOffCapability):
"""Capability to turn on or off a device."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain in (light.DOMAIN, fan.DOMAIN, switch.DOMAIN, humidifier.DOMAIN, input_boolean.DOMAIN)
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
await self._hass.services.async_call(
self.state.domain,
self._get_service(state),
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityAutomation(OnOffCapability):
"""Capability to enable or disable an automation."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return bool(self.state.domain == automation.DOMAIN)
def get_value(self) -> bool | None:
"""Return the current capability value."""
return self.state.state == STATE_ON
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
await self._hass.services.async_call(
automation.DOMAIN,
self._get_service(state),
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityGroup(OnOffCapability):
"""Capability to turn on or off a group of devices."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain in group.DOMAIN
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
await self._hass.services.async_call(
HA_DOMAIN,
self._get_service(state),
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityScript(OnlyOnCapability):
"""Capability to call a script or scene."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain in (scene.DOMAIN, script.DOMAIN)
@property
def _wait_for_service_call(self) -> bool:
"""Check if service should be run in blocking mode."""
if self.state.domain == script.DOMAIN:
return False
return super()._wait_for_service_call
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
self.state.domain,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityButton(OnlyOnCapability):
"""Capability to press a button."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == button.DOMAIN
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
self.state.domain,
button.SERVICE_PRESS,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityInputButton(OnlyOnCapability):
"""Capability to press a input_button."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == input_button.DOMAIN
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
self.state.domain,
input_button.SERVICE_PRESS,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityLock(OnOffCapability):
"""Capability to lock or unlock a lock."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == lock.DOMAIN
def get_value(self) -> bool | None:
"""Return the current capability value."""
return bool(self.state.state == LockState.UNLOCKED)
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if state.value:
service = SERVICE_UNLOCK
else:
service = SERVICE_LOCK
await self._hass.services.async_call(
lock.DOMAIN,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityCover(OnOffCapability):
"""Capability to open or close a cover."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == cover.DOMAIN
def get_value(self) -> bool | None:
"""Return the current capability value."""
return self.state.state == STATE_OPEN
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if state.value:
service = SERVICE_OPEN_COVER
else:
service = SERVICE_CLOSE_COVER
await self._hass.services.async_call(
cover.DOMAIN,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityRemote(ActionOnlyCapabilityMixin, OnOffCapability):
"""Capability to turn on or off a remote."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == remote.DOMAIN
@property
def parameters(self) -> OnOffCapabilityParameters | None:
"""Return parameters for a devices list request."""
return OnOffCapabilityParameters(split=True)
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
remote.DOMAIN,
self._get_service(state),
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=False,
context=context,
)
class OnOffCapabilityMediaPlayer(OnOffCapability):
"""Capability to turn on or off a media player device."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == media_player.DOMAIN:
if CONF_TURN_ON in self._entity_config or CONF_TURN_OFF in self._entity_config:
return True
if MediaPlayerFeature.TURN_ON_OFF in self._entity_config.get(CONF_FEATURES, []):
return True
return bool(
self._state_features & MediaPlayerEntityFeature.TURN_ON
or self._state_features & MediaPlayerEntityFeature.TURN_OFF
)
return False
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
await self._hass.services.async_call(
media_player.DOMAIN,
self._get_service(state),
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityVacuum(OnOffCapability):
"""Capability to start or stop cleaning by a vacuum."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain != vacuum.DOMAIN:
return False
if CONF_TURN_ON in self._entity_config:
return True
if self._state_features & VacuumEntityFeature.TURN_ON and self._state_features & VacuumEntityFeature.TURN_OFF:
return True
if self._state_features & VacuumEntityFeature.START:
if (
self._state_features & VacuumEntityFeature.RETURN_HOME
or self._state_features & VacuumEntityFeature.STOP
):
return True
return False
def get_value(self) -> bool | None:
"""Return the current capability value."""
return self.state.state in [STATE_ON, VacuumActivity.CLEANING]
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
if state.value:
service = SERVICE_TURN_ON
if self._state_features & VacuumEntityFeature.START:
service = vacuum.SERVICE_START
else:
service = SERVICE_TURN_OFF
if self._state_features & VacuumEntityFeature.RETURN_HOME:
service = vacuum.SERVICE_RETURN_TO_BASE
elif self._state_features & VacuumEntityFeature.STOP:
service = vacuum.SERVICE_STOP
await self._hass.services.async_call(
vacuum.DOMAIN,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OnOffCapabilityClimate(OnOffCapability):
"""Capability to turn on or off a climate device."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == climate.DOMAIN
def get_value(self) -> bool | None:
"""Return the current capability value."""
return self.state.state != HVACMode.OFF
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
service_data = {ATTR_ENTITY_ID: self.state.entity_id}
if state.value:
service = SERVICE_TURN_ON
hvac_modes = self.state.attributes.get(climate.ATTR_HVAC_MODES, [])
for mode in (HVACMode.HEAT_COOL, HVACMode.AUTO):
if mode not in hvac_modes:
continue
service_data[climate.ATTR_HVAC_MODE] = mode
service = climate.SERVICE_SET_HVAC_MODE
break
else:
service = SERVICE_TURN_OFF
await self._hass.services.async_call(
climate.DOMAIN, service, service_data, blocking=self._wait_for_service_call, context=context
)
class OnOffCapabilityWaterHeater(OnOffCapability):
"""Capability to turn on or off a water heater."""
_water_heater_operations = {
STATE_ON: [STATE_ON, "On", "ON", water_heater.STATE_ELECTRIC, SKYKETTLE_MODE_BOIL],
STATE_OFF: [STATE_OFF, "Off", "OFF"],
}
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == water_heater.DOMAIN
def get_value(self) -> bool | None:
"""Return the current capability value."""
return self.state.state.lower() != STATE_OFF
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state (if wasn't overriden by the user)."""
if self._state_features & WaterHeaterEntityFeature.ON_OFF:
await self._set_state_on_off(context, state)
else:
await self._set_state_operation_mode(context, state)
async def _set_state_on_off(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
await self._hass.services.async_call(
water_heater.DOMAIN,
self._get_service(state),
{
ATTR_ENTITY_ID: self.state.entity_id,
},
blocking=self._wait_for_service_call,
context=context,
)
async def _set_state_operation_mode(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
operation_list = self.state.attributes.get(water_heater.ATTR_OPERATION_LIST, [])
if state.value:
mode = self._get_water_heater_operation(STATE_ON, operation_list)
else:
mode = self._get_water_heater_operation(STATE_OFF, operation_list)
if not mode:
target_state_text = "on" if state.value else "off"
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
f"Unable to determine operation mode for {target_state_text} state for {self}",
)
await self._hass.services.async_call(
water_heater.DOMAIN,
water_heater.SERVICE_SET_OPERATION_MODE,
{ATTR_ENTITY_ID: self.state.entity_id, water_heater.ATTR_OPERATION_MODE: mode},
blocking=self._wait_for_service_call,
context=context,
)
def _get_water_heater_operation(self, required_mode: str, operations_list: list[str]) -> str | None:
for operation in self._water_heater_operations[required_mode]:
if operation in operations_list:
return operation
return None
class OnOffCapabilityValve(OnOffCapability):
"""Capability to open or close a valve."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return bool(self.state.domain == valve.DOMAIN)
def get_value(self) -> bool | None:
"""Return the current capability value."""
return self.state.state == STATE_OPEN
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if state.value:
service = SERVICE_OPEN_VALVE
else:
service = SERVICE_CLOSE_VALVE
await self._hass.services.async_call(
valve.DOMAIN,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityBasic)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityAutomation)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityGroup)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityScript)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityButton)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityInputButton)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityLock)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityCover)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityRemote)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityMediaPlayer)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityVacuum)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityClimate)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityWaterHeater)
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityValve)

View File

@@ -0,0 +1,742 @@
"""Implement the Yandex Smart Home range capabilities."""
from abc import ABC, abstractmethod
from functools import cached_property
import logging
import math
from typing import Any, Protocol
from homeassistant.components import climate, cover, fan, humidifier, light, media_player, valve, water_heater
from homeassistant.components.climate import ClimateEntityFeature
from homeassistant.components.cover import CoverEntityFeature
from homeassistant.components.light import ColorMode
from homeassistant.components.media_player import MediaPlayerDeviceClass, MediaPlayerEntityFeature, MediaType
from homeassistant.components.valve import ValveEntityFeature
from homeassistant.components.water_heater import WaterHeaterEntityFeature
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_MODEL,
ATTR_TEMPERATURE,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_SET_COVER_POSITION,
SERVICE_SET_VALVE_POSITION,
SERVICE_TURN_ON,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Context
from homeassistant.util.color import RGBColor
from .capability import STATE_CAPABILITIES_REGISTRY, Capability, StateCapability
from .capability_color import LightState
from .const import (
ATTR_TARGET_HUMIDITY,
CONF_ENTITY_RANGE,
CONF_ENTITY_RANGE_MAX,
CONF_ENTITY_RANGE_MIN,
CONF_ENTITY_RANGE_PRECISION,
CONF_FEATURES,
CONF_SUPPORT_SET_CHANNEL,
DOMAIN_XIAOMI_AIRPURIFIER,
MODEL_PREFIX_XIAOMI_AIRPURIFIER,
SERVICE_FAN_SET_TARGET_HUMIDITY,
STATE_NONE,
MediaPlayerFeature,
)
from .helpers import APIError
from .schema import (
CapabilityType,
RangeCapabilityInstance,
RangeCapabilityInstanceActionState,
RangeCapabilityParameters,
RangeCapabilityRange,
ResponseCode,
)
_LOGGER = logging.getLogger(__name__)
class RangeCapability(Capability[RangeCapabilityInstanceActionState], Protocol):
"""Base class for capabilities with range functionality like volume or brightness.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-docpage/
"""
type: CapabilityType = CapabilityType.RANGE
instance: RangeCapabilityInstance
@property
@abstractmethod
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
...
@abstractmethod
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
...
@property
def retrievable(self) -> bool:
"""Test if the capability can return the current value."""
return self.support_random_access
@property
def parameters(self) -> RangeCapabilityParameters:
"""Return parameters for a devices list request."""
if self.support_random_access:
return RangeCapabilityParameters(instance=self.instance, random_access=True, range=self._range)
if self.instance in [
RangeCapabilityInstance.BRIGHTNESS,
RangeCapabilityInstance.HUMIDITY,
RangeCapabilityInstance.OPEN,
RangeCapabilityInstance.TEMPERATURE,
]:
return RangeCapabilityParameters(
instance=self.instance, random_access=self.support_random_access, range=self._range
)
return RangeCapabilityParameters(instance=self.instance, random_access=False)
def get_value(self) -> float | None:
"""Return the current capability value."""
value = self._get_value()
if self.support_random_access and value is not None:
if not (self._range.min <= value <= self._range.max):
_LOGGER.debug(
f"Value {value} is not in range {self._range} for instance {self.instance.value} "
f"of {self.device_id}"
)
return None
return value
@abstractmethod
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
...
@abstractmethod
def _get_absolute_value(self, relative_value: float) -> float:
"""Return the absolute value for a relative value."""
...
def _get_service_call_value(self, state: RangeCapabilityInstanceActionState) -> float:
"""Return the absolute value for a service call."""
if state.relative:
return self._get_absolute_value(state.value)
return state.value
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(min=0, max=100, precision=1)
def _convert_to_float(self, value: Any, strict: bool = True) -> float | None:
"""Return float of a value, ignore some states, catch errors."""
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE):
return None
try:
return float(value)
except (ValueError, TypeError):
if strict:
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Unsupported value '{value}' for {self}")
return None
class StateRangeCapability(RangeCapability, StateCapability[RangeCapabilityInstanceActionState], Protocol):
"""Base class for a range capability based on the state."""
def _get_absolute_value(self, relative_value: float) -> float:
"""Return the absolute value for a relative value."""
value = self._get_value()
if value is None:
if self.state.state == STATE_OFF:
raise APIError(ResponseCode.DEVICE_OFF, f"Device {self.state.entity_id} probably turned off")
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Missing current value for {self}")
return max(min(value + relative_value, self._range.max), self._range.min)
class CoverPositionCapability(StateRangeCapability):
"""Capability to control position of a cover."""
instance = RangeCapabilityInstance.OPEN
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == cover.DOMAIN and bool(self._state_features & CoverEntityFeature.SET_POSITION)
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
return True
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
cover.DOMAIN,
SERVICE_SET_COVER_POSITION,
{ATTR_ENTITY_ID: self.state.entity_id, cover.ATTR_POSITION: self._get_service_call_value(state)},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
return self._convert_to_float(self.state.attributes.get(cover.ATTR_CURRENT_POSITION))
class TemperatureCapability(StateRangeCapability, ABC):
"""Capability to control a device target temperature."""
instance = RangeCapabilityInstance.TEMPERATURE
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
return True
class TemperatureCapabilityWaterHeater(TemperatureCapability):
"""Capability to control a water heater target temperature."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == water_heater.DOMAIN and bool(
self._state_features & WaterHeaterEntityFeature.TARGET_TEMPERATURE
)
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
water_heater.DOMAIN,
water_heater.SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: self._get_service_call_value(state)},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
return self._convert_to_float(self.state.attributes.get(ATTR_TEMPERATURE))
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(
min=self.state.attributes.get(water_heater.ATTR_MIN_TEMP, 0),
max=self.state.attributes.get(water_heater.ATTR_MAX_TEMP, 100),
precision=0.5,
)
class TemperatureCapabilityClimate(TemperatureCapability):
"""Capability to control a climate device target temperature."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == climate.DOMAIN and bool(
self._state_features & ClimateEntityFeature.TARGET_TEMPERATURE
)
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
climate.DOMAIN,
climate.SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: self._get_service_call_value(state)},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
return self._convert_to_float(self.state.attributes.get(ATTR_TEMPERATURE))
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(
min=self.state.attributes.get(climate.ATTR_MIN_TEMP, 0),
max=self.state.attributes.get(climate.ATTR_MAX_TEMP, 100),
precision=self.state.attributes.get(climate.ATTR_TARGET_TEMP_STEP, 0.5),
)
class HumidityCapability(StateRangeCapability, ABC):
"""Capability to control a device target humidity."""
instance = RangeCapabilityInstance.HUMIDITY
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
return True
class HumidityCapabilityHumidifier(HumidityCapability):
"""Capability to control a humidifier target humidity."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == humidifier.DOMAIN
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
humidifier.DOMAIN,
humidifier.SERVICE_SET_HUMIDITY,
{ATTR_ENTITY_ID: self.state.entity_id, humidifier.ATTR_HUMIDITY: self._get_service_call_value(state)},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
return self._convert_to_float(self.state.attributes.get(humidifier.ATTR_HUMIDITY))
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(
min=self.state.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 0),
max=self.state.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 100),
precision=1,
)
class HumidityCapabilityXiaomiFan(HumidityCapability):
"""Capability to control a Xiaomi fan target humidity."""
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == fan.DOMAIN:
if self.state.attributes.get(ATTR_MODEL, "").startswith(MODEL_PREFIX_XIAOMI_AIRPURIFIER):
if ATTR_TARGET_HUMIDITY in self.state.attributes:
return True
return False
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
DOMAIN_XIAOMI_AIRPURIFIER,
SERVICE_FAN_SET_TARGET_HUMIDITY,
{ATTR_ENTITY_ID: self.state.entity_id, humidifier.ATTR_HUMIDITY: self._get_service_call_value(state)},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
return self._convert_to_float(self.state.attributes.get(ATTR_TARGET_HUMIDITY))
class BrightnessCapability(StateRangeCapability):
"""Capability to control brightness of a device."""
instance = RangeCapabilityInstance.BRIGHTNESS
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == light.DOMAIN and light.brightness_supported(
self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES)
)
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
return True
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if state.relative:
attribute = light.ATTR_BRIGHTNESS_STEP_PCT
else:
attribute = light.ATTR_BRIGHTNESS_PCT
await self._hass.services.async_call(
light.DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: self.state.entity_id, attribute: state.value},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
if (brightness := self._convert_to_float(self.state.attributes.get(light.ATTR_BRIGHTNESS))) is not None:
return int(100 * (brightness / 255))
return None
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(min=1, max=100, precision=1)
class WhiteLightBrightnessCapability(StateRangeCapability, LightState):
"""Capability to control white brightness and cold white brightness of a RGBW/RGBWW light device."""
instance = RangeCapabilityInstance.VOLUME
volume_default_relative_step = 3
brightness_relative_step = 20
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == light.DOMAIN and bool(
{ColorMode.RGBW, ColorMode.RGBWW} & self._supported_color_modes
)
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
return True
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
service_data: dict[str, Any] = {ATTR_ENTITY_ID: self.state.entity_id}
color = self._rgb_color or RGBColor(0, 0, 0)
brightness_pct = state.value
if state.relative:
if abs(state.value) == self.volume_default_relative_step:
brightness_pct = self._get_absolute_value(self.brightness_relative_step * math.copysign(1, state.value))
else:
brightness_pct = self._get_absolute_value(state.value)
brightness = round(255 * brightness_pct / 100)
if ColorMode.RGBWW in self._supported_color_modes:
service_data[light.ATTR_RGBWW_COLOR] = color + (brightness, self._warm_white_brightness or 0)
else:
service_data[light.ATTR_RGBW_COLOR] = color + (brightness,)
await self._hass.services.async_call(
light.DOMAIN, SERVICE_TURN_ON, service_data, blocking=self._wait_for_service_call, context=context
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
if (value := self._white_brightness) is not None:
return int(100 * (value / 255))
return None
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(min=0, max=100, precision=1)
class WarmWhiteLightBrightnessCapability(StateRangeCapability, LightState):
"""Capability to control warm white brightness of a RGBWW light device."""
instance = RangeCapabilityInstance.OPEN
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == light.DOMAIN and ColorMode.RGBWW in self._supported_color_modes
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
return True
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
color = self._rgb_color or RGBColor(0, 0, 0)
brightness_pct = self._get_service_call_value(state)
await self._hass.services.async_call(
light.DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: self.state.entity_id,
light.ATTR_RGBWW_COLOR: color + (self._white_brightness or 0, round(255 * brightness_pct / 100)),
},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
if (value := self._warm_white_brightness) is not None:
return int(100 * (value / 255))
return None
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(min=0, max=100, precision=1)
class VolumeCapability(StateRangeCapability):
"""Capability to control volume of a device."""
instance = RangeCapabilityInstance.VOLUME
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == media_player.DOMAIN:
if self._state_features & MediaPlayerEntityFeature.VOLUME_STEP:
return True
if self._state_features & MediaPlayerEntityFeature.VOLUME_SET:
return True
if MediaPlayerFeature.VOLUME_SET in self._entity_config.get(CONF_FEATURES, []):
return True
return False
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
if MediaPlayerFeature.VOLUME_SET in self._entity_config.get(CONF_FEATURES, []):
return True
return not (
self._state_features & MediaPlayerEntityFeature.VOLUME_STEP
and not self._state_features & MediaPlayerEntityFeature.VOLUME_SET
)
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if self.support_random_access:
await self._hass.services.async_call(
media_player.DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: self.state.entity_id,
media_player.ATTR_MEDIA_VOLUME_LEVEL: self._get_service_call_value(state) / 100,
},
blocking=self._wait_for_service_call,
context=context,
)
return
# absolute volume
if not state.relative:
raise APIError(ResponseCode.INVALID_VALUE, f"Absolute volume is not supported for {self}")
if state.value > 0:
service = SERVICE_VOLUME_UP
else:
service = SERVICE_VOLUME_DOWN
volume_step = int(self._entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_PRECISION, 1))
if abs(state.value) != 1:
volume_step = int(abs(state.value))
for _ in range(volume_step):
await self._hass.services.async_call(
media_player.DOMAIN,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
return None
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
if (
level := self._convert_to_float(self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL))
) is not None:
return int(level * 100)
return None
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(
min=self._entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MIN, 0),
max=self._entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MAX, 100),
precision=self._entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_PRECISION, 1),
)
class ChannelCapability(StateRangeCapability):
"""Capability to control media playback state."""
instance = RangeCapabilityInstance.CHANNEL
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == media_player.DOMAIN:
if (
self._state_features & MediaPlayerEntityFeature.PREVIOUS_TRACK
and self._state_features & MediaPlayerEntityFeature.NEXT_TRACK
):
return True
if MediaPlayerFeature.NEXT_PREVIOUS_TRACK in self._entity_config.get(CONF_FEATURES, []):
return True
if (
self._state_features & MediaPlayerEntityFeature.PLAY_MEDIA
or MediaPlayerFeature.PLAY_MEDIA in self._entity_config.get(CONF_FEATURES, [])
):
if self._entity_config.get(CONF_SUPPORT_SET_CHANNEL) is False:
return False
return True
return False
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
if self._entity_config.get(CONF_SUPPORT_SET_CHANNEL) is False:
return False
if device_class == MediaPlayerDeviceClass.TV:
if (
self._state_features & MediaPlayerEntityFeature.PLAY_MEDIA
or MediaPlayerFeature.PLAY_MEDIA in self._entity_config.get(CONF_FEATURES, [])
):
return True
return False
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
value = state.value
if state.relative:
if (
self._state_features & MediaPlayerEntityFeature.PREVIOUS_TRACK
and self._state_features & MediaPlayerEntityFeature.NEXT_TRACK
):
if state.value > 0:
service = SERVICE_MEDIA_NEXT_TRACK
else:
service = SERVICE_MEDIA_PREVIOUS_TRACK
await self._hass.services.async_call(
media_player.DOMAIN,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
return
if self.get_value() is None:
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Missing current value for {self}")
else:
value = self._get_absolute_value(state.value)
try:
await self._hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: self.state.entity_id,
media_player.ATTR_MEDIA_CONTENT_ID: int(value),
media_player.ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL,
},
blocking=False, # some tv's do it too slow
context=context,
)
except ValueError as e:
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
f"Failed to set channel for {self.device_id}. "
f'Please change setting "support_set_channel" to "false" in entity_config '
f"if the device does not support channel selection. Error: {e!r}",
)
return None
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
media_content_type = self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_TYPE)
if media_content_type == MediaType.CHANNEL:
return self._convert_to_float(self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_ID), strict=False)
return None
@cached_property
def _range(self) -> RangeCapabilityRange:
"""Return supporting value range."""
return RangeCapabilityRange(
min=0,
max=999,
precision=1,
)
class ValvePositionCapability(StateRangeCapability):
"""Capability to control position of a device."""
instance = RangeCapabilityInstance.OPEN
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == valve.DOMAIN and bool(self._state_features & ValveEntityFeature.SET_POSITION)
@property
def support_random_access(self) -> bool:
"""Test if the capability accept arbitrary values to be set."""
return True
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
valve.DOMAIN,
SERVICE_SET_VALVE_POSITION,
{ATTR_ENTITY_ID: self.state.entity_id, valve.ATTR_POSITION: self._get_service_call_value(state)},
blocking=self._wait_for_service_call,
context=context,
)
def _get_value(self) -> float | None:
"""Return the current capability value (unguarded)."""
return self._convert_to_float(self.state.attributes.get(valve.ATTR_CURRENT_POSITION))
STATE_CAPABILITIES_REGISTRY.register(CoverPositionCapability)
STATE_CAPABILITIES_REGISTRY.register(TemperatureCapabilityWaterHeater)
STATE_CAPABILITIES_REGISTRY.register(TemperatureCapabilityClimate)
STATE_CAPABILITIES_REGISTRY.register(HumidityCapabilityHumidifier)
STATE_CAPABILITIES_REGISTRY.register(HumidityCapabilityXiaomiFan)
STATE_CAPABILITIES_REGISTRY.register(BrightnessCapability)
STATE_CAPABILITIES_REGISTRY.register(WhiteLightBrightnessCapability)
STATE_CAPABILITIES_REGISTRY.register(WarmWhiteLightBrightnessCapability)
STATE_CAPABILITIES_REGISTRY.register(VolumeCapability)
STATE_CAPABILITIES_REGISTRY.register(ChannelCapability)
STATE_CAPABILITIES_REGISTRY.register(ValvePositionCapability)

View File

@@ -0,0 +1,265 @@
"""Implement the Yandex Smart Home toggle capabilities."""
from typing import Protocol
from homeassistant.components import cover, fan, light, media_player, vacuum
from homeassistant.components.cover import CoverEntityFeature
from homeassistant.components.fan import FanEntityFeature
from homeassistant.components.media_player.const import MediaPlayerEntityFeature
from homeassistant.components.vacuum import VacuumEntityFeature
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_STOP_COVER,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_VOLUME_MUTE,
STATE_ON,
STATE_PLAYING,
)
from homeassistant.core import Context
from .backports import VacuumActivity
from .capability import STATE_CAPABILITIES_REGISTRY, ActionOnlyCapabilityMixin, Capability, StateCapability
from .color import SOLID_LIGHT_EFFECT, LightState
from .const import CONF_FEATURES, MediaPlayerFeature
from .schema import (
CapabilityType,
ToggleCapabilityInstance,
ToggleCapabilityInstanceActionState,
ToggleCapabilityParameters,
)
class ToggleCapability(Capability[ToggleCapabilityInstanceActionState], Protocol):
"""Base class for capabilities with toggle functions like mute or pause.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-docpage/
"""
type: CapabilityType = CapabilityType.TOGGLE
instance: ToggleCapabilityInstance
@property
def parameters(self) -> ToggleCapabilityParameters:
"""Return parameters for a devices list request."""
return ToggleCapabilityParameters(instance=self.instance)
class StateToggleCapability(ToggleCapability, StateCapability[ToggleCapabilityInstanceActionState], Protocol):
"""Base class for a toggle capability based on the state."""
pass
class BacklightCapability(StateToggleCapability):
"""Capability to represent state as backlight toggle."""
instance = ToggleCapabilityInstance.BACKLIGHT
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return True
def get_value(self) -> bool:
"""Return the current capability value."""
return self.state.state == STATE_ON
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if state.value:
service = SERVICE_TURN_ON
else:
service = SERVICE_TURN_OFF
await self._hass.services.async_call(
self.state.domain,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class MuteCapability(StateToggleCapability):
"""Capability to mute and unmute device."""
instance = ToggleCapabilityInstance.MUTE
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == media_player.DOMAIN:
if self._state_features & MediaPlayerEntityFeature.VOLUME_MUTE:
return True
if MediaPlayerFeature.VOLUME_MUTE in self._entity_config.get(CONF_FEATURES, []):
return True
return False
@property
def retrievable(self) -> bool:
"""Test if the capability can return the current value."""
return media_player.ATTR_MEDIA_VOLUME_MUTED in self.state.attributes
def get_value(self) -> bool:
"""Return the current capability value."""
return bool(self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED))
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
media_player.DOMAIN,
SERVICE_VOLUME_MUTE,
{ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_MUTED: state.value},
blocking=self._wait_for_service_call,
context=context,
)
class PauseCapabilityMediaPlayer(StateToggleCapability):
"""Capability to pause and resume media player playback."""
instance = ToggleCapabilityInstance.PAUSE
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
if self.state.domain == media_player.DOMAIN:
if MediaPlayerFeature.PLAY_PAUSE in self._entity_config.get(CONF_FEATURES, []):
return True
if (
self._state_features & MediaPlayerEntityFeature.PAUSE
and self._state_features & MediaPlayerEntityFeature.PLAY
):
return True
return False
def get_value(self) -> bool:
"""Return the current capability value."""
return bool(self.state.state != STATE_PLAYING)
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if state.value:
service = SERVICE_MEDIA_PAUSE
else:
service = SERVICE_MEDIA_PLAY
await self._hass.services.async_call(
media_player.DOMAIN,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class PauseCapabilityCover(ActionOnlyCapabilityMixin, StateToggleCapability):
"""Capability to stop a cover."""
instance = ToggleCapabilityInstance.PAUSE
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == cover.DOMAIN and bool(self._state_features & CoverEntityFeature.STOP)
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
cover.DOMAIN,
SERVICE_STOP_COVER,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class PauseCapabilityLight(ActionOnlyCapabilityMixin, StateToggleCapability, LightState):
"""Capability to turn on solid light effect for a light device."""
instance = ToggleCapabilityInstance.PAUSE
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == light.DOMAIN and self._solid_effect_supported
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
light.DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_EFFECT: SOLID_LIGHT_EFFECT},
blocking=self._wait_for_service_call,
context=context,
)
class PauseCapabilityVacuum(StateToggleCapability):
"""Capability to stop a vacuum."""
instance = ToggleCapabilityInstance.PAUSE
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == vacuum.DOMAIN and bool(self._state_features & VacuumEntityFeature.PAUSE)
def get_value(self) -> bool:
"""Return the current capability value."""
return self.state.state == VacuumActivity.PAUSED
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
"""Change the capability state."""
if state.value:
service = vacuum.SERVICE_PAUSE
else:
service = vacuum.SERVICE_START
await self._hass.services.async_call(
vacuum.DOMAIN,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=self._wait_for_service_call,
context=context,
)
class OscillationCapability(StateToggleCapability):
"""Capability to control fan oscillation."""
instance = ToggleCapabilityInstance.OSCILLATION
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == fan.DOMAIN and bool(self._state_features & FanEntityFeature.OSCILLATE)
def get_value(self) -> bool:
"""Return the current capability value."""
return bool(self.state.attributes.get(fan.ATTR_OSCILLATING))
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
"""Change the capability state."""
await self._hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_OSCILLATE,
{ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_OSCILLATING: state.value},
blocking=self._wait_for_service_call,
context=context,
)
STATE_CAPABILITIES_REGISTRY.register(MuteCapability)
STATE_CAPABILITIES_REGISTRY.register(PauseCapabilityMediaPlayer)
STATE_CAPABILITIES_REGISTRY.register(PauseCapabilityCover)
STATE_CAPABILITIES_REGISTRY.register(PauseCapabilityLight)
STATE_CAPABILITIES_REGISTRY.register(PauseCapabilityVacuum)
STATE_CAPABILITIES_REGISTRY.register(OscillationCapability)

View File

@@ -0,0 +1,107 @@
"""Implement the Yandex Smart Home video_stream capabilities."""
# pyright: reportAttributeAccessIssue=information
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components import camera
from homeassistant.components.camera import CameraEntityFeature, StreamType
from homeassistant.components.stream import Stream
from homeassistant.core import Context
from homeassistant.helpers import network
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import DOMAIN
from .capability import STATE_CAPABILITIES_REGISTRY, ActionOnlyCapabilityMixin, StateCapability
from .cloud_stream import CloudStreamManager
from .helpers import APIError
from .schema import (
CapabilityType,
GetStreamInstanceActionResultValue,
GetStreamInstanceActionState,
ResponseCode,
VideoStreamCapabilityInstance,
VideoStreamCapabilityParameters,
)
try:
from homeassistant.components.camera import get_camera_from_entity_id
except ImportError: # pragma: no cover
from homeassistant.components.camera import ( # type: ignore[no-redef]
_get_camera_from_entity_id as get_camera_from_entity_id,
)
if TYPE_CHECKING:
from . import YandexSmartHome
class VideoStreamCapability(ActionOnlyCapabilityMixin, StateCapability[GetStreamInstanceActionState]):
"""Capability to stream from cameras."""
type = CapabilityType.VIDEO_STREAM
instance = VideoStreamCapabilityInstance.GET_STREAM
@property
def supported(self) -> bool:
"""Test if the capability is supported."""
return self.state.domain == camera.DOMAIN and bool(self._state_features & CameraEntityFeature.STREAM)
@property
def parameters(self) -> VideoStreamCapabilityParameters:
"""Return parameters for a devices request."""
return VideoStreamCapabilityParameters(protocols=["hls"])
async def set_instance_state(
self, context: Context, state: GetStreamInstanceActionState
) -> GetStreamInstanceActionResultValue:
"""Change capability instance state."""
component: YandexSmartHome = self._hass.data[DOMAIN]
entity_id = self.state.entity_id
stream = await self._async_request_stream(entity_id)
if self._entry_data.use_cloud_stream:
cloud_stream = component.cloud_streams.get(entity_id)
if not cloud_stream:
cloud_stream = CloudStreamManager(self._hass, stream, async_get_clientsession(self._hass))
component.cloud_streams[entity_id] = cloud_stream
await cloud_stream.async_start()
stream_url = cloud_stream.stream_url
if not stream_url:
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, "Failed to start stream")
else:
try:
external_url = network.get_url(self._hass, allow_internal=False)
except network.NoURLAvailableError:
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
"Missing Home Assistant external URL. Have you set external URLs in Configuration -> General?",
)
endpoint_url = stream.endpoint_url(StreamType.HLS)
stream_url = f"{external_url}{endpoint_url}"
return GetStreamInstanceActionResultValue(stream_url=stream_url, protocol="hls")
async def _async_request_stream(self, entity_id: str) -> Stream:
camera_entity = get_camera_from_entity_id(self._hass, self.state.entity_id)
stream = await camera_entity.async_create_stream()
if not stream:
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"{entity_id} does not support play stream service"
)
stream.add_provider(StreamType.HLS)
await stream.start()
stream.endpoint_url(StreamType.HLS)
return stream
STATE_CAPABILITIES_REGISTRY.register(VideoStreamCapability)

View File

@@ -0,0 +1,220 @@
"""Implement the Yandex Smart Home cloud connection manager."""
from __future__ import annotations
from asyncio import TimeoutError
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any, AsyncIterable, cast
from aiohttp import ClientConnectorError, ClientResponseError, ClientWebSocketResponse, WSMessage, WSMsgType, hdrs
from homeassistant.core import CALLBACK_TYPE, Context, HassJob, HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE, async_create_clientsession, async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt
from pydantic.v1 import BaseModel
from . import handlers
from .const import CLOUD_BASE_URL, DOMAIN, ISSUE_ID_RECONNECTING_TOO_FAST
from .helpers import RequestData, SmartHomePlatform
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
_LOGGER = logging.getLogger(__name__)
DEFAULT_RECONNECTION_DELAY = 2
MAX_RECONNECTION_DELAY = 180
FAST_RECONNECTION_TIME = timedelta(seconds=6)
FAST_RECONNECTION_THRESHOLD = 5
BASE_API_URL = f"{CLOUD_BASE_URL}/api/home_assistant/v1"
class CloudInstanceData(BaseModel):
"""Hold settings for the cloud connection."""
id: str
password: str
connection_token: str
class CloudInstanceOTP(BaseModel):
"""Hold response for one time password request."""
code: str
class CloudRequest(BaseModel):
"""Request from the cloud."""
request_id: str
platform: SmartHomePlatform
action: str
message: str = ""
class CloudManager:
"""Class to manage cloud connection."""
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData):
"""Initialize a cloud manager with entry data and client session."""
self._hass = hass
self._entry_data = entry_data
self._session = async_get_clientsession(hass)
self._last_connection_at: datetime | None = None
self._fast_reconnection_count = 0
self._ws: ClientWebSocketResponse | None = None
self._ws_reconnect_delay = DEFAULT_RECONNECTION_DELAY
self._ws_active = True
self._unsub_connect: CALLBACK_TYPE | None = None
self._url = f"{BASE_API_URL}/connect"
async def async_connect(self, *_: Any) -> None:
"""Connect to the cloud."""
try:
_LOGGER.debug(f"Connecting to {self._url}")
self._ws = await self._session.ws_connect(
self._url,
heartbeat=45,
compress=15,
headers={
hdrs.AUTHORIZATION: f"Bearer {self._entry_data.cloud_connection_token}",
hdrs.USER_AGENT: f"{SERVER_SOFTWARE} {DOMAIN}/{self._entry_data.component_version}",
},
)
_LOGGER.debug("Connection to Yandex Smart Home cloud established")
self._ws_reconnect_delay = DEFAULT_RECONNECTION_DELAY
self._last_connection_at = dt.utcnow()
ir.async_delete_issue(self._hass, DOMAIN, ISSUE_ID_RECONNECTING_TOO_FAST)
async for msg in cast(AsyncIterable[WSMessage], self._ws):
if msg.type == WSMsgType.TEXT:
await self._on_message(msg)
_LOGGER.debug(f"Disconnected: {self._ws.close_code}")
if self._ws.close_code is not None:
self._try_reconnect()
except (ClientConnectorError, ClientResponseError, TimeoutError):
_LOGGER.exception("Failed to connect to Yandex Smart Home cloud")
self._try_reconnect()
except Exception:
_LOGGER.exception("Unexpected exception")
self._try_reconnect()
return None
async def async_disconnect(self, *_: Any) -> None:
"""Disconnect from the cloud."""
self._ws_active = False
if self._ws:
await self._ws.close()
if self._unsub_connect:
self._unsub_connect()
self._unsub_connect = None
return None
async def _on_message(self, message: WSMessage) -> None:
"""Handle incoming request from the cloud."""
request = CloudRequest.parse_raw(message.data)
_LOGGER.debug("Request: %s (message: %s)" % (request.action, request.message))
data = RequestData(
entry_data=self._entry_data,
context=Context(user_id=await self._entry_data.async_get_context_user_id()),
platform=request.platform,
request_user_id=self._entry_data.cloud_instance_id,
request_id=request.request_id,
)
result = await handlers.async_handle_request(self._hass, data, request.action, request.message)
response = result.as_json()
_LOGGER.debug(f"Response: {response}")
assert self._ws is not None
await self._ws.send_str(response)
return None
def _try_reconnect(self) -> None:
"""Schedule reconnection to the cloud."""
if not self._ws_active:
return None
self._ws_reconnect_delay = min(2 * self._ws_reconnect_delay, MAX_RECONNECTION_DELAY)
if self._last_connection_at and self._last_connection_at + FAST_RECONNECTION_TIME > dt.utcnow():
self._fast_reconnection_count += 1
else:
self._fast_reconnection_count = 0
if self._fast_reconnection_count >= FAST_RECONNECTION_THRESHOLD:
self._ws_reconnect_delay = MAX_RECONNECTION_DELAY
ir.async_create_issue(
self._hass,
DOMAIN,
ISSUE_ID_RECONNECTING_TOO_FAST,
is_fixable=False,
severity=ir.IssueSeverity.CRITICAL,
translation_key=ISSUE_ID_RECONNECTING_TOO_FAST,
translation_placeholders={"entry_title": self._entry_data.entry.title},
)
_LOGGER.warning(f"Reconnecting too fast, next reconnection in {self._ws_reconnect_delay} seconds")
_LOGGER.debug(f"Trying to reconnect in {self._ws_reconnect_delay} seconds")
self._unsub_connect = async_call_later(self._hass, self._ws_reconnect_delay, HassJob(self.async_connect))
return None
async def register_instance(hass: HomeAssistant, platform: SmartHomePlatform | None = None) -> CloudInstanceData:
"""Register a new cloud instance."""
session = async_create_clientsession(hass)
if platform:
response = await session.post(f"{BASE_API_URL}/instance/register", json={"platform": platform.value})
else:
response = await session.post(f"{BASE_API_URL}/instance/register")
response.raise_for_status()
return CloudInstanceData.parse_raw(await response.text())
async def get_instance_otp(hass: HomeAssistant, instance_id: str, token: str) -> str:
"""Return one time password for a cloud instance linking."""
session = async_create_clientsession(hass)
response = await session.post(
f"{BASE_API_URL}/instance/{instance_id}/otp",
headers={hdrs.AUTHORIZATION: f"Bearer {token}"},
)
response.raise_for_status()
return CloudInstanceOTP.parse_raw(await response.text()).code
async def reset_connection_token(hass: HomeAssistant, instance_id: str, token: str) -> CloudInstanceData:
"""Reset a cloud instance connection token."""
session = async_create_clientsession(hass)
response = await session.post(
f"{BASE_API_URL}/instance/{instance_id}/reset-connection-token",
headers={hdrs.AUTHORIZATION: f"Bearer {token}"},
)
response.raise_for_status()
return CloudInstanceData.parse_raw(await response.text())
async def revoke_oauth_tokens(hass: HomeAssistant, instance_id: str, token: str) -> None:
"""Revoke all access and refresh tokens for a cloud instance."""
session = async_create_clientsession(hass)
response = await session.post(
f"{BASE_API_URL}/instance/{instance_id}/oauth/revoke-all",
headers={hdrs.AUTHORIZATION: f"Bearer {token}"},
)
response.raise_for_status()

View File

@@ -0,0 +1,196 @@
"""Implement the Yandex Smart Home cloud connection manager for video streaming."""
import asyncio
from datetime import timedelta
import logging
from typing import Any, AsyncIterable, cast
from aiohttp import (
ClientConnectionError,
ClientResponseError,
ClientSession,
ClientWebSocketResponse,
WSMessage,
WSMsgType,
web,
)
from aiohttp.web_request import Request as AIOWebRequest
from homeassistant.components.stream import Stream
from homeassistant.components.stream.core import StreamView
from homeassistant.components.stream.hls import (
HlsInitView,
HlsMasterPlaylistView,
HlsPartView,
HlsPlaylistView,
HlsSegmentView,
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.http import KEY_HASS
from multidict import MultiDictProxy
from pydantic.v1 import BaseModel
import yarl
from .const import CLOUD_STREAM_BASE_URL
_LOGGER = logging.getLogger(__name__)
RECONNECTION_DELAY = 2
WAIT_FOR_CONNECTION_TIMEOUT = 10
class Request(BaseModel):
"""Request from the cloud."""
view: str
sequence: str = ""
part_num: str = ""
url_query: str | None
class ResponseMeta(BaseModel):
"""Response metadata."""
status_code: int
headers: dict[str, str]
class WebRequest:
"""Represent minimal HTTP request to use in HomeAssistantView"""
def __init__(self, hass: HomeAssistant, url: yarl.URL):
"""Initialize web request from url."""
self.app = {KEY_HASS: hass}
self._url = url
@property
def query(self) -> MultiDictProxy[str]:
"""Return parsed query parameters in decoded representation."""
return MultiDictProxy(self._url.query)
class CloudStreamManager:
"""Class to manage cloud connection for streaming."""
def __init__(self, hass: HomeAssistant, stream: Stream, session: ClientSession):
"""Initialize a cloud manager with stream and client session."""
self._hass = hass
self._stream = stream
self._running_stream_id: str | None = None
self._session = session
self._connected = asyncio.Event()
self._ws: ClientWebSocketResponse | None = None
self._unsub_connect: CALLBACK_TYPE | None = None
self._unsub_keepalive: CALLBACK_TYPE | None = None
@property
def stream_url(self) -> str | None:
"""Return URL to stream."""
if not self._running_stream_id:
return None
return f"{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/master_playlist.m3u8"
async def async_start(self) -> None:
"""Start connection."""
if self._ws or not self._stream.access_token:
return
self._running_stream_id = self._stream.access_token
self._hass.loop.create_task(self._async_connect())
await asyncio.wait_for(self._connected.wait(), timeout=WAIT_FOR_CONNECTION_TIMEOUT)
return await self._async_keepalive()
async def _async_keepalive(self, *_: Any) -> None:
"""Disconnect if stream is not active anymore."""
if self._stream.access_token != self._running_stream_id:
return await self._async_disconnect()
self._unsub_keepalive = async_call_later(self._hass, timedelta(seconds=1), HassJob(self._async_keepalive))
return None
async def _async_connect(self, *_: Any) -> None:
"""Connect to the cloud."""
if not self._running_stream_id:
return
ws_url = f"{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/connect"
try:
_LOGGER.debug(f"Connecting to {ws_url}")
self._ws = await self._session.ws_connect(ws_url, heartbeat=30)
_LOGGER.debug("Connection to Yandex Smart Home cloud established")
self._connected.set()
async for msg in cast(AsyncIterable[WSMessage], self._ws):
if msg.type == WSMsgType.TEXT:
await self._on_message(msg)
_LOGGER.debug(f"Disconnected: {self._ws.close_code}")
if self._ws.close_code is not None:
self._try_reconnect()
except (ClientConnectionError, ClientResponseError, asyncio.TimeoutError):
_LOGGER.exception("Failed to connect to Yandex Smart Home cloud")
self._try_reconnect()
except Exception:
_LOGGER.exception("Unexpected exception")
self._try_reconnect()
return None
async def _async_disconnect(self, *_: Any) -> None:
"""Disconnect from the cloud."""
self._running_stream_id = None
self._connected.clear()
if self._ws:
await self._ws.close()
self._ws = None
for unsub in [self._unsub_connect, self._unsub_keepalive]:
if unsub:
unsub()
self._unsub_connect = None
self._unsub_keepalive = None
return None
async def _on_message(self, message: WSMessage) -> None:
"""Handle incoming request from the cloud."""
_LOGGER.debug(f"Request: {message.data}")
request = Request.parse_raw(message.data)
request_url = yarl.URL.build(path=f"{request.view}", query=request.url_query)
web_request = cast(AIOWebRequest, WebRequest(self._hass, request_url))
views: dict[str, type[StreamView]] = {
"master_playlist": HlsMasterPlaylistView,
"playlist": HlsPlaylistView,
"init": HlsInitView,
"part": HlsPartView,
"segment": HlsSegmentView,
}
view = views[request.view]()
r = cast(
web.Response,
await view.get(web_request, self._stream.access_token or "", request.sequence, request.part_num),
)
assert self._ws is not None
body = r.body if r.body is not None else b""
assert isinstance(body, bytes)
meta = ResponseMeta(status_code=r.status, headers=dict(r.headers))
response = bytes(meta.json(), "utf-8") + b"\r\n" + body
return await self._ws.send_bytes(response, compress=False)
def _try_reconnect(self) -> None:
"""Schedule reconnection to the cloud."""
_LOGGER.debug(f"Trying to reconnect in {RECONNECTION_DELAY} seconds")
self._unsub_reconnect = async_call_later(self._hass, RECONNECTION_DELAY, HassJob(self._async_connect))
return None

View File

@@ -0,0 +1,315 @@
"""Color manipulation helpers."""
from enum import StrEnum
from functools import cached_property
from math import sqrt
from typing import Final, Protocol, Self
from homeassistant.components.light import (
ATTR_EFFECT_LIST,
ATTR_HS_COLOR,
ATTR_MAX_COLOR_TEMP_KELVIN,
ATTR_MIN_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ATTR_XY_COLOR,
ColorMode,
)
from homeassistant.core import State
from homeassistant.util.color import RGBColor, color_hs_to_RGB, color_xy_to_RGB
SOLID_LIGHT_EFFECT: Final = "Solid"
class ColorName(StrEnum):
RED = "red"
CORAL = "coral"
ORANGE = "orange"
YELLOW = "yellow"
LIME = "lime"
GREEN = "green"
EMERALD = "emerald"
TURQUOISE = "turquoise"
CYAN = "cyan"
BLUE = "blue"
MOONLIGHT = "moonlight"
LAVENDER = "lavender"
VIOLET = "violet"
PURPLE = "purple"
ORCHID = "orchid"
MAUVE = "mauve"
RASPBERRY = "raspberry"
FIERY_WHITE = "fiery_white"
SOFT_WHITE = "soft_white"
WARM_WHITE = "warm_white"
WHITE = "white"
DAYLIGHT = "daylight"
COLD_WHITE = "cold_white"
MISTY_WHITE = "misty_white"
HEAVENLY_WHITE = "heavenly_white"
def rgb_to_int(color: RGBColor) -> int:
"""Convert a rgb color to int value."""
return (color.r << 16) + (color.g << 8) + color.b
def int_to_rgb(i: int) -> RGBColor:
"""Convert int value to a rgb color."""
return RGBColor(r=(i >> 16) & 0xFF, g=(i >> 8) & 0xFF, b=i & 0xFF)
ColorProfile = dict[ColorName, int]
"""Hold int value for color/temperature name."""
class ColorProfiles(dict[str, ColorProfile]):
"""Represent color profiles."""
_default_profiles = {
"natural": {
ColorName.RED: 16711680,
ColorName.YELLOW: 16760576,
ColorName.GREEN: 65280,
ColorName.EMERALD: 2424612,
ColorName.TURQUOISE: 65471,
ColorName.CYAN: 65535,
ColorName.BLUE: 255,
ColorName.MOONLIGHT: 16763025,
ColorName.LAVENDER: 4129023,
ColorName.VIOLET: 8323327,
ColorName.PURPLE: 12517631,
ColorName.ORCHID: 16711765,
ColorName.RASPBERRY: 16713260,
}
}
@classmethod
def from_dict(cls, data: dict[str, dict[str, int]]) -> Self:
"""Intialize the color profiles from a dict."""
profiles = cls._default_profiles.copy()
for profile_name, mapping in data.items():
profiles.setdefault(profile_name, {})
profiles[profile_name].update({ColorName(name): v for name, v in mapping.items()})
return cls(profiles)
class ColorConverter:
"""Utility to convert Yandex color to HA and vise-versa."""
_palette = {
ColorName.RED: 16714250,
ColorName.CORAL: 16729907,
ColorName.ORANGE: 16727040,
ColorName.YELLOW: 16740362,
ColorName.LIME: 13303562,
ColorName.GREEN: 720711,
ColorName.EMERALD: 720813,
ColorName.TURQUOISE: 720883,
ColorName.CYAN: 710399,
ColorName.BLUE: 673791,
ColorName.MOONLIGHT: 15067647,
ColorName.LAVENDER: 8719103,
ColorName.VIOLET: 11340543,
ColorName.PURPLE: 16714471,
ColorName.ORCHID: 16714393,
ColorName.MAUVE: 16722742,
ColorName.RASPBERRY: 16711765,
}
def __init__(self, profile: ColorProfile | None = None):
"""Initialize the color converter from color profile."""
profile = profile or {}
self._yandex_mapping: dict[int, int] = {}
self._ha_mapping: dict[int, int] = {}
for color_name, yandex_value in self._palette.items():
ha_value = profile.get(color_name, yandex_value)
self._yandex_mapping[yandex_value] = ha_value
self._ha_mapping[ha_value] = yandex_value
def get_ha_color(self, yandex_color: int) -> RGBColor:
"""Return HA color for Yandex color."""
return int_to_rgb(self._yandex_mapping.get(yandex_color, yandex_color))
def get_yandex_color(self, ha_color: RGBColor) -> int:
"""Return Yandex color for HA color."""
for from_ha_value, to_yandex_value in self._ha_mapping.items():
if self._distance(ha_color, int_to_rgb(from_ha_value)) <= 2:
return to_yandex_value
return rgb_to_int(ha_color)
@staticmethod
def _distance(a: RGBColor, b: RGBColor) -> float:
"""Return a distance between two colors."""
return abs(sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2))
class ColorTemperatureConverter:
"""Utility to convert Yandex color temperature to HA and vise-versa."""
default_white_temperature = 4500
_palette = {
ColorName.FIERY_WHITE: 1500,
ColorName.SOFT_WHITE: 2700,
ColorName.WARM_WHITE: 3400,
ColorName.WHITE: 4500,
ColorName.DAYLIGHT: 5600,
ColorName.COLD_WHITE: 6500,
ColorName.MISTY_WHITE: 7500,
ColorName.HEAVENLY_WHITE: 9000,
}
_temperature_steps = sorted(_palette.values())
def __init__(self, profile: ColorProfile | None, state: State):
"""Initialize the color temperature converter from color profile."""
self._yandex_mapping: dict[int, int] = {}
self._ha_mapping: dict[int, int] = {}
profile = profile or {}
range_extend_threshold = 200
min_color_temp = self._round_color_temperature(int(state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, 2000)))
max_color_temp = self._round_color_temperature(int(state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, 6500)))
for color_name, yandex_value in self._palette.items():
ha_value = self._round_color_temperature(profile.get(color_name, yandex_value))
if ha_value < min_color_temp or ha_value > max_color_temp:
continue
self._map_values(yandex_value, ha_value)
if self._ha_mapping and self._yandex_mapping:
if min_color_temp + range_extend_threshold < min(self._ha_mapping):
if yandex_color_temp := self._first_available_temperature_step:
self._map_values(yandex_color_temp, min_color_temp)
if max_color_temp - range_extend_threshold > max(self._ha_mapping):
if yandex_color_temp := self._last_available_temperature_step:
self._map_values(yandex_color_temp, max_color_temp)
else:
self._ha_mapping[min_color_temp] = self.default_white_temperature
self._yandex_mapping[self.default_white_temperature] = min_color_temp
def get_ha_color_temperature(self, yandex_color_temperature: int) -> int:
"""Return HA color temperature for Yandex color temperature."""
return self._yandex_mapping.get(yandex_color_temperature, yandex_color_temperature)
def get_yandex_color_temperature(self, ha_color_temperature: int) -> int:
"""Return Yandex color temperature for HA color temperature."""
color_temperature = self._round_color_temperature(ha_color_temperature)
return self._ha_mapping.get(color_temperature, color_temperature)
@property
def supported_range(self) -> tuple[int, int]:
"""Return temperature range supported for the state."""
return min(self._yandex_mapping), max(self._yandex_mapping)
@staticmethod
def _round_color_temperature(color_temperature: int) -> int:
"""Return kelvin temperature with decreased precision."""
return round(color_temperature, -2)
@property
def _first_available_temperature_step(self) -> int | None:
"""Return additional minimal temperature that outside mapped temperature range."""
min_color_temp = min(self._yandex_mapping)
idx = self._temperature_steps.index(min_color_temp)
if idx != 0:
return self._temperature_steps[idx - 1]
return None
@property
def _last_available_temperature_step(self) -> int | None:
"""Return additional maximal temperature that outside mapped temperature range."""
max_color_temp = max(self._yandex_mapping)
idx = self._temperature_steps.index(max_color_temp)
try:
return self._temperature_steps[idx + 1]
except IndexError:
pass
return None
def _map_values(self, yandex_value: int, ha_value: int) -> None:
"""Add mapping between yandex values and HA."""
self._yandex_mapping[yandex_value] = ha_value
self._ha_mapping[ha_value] = yandex_value
return None
class LightState(Protocol):
"""Helper class for the state of a light device."""
state: State
@cached_property
def _supported_color_modes(self) -> set[ColorMode]:
"""Return a set of supported color modes."""
return set(self.state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, []))
@cached_property
def _rgb_color(self) -> RGBColor | None:
"""Return current RGB color."""
rgb_color: tuple[int, ...] | None = None
if ColorMode.RGBWW in self._supported_color_modes:
rgb_color = self.state.attributes.get(ATTR_RGBWW_COLOR)
elif ColorMode.RGBW in self._supported_color_modes:
rgb_color = self.state.attributes.get(ATTR_RGBW_COLOR)
else:
rgb_color = self.state.attributes.get(ATTR_RGB_COLOR)
if rgb_color:
return RGBColor(*rgb_color[:3])
if ColorMode.HS in self._supported_color_modes:
hs_color: tuple[float, float] | None = self.state.attributes.get(ATTR_HS_COLOR)
if hs_color:
return RGBColor(*color_hs_to_RGB(*hs_color))
xy_color: tuple[float, float] | None = self.state.attributes.get(ATTR_XY_COLOR)
if xy_color:
return RGBColor(*color_xy_to_RGB(*xy_color, Gamut=None))
return None
@cached_property
def _white_brightness(self) -> int | None:
"""Return current white brightness or cold white brightness."""
rgbw_color: tuple[int, ...] | None = None
if ColorMode.RGBWW in self._supported_color_modes:
rgbw_color = self.state.attributes.get(ATTR_RGBWW_COLOR)
elif ColorMode.RGBW in self._supported_color_modes:
rgbw_color = self.state.attributes.get(ATTR_RGBW_COLOR)
if rgbw_color:
return rgbw_color[3]
return None
@cached_property
def _warm_white_brightness(self) -> int | None:
"""Return current warm white brightness."""
if ColorMode.RGBWW in self._supported_color_modes:
rgbww_color: tuple[int, int, int, int, int] | None = self.state.attributes.get(ATTR_RGBWW_COLOR)
if rgbww_color:
return rgbww_color[4]
return None
@property
def _solid_effect_supported(self) -> bool:
"""Check if the solid light effect is supported by the state."""
return SOLID_LIGHT_EFFECT in (self.state.attributes.get(ATTR_EFFECT_LIST) or [])

View File

@@ -0,0 +1,761 @@
"""Config flow for the Yandex Smart Home integration."""
from __future__ import annotations
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, cast
from aiohttp import ClientConnectorError, ClientResponseError
from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_ENTITIES, CONF_ID, CONF_NAME, CONF_PLATFORM, CONF_TOKEN
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow, FlowHandler
from homeassistant.helpers import entity_registry as er, network, selector
from homeassistant.helpers.entityfilter import CONF_INCLUDE_ENTITIES, FILTER_SCHEMA, EntityFilter
from homeassistant.helpers.selector import (
BooleanSelector,
LabelSelector,
LabelSelectorConfig,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
import voluptuous as vol
from . import DOMAIN, cloud
from .const import (
CLOUD_BASE_URL,
CONF_CLOUD_INSTANCE,
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN,
CONF_CLOUD_INSTANCE_ID,
CONF_CLOUD_INSTANCE_OTP,
CONF_CLOUD_INSTANCE_PASSWORD,
CONF_CONNECTION_TYPE,
CONF_ENTRY_ALIASES,
CONF_FILTER,
CONF_FILTER_SOURCE,
CONF_LABEL,
CONF_LINKED_PLATFORMS,
CONF_SKILL,
CONF_USER_ID,
ConnectionType,
EntityFilterSource,
)
from .helpers import SmartHomePlatform
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigFlowContext # noqa: F401
from . import YandexSmartHome
_LOGGER = logging.getLogger(__name__)
DEFAULT_CONFIG_ENTRY_TITLE = "Yandex Smart Home"
PRE_V1_DIRECT_CONFIG_ENTRY_TITLE = "YSH: Direct" # TODO: remove after v1.1 release
USER_NONE = "none"
class MaintenanceAction(StrEnum):
REVOKE_OAUTH_TOKENS = "revoke_oauth_tokens"
UNLINK_ALL_PLATFORMS = "unlink_all_platforms"
RESET_CLOUD_INSTANCE_CONNECTION_TOKEN = "reset_cloud_instance_connection_token"
TRANSFER_ENTITY_FILTER_FROM_YAML = "transfer_entity_filter_from_yaml"
CONNECTION_TYPE_SELECTOR = SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.LIST,
translation_key=CONF_CONNECTION_TYPE,
options=[
SelectOptionDict(value=ConnectionType.CLOUD, label=ConnectionType.CLOUD),
SelectOptionDict(value=ConnectionType.CLOUD_PLUS, label=ConnectionType.CLOUD_PLUS),
SelectOptionDict(value=ConnectionType.DIRECT, label=ConnectionType.DIRECT),
],
),
)
PLATFORM_SELECTOR = SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.LIST,
translation_key=CONF_PLATFORM,
options=[
SelectOptionDict(value=SmartHomePlatform.YANDEX, label=SmartHomePlatform.YANDEX),
SelectOptionDict(value=SmartHomePlatform.VK, label=SmartHomePlatform.VK),
],
),
)
FILTER_SOURCE_SELECTOR = SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.LIST,
translation_key=CONF_FILTER_SOURCE,
options=[
SelectOptionDict(value=EntityFilterSource.CONFIG_ENTRY, label=EntityFilterSource.CONFIG_ENTRY),
SelectOptionDict(
value=EntityFilterSource.GET_FROM_CONFIG_ENTRY, label=EntityFilterSource.GET_FROM_CONFIG_ENTRY
),
SelectOptionDict(value=EntityFilterSource.LABEL, label=EntityFilterSource.LABEL),
SelectOptionDict(value=EntityFilterSource.YAML, label=EntityFilterSource.YAML),
],
),
)
class BaseFlowHandler(FlowHandler["ConfigFlowContext", ConfigFlowResult]):
"""Handle shared steps between config and options flow for Yandex Smart Home."""
def __init__(self) -> None:
"""Initialize a flow handler."""
self._options: ConfigType = {}
self._data: ConfigType = {}
self._entry: ConfigEntry | None = None
super().__init__()
async def _async_step_skill_direct(
self, platform: SmartHomePlatform, user_input: ConfigType | None = None
) -> ConfigFlowResult:
"""Choose skill settings for direct connection."""
errors = {}
description_placeholders = {"external_url": self._get_external_url()}
entry_skill = self._options.get(CONF_SKILL, {})
if DOMAIN not in self.hass.data:
await async_setup_component(self.hass, DOMAIN, {}) # expose http endpoints for skill validation
if user_input is not None:
if existed_entry := self._get_direct_connection_entry(
platform=platform,
user_id=user_input[CONF_USER_ID],
):
description_placeholders["entry_title"] = existed_entry.title
errors["base"] = "already_configured"
else:
self._options[CONF_SKILL] = user_input
if self._entry:
if user_input[CONF_ID] != entry_skill.get(CONF_ID) or user_input[CONF_USER_ID] != entry_skill.get(
CONF_USER_ID
):
self._data[CONF_LINKED_PLATFORMS] = []
self.hass.config_entries.async_update_entry(
self._entry,
title=await async_config_entry_title(self.hass, self._data, self._options),
data=self._data,
)
return await self.async_step_done()
return await self.async_step_expose_settings()
data_schema = vol.Schema(
{
vol.Required(CONF_USER_ID, default=entry_skill.get(CONF_USER_ID)): await _async_get_user_selector(
self.hass, mode=SelectSelectorMode.DROPDOWN, required=True
),
vol.Required(CONF_ID, default=entry_skill.get(CONF_ID)): TextSelector(),
vol.Required(CONF_TOKEN, default=entry_skill.get(CONF_TOKEN)): TextSelector(),
},
)
if platform == SmartHomePlatform.VK:
data_schema = vol.Schema(
{
vol.Required(CONF_USER_ID, default=entry_skill.get(CONF_USER_ID)): await _async_get_user_selector(
self.hass, mode=SelectSelectorMode.DROPDOWN, required=True
),
vol.Required(CONF_ID, default=entry_skill.get(CONF_ID)): TextSelector(),
},
)
return self.async_show_form(
step_id=f"skill_{platform}_direct",
data_schema=data_schema,
errors=errors,
description_placeholders=description_placeholders,
)
async def async_step_skill_yandex_direct(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose skill settings for direct connection to the Yandex Smart Home platform."""
return await self._async_step_skill_direct(SmartHomePlatform.YANDEX, user_input)
async def async_step_skill_vk_direct(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose skill settings for direct connection to the VK Smart Home platform."""
return await self._async_step_skill_direct(SmartHomePlatform.VK, user_input)
async def _async_step_skill_cloud_plus(
self, platform: SmartHomePlatform, user_input: ConfigType | None = None
) -> ConfigFlowResult:
"""Choose skill settings for cloud plus connection."""
errors: dict[str, str] = {}
description_placeholders = {
"cloud_base_url": CLOUD_BASE_URL,
"instance_id": self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
}
entry_skill = self._options.get(CONF_SKILL, {})
if user_input is not None:
self._options[CONF_SKILL] = user_input
if self._entry:
if user_input[CONF_ID] != entry_skill.get(CONF_ID):
self._data[CONF_LINKED_PLATFORMS] = []
if user_input[CONF_ID] != entry_skill.get(CONF_ID) or user_input[CONF_NAME] != entry_skill.get(
CONF_NAME
):
self.hass.config_entries.async_update_entry(
self._entry,
title=await async_config_entry_title(self.hass, self._data, self._options),
data=self._data,
)
return await self.async_step_done()
return await self.async_step_expose_settings()
data_schema = vol.Schema(
{
vol.Required(CONF_NAME, default=entry_skill.get(CONF_NAME)): TextSelector(),
vol.Required(CONF_ID, default=entry_skill.get(CONF_ID)): TextSelector(),
vol.Required(CONF_TOKEN, default=entry_skill.get(CONF_TOKEN)): TextSelector(),
}
)
if platform == SmartHomePlatform.VK:
data_schema = vol.Schema(
{
vol.Required(CONF_NAME, default=entry_skill.get(CONF_NAME)): TextSelector(),
vol.Required(CONF_ID, default=entry_skill.get(CONF_ID)): TextSelector(),
}
)
return self.async_show_form(
step_id=f"skill_{platform}_cloud_plus",
data_schema=data_schema,
errors=errors,
description_placeholders=description_placeholders,
)
async def async_step_skill_yandex_cloud_plus(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose skill settings for cloud plus connection to the Yandex Smart Home platform."""
return await self._async_step_skill_cloud_plus(SmartHomePlatform.YANDEX, user_input)
async def async_step_skill_vk_cloud_plus(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose skill settings for cloud plus connection to the VK Smart Home platform."""
return await self._async_step_skill_cloud_plus(SmartHomePlatform.VK, user_input)
async def async_step_expose_settings(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose entity expose settings."""
if user_input is not None:
self._options.update(user_input)
match user_input[CONF_FILTER_SOURCE]:
case EntityFilterSource.CONFIG_ENTRY:
return await self.async_step_include_entities()
case EntityFilterSource.GET_FROM_CONFIG_ENTRY:
return await self.async_step_update_filter()
case EntityFilterSource.LABEL:
return await self.async_step_choose_label()
return await self.async_step_done()
return self.async_show_form(
step_id="expose_settings",
data_schema=vol.Schema(
{
vol.Required(
CONF_FILTER_SOURCE,
default=self._options.get(CONF_FILTER_SOURCE, EntityFilterSource.CONFIG_ENTRY),
): FILTER_SOURCE_SELECTOR,
vol.Required(
CONF_ENTRY_ALIASES,
default=self._options.get(CONF_ENTRY_ALIASES, True),
): BooleanSelector(),
}
),
)
async def async_step_update_filter(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose a config entry from which the filter will be copied."""
if user_input is not None:
if user_input.get(CONF_FILTER_SOURCE) is True:
return await self.async_step_expose_settings()
if entry := self.hass.config_entries.async_get_entry(user_input.get(CONF_ID, "")):
self._options.update(
{
CONF_FILTER_SOURCE: EntityFilterSource.CONFIG_ENTRY,
CONF_FILTER: entry.options[CONF_FILTER],
}
)
return await self.async_step_include_entities()
config_entries = [
entry
for entry in self.hass.config_entries.async_entries(DOMAIN)
if CONF_FILTER in entry.options and (not self._entry or self._entry.entry_id != entry.entry_id)
]
if not config_entries:
data_schema = None
if not self._entry:
data_schema = vol.Schema({vol.Optional(CONF_FILTER_SOURCE): BooleanSelector()})
return self.async_show_form(
step_id="update_filter",
data_schema=data_schema,
errors={"base": "missing_config_entry"},
)
return self.async_show_form(
step_id="update_filter",
data_schema=vol.Schema(
{
vol.Required(CONF_ID): SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.LIST,
options=[
SelectOptionDict(value=entry.entry_id, label=entry.title) for entry in config_entries
],
),
)
}
),
)
async def async_step_include_entities(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose entities that should be exposed."""
errors = {}
entities: set[str] = set()
if entity_filter_config := self._options.get(CONF_FILTER):
entities.update(entity_filter_config.get(CONF_INCLUDE_ENTITIES, []))
if len(entity_filter_config) > 1 or CONF_INCLUDE_ENTITIES not in entity_filter_config:
entity_filter: EntityFilter = FILTER_SCHEMA(entity_filter_config)
if not entity_filter.empty_filter:
entities.update([s.entity_id for s in self.hass.states.async_all() if entity_filter(s.entity_id)])
if user_input is not None:
if user_input[CONF_ENTITIES]:
self._options[CONF_FILTER] = {CONF_INCLUDE_ENTITIES: user_input[CONF_ENTITIES]}
return await self.async_step_done()
else:
errors["base"] = "entities_not_selected"
entities.clear()
return self.async_show_form(
step_id="include_entities",
data_schema=vol.Schema(
{
vol.Required(CONF_ENTITIES, default=sorted(entities)): selector.EntitySelector(
selector.EntitySelectorConfig(multiple=True)
)
}
),
errors=errors,
)
async def async_step_choose_label(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose a label that should be used as filter for entities."""
if user_input is not None:
self._options.update(user_input)
return await self.async_step_done()
return self.async_show_form(
step_id="choose_label",
data_schema=vol.Schema(
{
vol.Required(CONF_LABEL, default=self._options.get(CONF_LABEL, "")): LabelSelector(
LabelSelectorConfig(multiple=False),
)
}
),
)
async def async_step_done(self, _: ConfigType | None = None) -> ConfigFlowResult:
"""Finish the flow."""
raise NotImplementedError
@callback
def _get_direct_connection_entry(self, platform: SmartHomePlatform, user_id: str) -> ConfigEntry | None:
"""Return already configured config entry with direct connection."""
for entry in self.hass.config_entries.async_entries(DOMAIN):
if self._entry and self._entry.entry_id == entry.entry_id:
continue
if CONF_SKILL in entry.options:
if (
ConnectionType.DIRECT == entry.data[CONF_CONNECTION_TYPE]
and platform == entry.data[CONF_PLATFORM]
and user_id == entry.options[CONF_SKILL][CONF_USER_ID]
):
return entry
return None
def _get_external_url(self) -> str:
"""Return external URL or abort the flow."""
try:
return network.get_url(self.hass, allow_internal=False)
except network.NoURLAvailableError:
raise AbortFlow("missing_external_url")
class ConfigFlowHandler(BaseFlowHandler, ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Yandex Smart Home."""
VERSION = 6
def __init__(self) -> None:
"""Initialize a config flow handler."""
super().__init__()
self._data: ConfigType = {}
async def async_step_user(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
if user_input is not None:
return await self.async_step_connection_type()
return self.async_show_form(step_id="user")
async def async_step_connection_type(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose connection type."""
errors = {}
if user_input is not None:
self._data.update(user_input)
if user_input[CONF_CONNECTION_TYPE] == ConnectionType.CLOUD:
try:
instance = await cloud.register_instance(self.hass)
self._data[CONF_CLOUD_INSTANCE] = {
CONF_CLOUD_INSTANCE_ID: instance.id,
CONF_CLOUD_INSTANCE_PASSWORD: instance.password,
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN: instance.connection_token,
}
except (ClientConnectorError, ClientResponseError):
errors["base"] = "cannot_connect"
_LOGGER.exception("Failed to register instance in Yandex Smart Home cloud")
if not errors:
match user_input[CONF_CONNECTION_TYPE]:
case ConnectionType.DIRECT:
return await self.async_step_platform_direct()
case ConnectionType.CLOUD_PLUS:
return await self.async_step_platform_cloud_plus()
return await self.async_step_expose_settings()
return self.async_show_form(
step_id="connection_type",
data_schema=vol.Schema(
{vol.Required(CONF_CONNECTION_TYPE, default=ConnectionType.CLOUD): CONNECTION_TYPE_SELECTOR}
),
errors=errors,
)
async def async_step_platform_direct(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose smart home platform for direct connection."""
if user_input is not None:
self._data.update(user_input)
step_fn = getattr(self, f"async_step_skill_{self._data[CONF_PLATFORM]}_direct")
return cast(ConfigFlowResult, await step_fn())
return self.async_show_form(
step_id="platform_direct",
description_placeholders={"external_url": self._get_external_url()},
data_schema=vol.Schema({vol.Required(CONF_PLATFORM): PLATFORM_SELECTOR}),
)
async def async_step_platform_cloud_plus(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose smart home platform for cloud p connection."""
errors = {}
if user_input is not None:
self._data.update(user_input)
try:
instance = await cloud.register_instance(self.hass, SmartHomePlatform(user_input[CONF_PLATFORM]))
self._data[CONF_CLOUD_INSTANCE] = {
CONF_CLOUD_INSTANCE_ID: instance.id,
CONF_CLOUD_INSTANCE_PASSWORD: instance.password,
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN: instance.connection_token,
}
except (ClientConnectorError, ClientResponseError):
errors["base"] = "cannot_connect"
_LOGGER.exception("Failed to register instance in Yandex Smart Home cloud")
if not errors:
step_fn = getattr(self, f"async_step_skill_{self._data[CONF_PLATFORM]}_cloud_plus")
return cast(ConfigFlowResult, await step_fn())
return self.async_show_form(
step_id="platform_cloud_plus",
data_schema=vol.Schema({vol.Required(CONF_PLATFORM): PLATFORM_SELECTOR}),
errors=errors,
)
async def async_step_done(self, _: ConfigType | None = None) -> ConfigFlowResult:
"""Finish the flow."""
description = self._data[CONF_CONNECTION_TYPE]
description_placeholders: dict[str, str] = self._data.get(CONF_CLOUD_INSTANCE, {}).copy()
if self._data[CONF_CONNECTION_TYPE] in (ConnectionType.DIRECT, ConnectionType.CLOUD_PLUS):
description += f"_{self._data[CONF_PLATFORM]}"
if self._data[CONF_CONNECTION_TYPE] == ConnectionType.CLOUD_PLUS:
description_placeholders[CONF_SKILL] = self._options[CONF_SKILL][CONF_NAME]
if self._data[CONF_CONNECTION_TYPE] in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
description_placeholders[CONF_CLOUD_INSTANCE_OTP] = "-"
try:
description_placeholders[CONF_CLOUD_INSTANCE_OTP] = await cloud.get_instance_otp(
self.hass,
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN],
)
except Exception:
_LOGGER.exception("Failed to get one time password for cloud connection")
return self.async_create_entry(
title=await async_config_entry_title(self.hass, self._data, self._options),
description=description,
description_placeholders=description_placeholders,
data=self._data,
options=self._options,
)
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(OptionsFlow, BaseFlowHandler):
"""Handle a options flow for Yandex Smart Home."""
def __init__(self, entry: ConfigEntry):
"""Initialize an options flow handler."""
super().__init__()
self._entry: ConfigEntry = entry
self._data: ConfigType = entry.data.copy()
self._options: ConfigType = entry.options.copy()
async def async_step_init(self, _: ConfigType | None = None) -> ConfigFlowResult:
"""Show menu."""
options = ["expose_settings"]
match self._data[CONF_CONNECTION_TYPE]:
case ConnectionType.CLOUD:
options += ["cloud_credentials", "context_user"]
case ConnectionType.CLOUD_PLUS:
options += ["cloud_credentials", f"skill_{self._data[CONF_PLATFORM]}_cloud_plus", "context_user"]
case ConnectionType.DIRECT:
options += [f"skill_{self._data[CONF_PLATFORM]}_direct"]
options += ["maintenance"]
return self.async_show_menu(step_id="init", menu_options=options)
async def async_step_cloud_credentials(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Show cloud connection credentials."""
errors = {}
if user_input is not None:
return await self.async_step_init()
description_placeholders = {
CONF_SKILL: "Yaha Cloud",
CONF_CLOUD_INSTANCE_ID: self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
CONF_CLOUD_INSTANCE_PASSWORD: self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_PASSWORD],
CONF_CLOUD_INSTANCE_OTP: "-",
}
if self._data[CONF_CONNECTION_TYPE] == ConnectionType.CLOUD_PLUS:
description_placeholders[CONF_SKILL] = self._options[CONF_SKILL][CONF_NAME]
try:
description_placeholders[CONF_CLOUD_INSTANCE_OTP] = await cloud.get_instance_otp(
self.hass,
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN],
)
except Exception:
errors["base"] = "cannot_connect"
_LOGGER.exception("Failed to get one time password for cloud connection")
return self.async_show_form(
step_id="cloud_credentials", description_placeholders=description_placeholders, errors=errors
)
async def async_step_context_user(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Choose user for a service calls context."""
if user_input is not None:
if user_input[CONF_USER_ID] == USER_NONE:
self._options.pop(CONF_USER_ID, None)
else:
self._options.update(user_input)
return await self.async_step_done()
return self.async_show_form(
step_id="context_user",
data_schema=vol.Schema(
{
vol.Required(
CONF_USER_ID, default=self._options.get(CONF_USER_ID, USER_NONE)
): await _async_get_user_selector(self.hass)
}
),
)
async def async_step_maintenance(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
"""Show maintenance actions."""
errors: dict[str, str] = {}
description_placeholders = {}
component: YandexSmartHome = self.hass.data[DOMAIN]
entity_filter = component.get_entity_filter_from_yaml()
if user_input is not None:
if user_input.get(MaintenanceAction.REVOKE_OAUTH_TOKENS):
match self._data[CONF_CONNECTION_TYPE]:
case ConnectionType.CLOUD:
try:
await cloud.revoke_oauth_tokens(
self.hass,
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN],
)
except Exception as e:
errors[MaintenanceAction.REVOKE_OAUTH_TOKENS] = "unknown"
description_placeholders["error"] = str(e)
case ConnectionType.DIRECT:
errors[MaintenanceAction.REVOKE_OAUTH_TOKENS] = "manual_revoke_oauth_tokens"
if user_input.get(MaintenanceAction.UNLINK_ALL_PLATFORMS):
self._data[CONF_LINKED_PLATFORMS] = []
self.hass.config_entries.async_update_entry(self._entry, data=self._data)
if user_input.get(MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN):
try:
instance = await cloud.reset_connection_token(
self.hass,
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN],
)
self._data[CONF_CLOUD_INSTANCE] = {
CONF_CLOUD_INSTANCE_ID: instance.id,
CONF_CLOUD_INSTANCE_PASSWORD: self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_PASSWORD],
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN: instance.connection_token,
}
self.hass.config_entries.async_update_entry(self._entry, data=self._data)
except Exception as e:
errors[MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN] = "unknown"
description_placeholders["error"] = str(e)
if user_input.get(MaintenanceAction.TRANSFER_ENTITY_FILTER_FROM_YAML):
entity_ids: set[str] = set()
if entity_filter:
for state in self.hass.states.async_all():
if entity_filter(state.entity_id):
entity_ids.add(state.entity_id)
match self._options[CONF_FILTER_SOURCE]:
case EntityFilterSource.CONFIG_ENTRY:
entity_ids.update(self._options[CONF_FILTER][CONF_INCLUDE_ENTITIES])
self._options[CONF_FILTER] = {CONF_INCLUDE_ENTITIES: sorted(entity_ids)}
case EntityFilterSource.LABEL:
for entity_id in entity_ids:
registry = er.async_get(self.hass)
if entity := registry.async_get(entity_id):
registry.async_update_entity(
entity.entity_id,
labels=entity.labels | {self._options[CONF_LABEL]},
)
if not errors:
return await self.async_step_done()
actions = [MaintenanceAction.REVOKE_OAUTH_TOKENS, MaintenanceAction.UNLINK_ALL_PLATFORMS]
if self._data[CONF_CONNECTION_TYPE] in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
actions += [MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN]
if entity_filter and self._options[CONF_FILTER_SOURCE] in [
EntityFilterSource.CONFIG_ENTRY,
EntityFilterSource.LABEL,
]:
actions += [MaintenanceAction.TRANSFER_ENTITY_FILTER_FROM_YAML]
return self.async_show_form(
step_id="maintenance",
data_schema=vol.Schema({vol.Optional(action.value): BooleanSelector() for action in actions}),
errors=errors,
description_placeholders=description_placeholders,
)
async def async_step_done(self, _: ConfigType | None = None) -> ConfigFlowResult:
"""Finish the flow."""
return self.async_create_entry(data=self._options)
async def _async_get_user_selector(
hass: HomeAssistant, mode: SelectSelectorMode = SelectSelectorMode.LIST, required: bool = False
) -> SelectSelector:
"""Return user selector."""
users: list[SelectOptionDict] = []
if not required:
users.append(SelectOptionDict(value=USER_NONE, label=USER_NONE))
for user in await hass.auth.async_get_users():
if any(gr.id == GROUP_ID_READ_ONLY for gr in user.groups):
continue
users.append(SelectOptionDict(value=user.id, label=user.name or user.id))
return SelectSelector(
SelectSelectorConfig(
mode=mode,
translation_key=CONF_USER_ID,
options=users,
),
)
async def async_config_entry_title(hass: HomeAssistant, data: ConfigType, options: ConfigType) -> str:
"""Return config entry title."""
if data.get(CONF_CONNECTION_TYPE) == ConnectionType.CLOUD:
instance_id = data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID]
return f"Yaha Cloud ({instance_id[:8]})"
title = DEFAULT_CONFIG_ENTRY_TITLE
connection_type = ""
match data.get(CONF_CONNECTION_TYPE):
case ConnectionType.CLOUD_PLUS:
connection_type = "Cloud Plus"
case ConnectionType.DIRECT:
connection_type = "Direct"
match data.get(CONF_PLATFORM):
case SmartHomePlatform.YANDEX:
title = f"Yandex Smart Home: {connection_type}"
case SmartHomePlatform.VK:
title = f"Marusia: {connection_type}"
if skill := options.get(CONF_SKILL):
parts: list[str] = []
if user := await hass.auth.async_get_user(skill.get(CONF_USER_ID, "")):
parts.append(user.name or user.id[:6])
if skill_id := skill.get(CONF_ID, ""):
parts.append(skill_id[:8])
if parts:
title += f' ({" / ".join(parts)})'
return title

View File

@@ -0,0 +1,468 @@
"""Helpers for config validation using voluptuous."""
from contextlib import suppress
import logging
from typing import Any
from homeassistant.components.event import EventDeviceClass
from homeassistant.components.sensor.const import SensorDeviceClass
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_ROOM, CONF_STATE_TEMPLATE, CONF_TYPE
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.color import RGBColor
import voluptuous as vol
from .color import ColorName, rgb_to_int
from .const import (
CONF_BACKLIGHT_ENTITY_ID,
CONF_BETA,
CONF_CLOUD_STREAM,
CONF_COLOR_PROFILE,
CONF_ENTITY_CONFIG,
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE,
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID,
CONF_ENTITY_CUSTOM_MODE_SET_MODE,
CONF_ENTITY_CUSTOM_MODES,
CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE,
CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE,
CONF_ENTITY_CUSTOM_RANGE_SET_VALUE,
CONF_ENTITY_CUSTOM_RANGES,
CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF,
CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON,
CONF_ENTITY_CUSTOM_TOGGLES,
CONF_ENTITY_EVENT_MAP,
CONF_ENTITY_MODE_MAP,
CONF_ENTITY_PROPERTIES,
CONF_ENTITY_PROPERTY_ATTRIBUTE,
CONF_ENTITY_PROPERTY_ENTITY,
CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT,
CONF_ENTITY_PROPERTY_TYPE,
CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT,
CONF_ENTITY_PROPERTY_VALUE_TEMPLATE,
CONF_ENTITY_RANGE,
CONF_ENTITY_RANGE_MAX,
CONF_ENTITY_RANGE_MIN,
CONF_ENTITY_RANGE_PRECISION,
CONF_ERROR_CODE_TEMPLATE,
CONF_FEATURES,
CONF_FILTER,
CONF_NOTIFIER,
CONF_NOTIFIER_OAUTH_TOKEN,
CONF_NOTIFIER_SKILL_ID,
CONF_NOTIFIER_USER_ID,
CONF_PRESSURE_UNIT,
CONF_SETTINGS,
CONF_SLOW,
CONF_STATE_UNKNOWN,
CONF_SUPPORT_SET_CHANNEL,
CONF_TURN_OFF,
CONF_TURN_ON,
MediaPlayerFeature,
PropertyInstanceType,
)
from .schema import (
ColorScene,
ColorSettingCapabilityInstance,
DeviceType,
EventPropertyInstance,
FloatPropertyInstance,
ModeCapabilityInstance,
ModeCapabilityMode,
RangeCapabilityInstance,
ToggleCapabilityInstance,
)
from .schema.property_event import get_supported_events_for_instance
from .unit_conversion import UnitOfPressure, UnitOfTemperature
_LOGGER = logging.getLogger(__name__)
def property_type(value: str) -> str:
if value.startswith(f"{PropertyInstanceType.EVENT}."):
instance = value.split(".", 1)[1]
try:
EventPropertyInstance(instance)
return value
except ValueError:
raise vol.Invalid(
f"Event property type '{instance}' is not supported, "
f"see valid event types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/event/#type"
)
if value.startswith(f"{PropertyInstanceType.FLOAT}."):
instance = value.split(".", 1)[1]
try:
FloatPropertyInstance(instance)
return value
except ValueError:
raise vol.Invalid(
f"Float property type '{instance}' is not supported, "
f"see valid float types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#type"
)
for enum in [FloatPropertyInstance, EventPropertyInstance]:
with suppress(ValueError):
return enum(value).value
device_class_to_float_instance = {
SensorDeviceClass.ATMOSPHERIC_PRESSURE.value: FloatPropertyInstance.PRESSURE,
SensorDeviceClass.CO2.value: FloatPropertyInstance.CO2_LEVEL,
SensorDeviceClass.CURRENT.value: FloatPropertyInstance.AMPERAGE,
SensorDeviceClass.ILLUMINANCE.value: FloatPropertyInstance.ILLUMINATION,
SensorDeviceClass.PM1.value: FloatPropertyInstance.PM1_DENSITY,
SensorDeviceClass.PM10.value: FloatPropertyInstance.PM10_DENSITY,
SensorDeviceClass.PM25.value: FloatPropertyInstance.PM2_5_DENSITY,
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS.value: FloatPropertyInstance.TVOC,
}
with suppress(KeyError):
instance = device_class_to_float_instance[value]
return f"{PropertyInstanceType.FLOAT}.{instance}"
raise vol.Invalid(
f"Property type '{value}' is not supported, "
f"see valid types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/event/#type and "
f"https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#type"
)
def property_attributes(value: ConfigType) -> ConfigType:
"""Validate keys for property."""
entity = value.get(CONF_ENTITY_PROPERTY_ENTITY)
attribute = value.get(CONF_ENTITY_PROPERTY_ATTRIBUTE)
value_template = value.get(CONF_ENTITY_PROPERTY_VALUE_TEMPLATE)
if value_template and (entity or attribute):
raise vol.Invalid("entity/attribute and value_template are mutually exclusive")
property_type_value = value.get(CONF_ENTITY_PROPERTY_TYPE)
target_unit_of_measurement = value.get(CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT)
if target_unit_of_measurement:
try:
if property_type_value in [
FloatPropertyInstance.TEMPERATURE,
f"{PropertyInstanceType.FLOAT}.{FloatPropertyInstance.TEMPERATURE}",
]:
assert UnitOfTemperature(target_unit_of_measurement).as_property_unit
elif property_type_value in [
FloatPropertyInstance.PRESSURE,
f"{PropertyInstanceType.FLOAT}.{FloatPropertyInstance.PRESSURE}",
]:
assert UnitOfPressure(target_unit_of_measurement).as_property_unit
else:
raise ValueError
except ValueError:
raise vol.Invalid(
f"Target unit of measurement '{target_unit_of_measurement}' is not supported "
f"for {property_type_value} property, see valid values "
f"at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#property-target-unit-of-measurement"
)
return value
def mode_instance(value: str) -> str:
if value == ColorSettingCapabilityInstance.SCENE:
return value
try:
ModeCapabilityInstance(value)
except ValueError:
_LOGGER.error(
f"Mode instance '{value}' is not supported, "
f"see valid modes at https://docs.yaha-cloud.ru/v1.0.x/advanced/capabilities/mode/#instance"
)
raise vol.Invalid(f"Mode instance '{value}' is not supported")
return value
def mode(value: str) -> str:
for enum in [ModeCapabilityMode, ColorScene]:
try:
enum(value)
return value
except ValueError:
pass
_LOGGER.error(
f"Mode '{value}' is not supported, "
f"see valid modes at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance-modes.html and "
f"https://docs.yaha-cloud.ru/v1.0.x/devices/light/#scene-list"
)
raise vol.Invalid(f"Mode '{value}' is not supported")
def event_instance(value: str) -> str:
try:
EventPropertyInstance(value)
except ValueError:
_LOGGER.error(
f"Event instance '{value}' is not supported, "
f"see valid event types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/event/#event-types"
)
raise vol.Invalid(f"Event instance '{value}' is not supported")
return value
def event_map(value: dict[str, dict[str, list[str]]]) -> dict[str, dict[str, list[str]]]:
for instance, mapped_events in value.items():
supported_events = get_supported_events_for_instance(EventPropertyInstance(instance))
for event in mapped_events:
if event not in supported_events:
_LOGGER.error(
f"Event '{event}' is not supported for '{instance}' event instance, "
f"see valid event types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/event/#event-types"
)
raise vol.Invalid(f"Event '{event}' is not supported for '{instance}' event instance")
return value
def toggle_instance(value: str) -> str:
try:
ToggleCapabilityInstance(value)
except ValueError:
_LOGGER.error(
f"Toggle instance '{value}' is not supported, "
f"see valid values at https://docs.yaha-cloud.ru/v1.0.x/advanced/capabilities/toggle/#instance"
)
raise vol.Invalid(f"Toggle instance '{value}' is not supported")
return value
def range_instance(value: str) -> str:
try:
RangeCapabilityInstance(value)
except ValueError:
_LOGGER.error(
f"Range instance '{value}' is not supported, "
f"see valid values at https://docs.yaha-cloud.ru/v1.0.x/advanced/capabilities/range/#instance"
)
raise vol.Invalid(f"Range instance '{value}' is not supported")
return value
def entity_features(value: list[str]) -> list[str]:
for feature in value:
try:
MediaPlayerFeature(feature)
except ValueError:
raise vol.Invalid(f"Feature {feature} is not supported")
return value
def device_type(value: str) -> str:
if value in ("devices.types.fan", "fan"):
_LOGGER.warning(
f"Device type '{value}' is deprecated, use 'devices.types.ventilation.fan' or 'ventilation.fan' instead"
)
value = "devices.types.ventilation.fan"
try:
return str(DeviceType(value))
except ValueError:
try:
return DeviceType(f"devices.types.{value}")
except ValueError:
pass
_LOGGER.error(
f"Device type '{value}' is not supported, "
f"see valid device types at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/device-types.html"
)
raise vol.Invalid(f"Device type '{value}' is not supported")
def color_name(value: str) -> str:
try:
ColorName(value)
except ValueError:
_LOGGER.error(
f"Color name '{value}' is not supported, "
f"see valid values at https://docs.yaha-cloud.ru/v1.0.x/devices/light/#color-profile-config"
)
raise vol.Invalid(f"Color name '{value}' is not supported")
return value
def color_value(value: list[Any] | int) -> int:
if isinstance(value, (int, str)):
return int(value)
if isinstance(value, list) and len(value) == 3:
return rgb_to_int(RGBColor(*[int(v) for v in value]))
raise vol.Invalid(f"Invalid value: {value}")
def custom_capability_state(value: ConfigType) -> ConfigType:
"""Validate keys for custom capability."""
state_entity_id = value.get(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID)
state_attribute = value.get(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE)
state_template = value.get(CONF_STATE_TEMPLATE)
if state_template and (state_entity_id or state_attribute):
raise vol.Invalid("state_entity_id/state_attribute and state_template are mutually exclusive")
return value
ENTITY_PROPERTY_SCHEMA = vol.All(
cv.has_at_least_one_key(
CONF_ENTITY_PROPERTY_ENTITY,
CONF_ENTITY_PROPERTY_ATTRIBUTE,
CONF_ENTITY_PROPERTY_VALUE_TEMPLATE,
),
vol.All(
{
vol.Required(CONF_ENTITY_PROPERTY_TYPE): vol.Schema(vol.All(str, property_type)),
vol.Optional(CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_ENTITY_PROPERTY_ENTITY): cv.entity_id,
vol.Optional(CONF_ENTITY_PROPERTY_ATTRIBUTE): cv.string,
vol.Optional(CONF_ENTITY_PROPERTY_VALUE_TEMPLATE): cv.template,
},
property_attributes,
),
)
ENTITY_MODE_MAP_SCHEMA = vol.Schema(
{vol.All(cv.string, mode_instance): vol.Schema({vol.All(cv.string, mode): vol.All(cv.ensure_list, [cv.string])})}
)
ENTITY_EVENT_MAP_SCHEMA = vol.Schema(
{vol.All(cv.string, event_instance): vol.Schema({cv.string: vol.All(cv.ensure_list, [cv.string])})}
)
ENTITY_RANGE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ENTITY_RANGE_MAX): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
vol.Optional(CONF_ENTITY_RANGE_MIN): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
vol.Optional(CONF_ENTITY_RANGE_PRECISION): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
},
)
ENTITY_CUSTOM_MODE_SCHEMA = vol.Schema(
{
vol.All(cv.string, mode_instance): vol.Any(
vol.All(
{
vol.Optional(CONF_ENTITY_CUSTOM_MODE_SET_MODE): cv.SERVICE_SCHEMA,
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
},
custom_capability_state,
),
cv.boolean,
)
}
)
ENTITY_CUSTOM_RANGE_SCHEMA = vol.Schema(
{
vol.All(cv.string, range_instance): vol.Any(
vol.All(
{
vol.Optional(CONF_ENTITY_CUSTOM_RANGE_SET_VALUE): vol.Any(cv.SERVICE_SCHEMA),
vol.Optional(CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE): vol.Any(cv.SERVICE_SCHEMA),
vol.Optional(CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE): vol.Any(cv.SERVICE_SCHEMA),
vol.Optional(CONF_ENTITY_RANGE): ENTITY_RANGE_SCHEMA,
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
},
custom_capability_state,
),
cv.boolean,
)
}
)
ENTITY_CUSTOM_TOGGLE_SCHEMA = vol.Schema(
{
vol.All(cv.string, toggle_instance): vol.Any(
vol.All(
{
vol.Optional(CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON): cv.SERVICE_SCHEMA,
vol.Optional(CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF): cv.SERVICE_SCHEMA,
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
},
custom_capability_state,
),
cv.boolean,
)
}
)
ENTITY_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_ROOM): cv.string,
vol.Optional(CONF_TYPE): vol.All(cv.string, device_type),
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
vol.Optional(CONF_TURN_ON): vol.Any(cv.SERVICE_SCHEMA, cv.boolean),
vol.Optional(CONF_TURN_OFF): vol.Any(cv.SERVICE_SCHEMA, cv.boolean),
vol.Optional(CONF_DEVICE_CLASS): vol.In(EventDeviceClass.BUTTON),
vol.Optional(CONF_FEATURES): vol.All(cv.ensure_list, entity_features),
vol.Optional(CONF_ENTITY_PROPERTIES): [ENTITY_PROPERTY_SCHEMA],
vol.Optional(CONF_SUPPORT_SET_CHANNEL): cv.boolean,
vol.Optional(CONF_STATE_UNKNOWN): cv.boolean,
vol.Optional(CONF_SLOW): cv.boolean,
vol.Optional(CONF_BACKLIGHT_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_COLOR_PROFILE): cv.string,
vol.Optional(CONF_ERROR_CODE_TEMPLATE): cv.template,
vol.Optional(CONF_ENTITY_RANGE): ENTITY_RANGE_SCHEMA,
vol.Optional(CONF_ENTITY_MODE_MAP): ENTITY_MODE_MAP_SCHEMA,
vol.Optional(CONF_ENTITY_EVENT_MAP): vol.All(ENTITY_EVENT_MAP_SCHEMA, event_map),
vol.Optional(CONF_ENTITY_CUSTOM_MODES): ENTITY_CUSTOM_MODE_SCHEMA,
vol.Optional(CONF_ENTITY_CUSTOM_TOGGLES): ENTITY_CUSTOM_TOGGLE_SCHEMA,
vol.Optional(CONF_ENTITY_CUSTOM_RANGES): ENTITY_CUSTOM_RANGE_SCHEMA,
}
)
)
NOTIFIER_SCHEMA = vol.Schema(
{
vol.Required(CONF_NOTIFIER_OAUTH_TOKEN): cv.string,
vol.Required(CONF_NOTIFIER_SKILL_ID): cv.string,
vol.Required(CONF_NOTIFIER_USER_ID): cv.string,
},
)
SETTINGS_SCHEMA = vol.All(
cv.deprecated(CONF_PRESSURE_UNIT),
{
vol.Optional(CONF_PRESSURE_UNIT): cv.string,
vol.Optional(CONF_BETA): cv.boolean,
vol.Optional(CONF_CLOUD_STREAM): cv.boolean,
},
)
YANDEX_SMART_HOME_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_NOTIFIER): vol.All(cv.ensure_list, [NOTIFIER_SCHEMA]),
vol.Optional(CONF_SETTINGS): vol.All(lambda value: value or {}, SETTINGS_SCHEMA),
vol.Optional(CONF_FILTER): BASE_FILTER_SCHEMA,
vol.Optional(CONF_ENTITY_CONFIG): vol.All(lambda value: value or {}, {cv.entity_id: ENTITY_SCHEMA}),
vol.Optional(CONF_COLOR_PROFILE): vol.Schema({cv.string: {vol.All(color_name): vol.All(color_value)}}),
},
)
)

View File

@@ -0,0 +1,153 @@
"""Constants for Yandex Smart Home."""
from enum import StrEnum
DOMAIN = "yandex_smart_home"
CONF_SETTINGS = "settings"
CONF_PRESSURE_UNIT = "pressure_unit"
CONF_BETA = "beta"
CONF_CLOUD_STREAM = "cloud_stream"
CONF_CONNECTION_TYPE = "connection_type"
CONF_CLOUD_INSTANCE = "cloud_instance"
CONF_CLOUD_INSTANCE_ID = "id"
CONF_CLOUD_INSTANCE_PASSWORD = "password"
CONF_CLOUD_INSTANCE_OTP = "otp"
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN = "token"
CONF_USER_ID = "user_id"
CONF_SKILL = "skill"
CONF_COLOR_PROFILE = "color_profile"
CONF_ENTITY_CONFIG = "entity_config"
CONF_FILTER = "filter"
CONF_FILTER_SOURCE = "filter_source"
CONF_ENTRY_ALIASES = "entry_aliases"
CONF_LABEL = "label"
CONF_ADD_LABEL = "add_label"
CONF_LINKED_PLATFORMS = "linked_platforms"
CONF_TURN_ON = "turn_on"
CONF_TURN_OFF = "turn_off"
CONF_FEATURES = "features"
CONF_SUPPORT_SET_CHANNEL = "support_set_channel"
CONF_STATE_UNKNOWN = "state_unknown"
CONF_BACKLIGHT_ENTITY_ID = "backlight_entity_id"
CONF_ERROR_CODE_TEMPLATE = "error_code_template"
CONF_SLOW = "slow"
CONF_ENTITY_PROPERTY_TYPE = "type"
CONF_ENTITY_PROPERTY_ENTITY = "entity"
CONF_ENTITY_PROPERTY_ATTRIBUTE = "attribute"
CONF_ENTITY_PROPERTY_VALUE_TEMPLATE = "value_template"
CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT = "unit_of_measurement"
CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT = "target_unit_of_measurement"
CONF_ENTITY_PROPERTIES = "properties"
CONF_ENTITY_RANGE = "range"
CONF_ENTITY_RANGE_MIN = "min"
CONF_ENTITY_RANGE_MAX = "max"
CONF_ENTITY_RANGE_PRECISION = "precision"
CONF_ENTITY_MODE_MAP = "modes"
CONF_ENTITY_EVENT_MAP = "events"
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID = "state_entity_id"
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE = "state_attribute"
CONF_ENTITY_CUSTOM_MODES = "custom_modes"
CONF_ENTITY_CUSTOM_MODE_SET_MODE = "set_mode"
CONF_ENTITY_CUSTOM_TOGGLES = "custom_toggles"
CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON = "turn_on"
CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF = "turn_off"
CONF_ENTITY_CUSTOM_RANGES = "custom_ranges"
CONF_ENTITY_CUSTOM_RANGE_SET_VALUE = "set_value"
CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE = "increase_value"
CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE = "decrease_value"
ISSUE_ID_DEPRECATED_PRESSURE_UNIT = "deprecated_pressure_unit"
ISSUE_ID_DEPRECATED_YAML_NOTIFIER = "deprecated_yaml_notifier"
ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS = "deprecated_yaml_several_notifiers"
ISSUE_ID_MISSING_INTEGRATION = "missing_integration"
ISSUE_ID_MISSING_SKILL_DATA = "missing_skill_data"
ISSUE_ID_RECONNECTING_TOO_FAST = "reconnecting_too_fast"
ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND = "unexposed_entity_found_"
# Legacy
CONF_DEVICES_DISCOVERED = "devices_discovered"
CONF_NOTIFIER = "notifier"
CONF_NOTIFIER_OAUTH_TOKEN = "oauth_token"
CONF_NOTIFIER_SKILL_ID = "skill_id"
CONF_NOTIFIER_USER_ID = "user_id"
CLOUD_BASE_URL = "https://yaha-cloud.ru"
CLOUD_STREAM_BASE_URL = "https://stream.yaha-cloud.ru"
EVENT_DEVICE_ACTION = "yandex_smart_home_device_action"
ATTR_CAPABILITY = "capability"
ATTR_ERROR_CODE = "error_code"
# Additional states
STATE_NONE = "none"
STATE_NONE_UI = "-"
STATE_EMPTY = ""
STATE_CHARGING = "charging"
STATE_LOW = "low"
# Additional attributes
ATTR_CURRENT = "current"
ATTR_ILLUMINANCE = "illuminance"
ATTR_LOAD_POWER = "load_power"
ATTR_CURRENT_CONSUMPTION = "current_consumption"
ATTR_POWER = "power"
ATTR_TVOC = "total_volatile_organic_compounds"
ATTR_WATER_LEVEL = "water_level"
# Custom component Xiaomi Gateway 3
ATTR_ACTION = "action"
# Integration xiaomi_airpurifier
ATTR_TARGET_HUMIDITY = "target_humidity"
DOMAIN_XIAOMI_AIRPURIFIER = "xiaomi_miio_airpurifier"
MODEL_PREFIX_XIAOMI_AIRPURIFIER = "zhimi."
SERVICE_FAN_SET_TARGET_HUMIDITY = "fan_set_target_humidity"
# https://github.com/ClusterM/skykettle-ha/blob/c1b61c4a22693d6e2b7c2f57a989df418011f2c2/custom_components/skykettle/skykettle.py#L53
SKYKETTLE_MODE_BOIL = "Boil"
type EntityId = str
class ConnectionType(StrEnum):
"""Valid connection type."""
DIRECT = "direct"
CLOUD = "cloud"
CLOUD_PLUS = "cloud_plus"
class EntityFilterSource(StrEnum):
"""Possible sources for entity filter."""
CONFIG_ENTRY = "config_entry"
GET_FROM_CONFIG_ENTRY = "get_from_config_entry"
YAML = "yaml"
LABEL = "label"
class MediaPlayerFeature(StrEnum):
"""Media player feature that user can force enable."""
VOLUME_MUTE = "volume_mute"
VOLUME_SET = "volume_set"
NEXT_PREVIOUS_TRACK = "next_previous_track"
SELECT_SOURCE = "select_source"
TURN_ON_OFF = "turn_on_off"
PLAY_PAUSE = "play_pause"
PLAY_MEDIA = "play_media"
class PropertyInstanceType(StrEnum):
"""Property instance type for config validation."""
FLOAT = "float"
EVENT = "event"
class XGW3DeviceClass(StrEnum):
"""Device class for Xiaomi Gateway 3 custom component."""
ACTION = "action"
TVOC = "tvoc"

View File

@@ -0,0 +1,520 @@
"""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

View File

@@ -0,0 +1,35 @@
"""Diagnostics support for Yandex Smart Home."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry
from . import DOMAIN, YandexSmartHome
from .const import CONF_CLOUD_INSTANCE, CONF_SKILL
from .device import async_get_device_description, async_get_devices
async def async_get_config_entry_diagnostics(hass: HomeAssistant, config_entry: ConfigEntry) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
component: YandexSmartHome = hass.data[DOMAIN]
entry_data = component.get_entry_data(config_entry)
diag: dict[str, Any] = {
"entry": async_redact_data(config_entry.as_dict(), [CONF_CLOUD_INSTANCE, CONF_SKILL]),
"devices": {},
"issues": [i.to_json() for i in issue_registry.async_get(hass).issues.values() if i.domain == DOMAIN],
}
diag.update(component.get_diagnostics())
for device in await async_get_devices(hass, entry_data):
diag["devices"][device.id] = {
"capabilities": [c.__repr__() for c in device.get_capabilities()],
"properties": [p.__repr__() for p in device.get_properties()],
"description": await async_get_device_description(hass, device),
"state": device.query(),
}
return async_redact_data(diag, [])

View File

@@ -0,0 +1,494 @@
"""Config entry data for the Yandex Smart Home."""
import asyncio
from contextlib import suppress
from dataclasses import dataclass
from functools import cached_property
import logging
from typing import Any, Self, cast
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ID,
CONF_PLATFORM,
CONF_STATE_TEMPLATE,
CONF_TOKEN,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
STATE_UNKNOWN,
)
from homeassistant.core import CoreState, HomeAssistant, State
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_custom_components
from . import capability_custom, property_custom
from .capability_custom import CustomCapability, get_custom_capability
from .cloud import CloudManager
from .color import ColorProfiles
from .const import (
CONF_BACKLIGHT_ENTITY_ID,
CONF_CLOUD_INSTANCE,
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN,
CONF_CLOUD_INSTANCE_ID,
CONF_CLOUD_STREAM,
CONF_COLOR_PROFILE,
CONF_CONNECTION_TYPE,
CONF_ENTITY_CUSTOM_MODES,
CONF_ENTITY_CUSTOM_RANGES,
CONF_ENTITY_CUSTOM_TOGGLES,
CONF_ENTITY_PROPERTIES,
CONF_ENTITY_PROPERTY_ENTITY,
CONF_ENTRY_ALIASES,
CONF_FILTER_SOURCE,
CONF_LABEL,
CONF_LINKED_PLATFORMS,
CONF_NOTIFIER,
CONF_PRESSURE_UNIT,
CONF_SETTINGS,
CONF_SKILL,
CONF_USER_ID,
DOMAIN,
ISSUE_ID_DEPRECATED_PRESSURE_UNIT,
ISSUE_ID_DEPRECATED_YAML_NOTIFIER,
ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS,
ISSUE_ID_MISSING_SKILL_DATA,
ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND,
ConnectionType,
EntityFilterSource,
EntityId,
)
from .device import BacklightCapability, DeviceId, StateCapability
from .helpers import APIError, CacheStore, SmartHomePlatform
from .notifier import CloudNotifier, Notifier, NotifierConfig, YandexDirectNotifier
from .property import StateProperty
from .property_custom import CustomProperty, get_custom_property, get_event_platform_custom_property_type
from .schema import CapabilityType, OnOffCapabilityInstance
_LOGGER = logging.getLogger(__name__)
@dataclass
class SkillConfig:
"""Class to hold configuration of a smart home skill."""
user_id: str
id: str
token: str | None
class ConfigEntryData:
"""Class to hold config entry data."""
cache: CacheStore
_entity_registry: er.EntityRegistry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
yaml_config: ConfigType | None = None,
entity_config: ConfigType | None = None,
entity_filter: EntityFilter | None = None,
):
"""Initialize."""
self.entry = entry
self.entity_config: ConfigType = entity_config or {}
self.unexposed_entities: set[str] = set()
self._yaml_config: ConfigType = yaml_config or {}
self.component_version = "unknown"
self._hass = hass
self._entity_filter = entity_filter
self._cloud_manager: CloudManager | None = None
self._notifiers: list[Notifier] = []
async def async_setup(self) -> Self:
"""Set up the config entry data."""
self.cache = CacheStore(self._hass)
await self.cache.async_load()
self._entity_registry = er.async_get(self._hass)
with suppress(KeyError):
integration = (await async_get_custom_components(self._hass))[DOMAIN]
self.component_version = str(integration.version)
if self.connection_type in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
await self._async_setup_cloud_connection()
if self._hass.state == CoreState.running:
await self._async_setup_notifiers()
else:
self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self._async_setup_notifiers)
if self._yaml_config.get(CONF_SETTINGS, {}).get(CONF_PRESSURE_UNIT):
ir.async_create_issue(
self._hass,
DOMAIN,
ISSUE_ID_DEPRECATED_PRESSURE_UNIT,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_ID_DEPRECATED_PRESSURE_UNIT,
learn_more_url="https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#unit-conversion",
)
else:
ir.async_delete_issue(self._hass, DOMAIN, "deprecated_pressure_unit")
if count := len(self._yaml_config.get(CONF_NOTIFIER, [])):
issue_id = ISSUE_ID_DEPRECATED_YAML_NOTIFIER if count == 1 else ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS
ir.async_create_issue(
self._hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=issue_id,
learn_more_url="https://docs.yaha-cloud.ru/v1.0.x/breaking-changes/#v1-notifier",
)
else:
ir.async_delete_issue(self._hass, DOMAIN, ISSUE_ID_DEPRECATED_YAML_NOTIFIER)
ir.async_delete_issue(self._hass, DOMAIN, ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS)
return self
async def async_unload(self) -> None:
"""Unload the config entry data."""
tasks = [asyncio.create_task(n.async_unload()) for n in self._notifiers]
if self._cloud_manager:
tasks.append(asyncio.create_task(self._cloud_manager.async_disconnect()))
if tasks:
await asyncio.wait(tasks)
return None
async def async_get_context_user_id(self) -> str | None:
"""Return user id for service calls (cloud connection only)."""
if user_id := self.entry.options.get(CONF_USER_ID):
if user := await self._hass.auth.async_get_user(user_id):
return user.id
return None
@cached_property
def is_reporting_states(self) -> bool:
"""Test if the config entry can report state changes."""
if self.connection_type == ConnectionType.CLOUD:
return True
if self.platform == SmartHomePlatform.VK:
return False
return self.skill is not None
@property
def use_cloud_stream(self) -> bool:
"""Test if the config entry use video streaming through the cloud."""
if self.connection_type in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
return True
settings = self._yaml_config.get(CONF_SETTINGS, {})
return bool(settings.get(CONF_CLOUD_STREAM))
@property
def use_entry_aliases(self) -> bool:
"""Test if device or area entry aliases should be used for device or room name."""
return bool(self.entry.options.get(CONF_ENTRY_ALIASES, True))
@property
def connection_type(self) -> ConnectionType:
"""Return connection type."""
return ConnectionType(str(self.entry.data.get(CONF_CONNECTION_TYPE)))
@property
def cloud_instance_id(self) -> str:
"""Return cloud instance id."""
if self.connection_type in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
return str(self.entry.data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID])
raise ValueError("Config entry uses direct connection")
@property
def cloud_connection_token(self) -> str:
"""Return cloud connection token."""
if self.connection_type in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
return str(self.entry.data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN])
raise ValueError("Config entry uses direct connection")
@property
def platform(self) -> SmartHomePlatform | None:
"""Return smart home platform."""
if self.connection_type == ConnectionType.CLOUD:
return None
return SmartHomePlatform(self.entry.data[CONF_PLATFORM])
@cached_property
def skill(self) -> SkillConfig | None:
"""Return configuration for the skill."""
config = self.entry.options.get(CONF_SKILL)
if not config:
return None
user_id = self.cloud_instance_id if self.connection_type == ConnectionType.CLOUD_PLUS else config[CONF_USER_ID]
return SkillConfig(user_id=user_id, id=config[CONF_ID], token=config.get(CONF_TOKEN))
@property
def color_profiles(self) -> ColorProfiles:
"""Return color profiles."""
return ColorProfiles.from_dict(self._yaml_config.get(CONF_COLOR_PROFILE, {}))
def get_entity_config(self, entity_id: str) -> ConfigType:
"""Return configuration for the entity."""
return cast(ConfigType, self.entity_config.get(entity_id, {}))
def should_expose(self, entity_id: str) -> bool:
"""Test if the entity should be exposed."""
if self.entry.options.get(CONF_FILTER_SOURCE) == EntityFilterSource.LABEL:
entity_entry = self._entity_registry.async_get(entity_id)
if not entity_entry:
return False
return self.entry.options[CONF_LABEL] in entity_entry.labels
if self._entity_filter and not self._entity_filter.empty_filter:
return self._entity_filter(entity_id)
return False
@property
def linked_platforms(self) -> set[SmartHomePlatform]:
"""Return list of smart home platforms linked with the config entry."""
platforms: set[SmartHomePlatform] = set()
for platform in self.entry.data.get(CONF_LINKED_PLATFORMS, []):
try:
platforms.add(SmartHomePlatform(platform))
except ValueError:
_LOGGER.error(f"Unsupported platform: {platform}")
return platforms
def link_platform(self, platform: SmartHomePlatform) -> None:
"""Link smart home platform to this config entry (device discovery)."""
if platform in self.linked_platforms:
return
data = self.entry.data.copy()
data[CONF_LINKED_PLATFORMS] = data.get(CONF_LINKED_PLATFORMS, []) + [platform]
self._hass.config_entries.async_update_entry(self.entry, data=data)
def unlink_platform(self, platform: SmartHomePlatform) -> None:
"""Unlink smart home platform."""
data = self.entry.data.copy()
data[CONF_LINKED_PLATFORMS] = list(self.linked_platforms - {platform})
self._hass.config_entries.async_update_entry(self.entry, data=data)
def mark_entity_unexposed(self, entity_id: str) -> None:
"""Create an issue for unexposed entity."""
_LOGGER.warning(
f"Device for {entity_id} exists in Yandex, but entity {entity_id} not exposed via integration settings. "
f"Please either expose the entity or delete the device from Yandex."
)
self.unexposed_entities.add(entity_id)
issue_id = ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + self.entry.options[CONF_FILTER_SOURCE]
formatted_entities: list[str] = []
for entity_id in sorted(self.unexposed_entities):
if self.entry.options[CONF_FILTER_SOURCE] == EntityFilterSource.YAML:
formatted_entities.append(f"* `- {entity_id}`")
else:
state = self._hass.states.get(entity_id) or State(entity_id, STATE_UNKNOWN)
formatted_entities.append(f"* `{state.entity_id}` ({state.name})")
ir.async_create_issue(
self._hass,
DOMAIN,
issue_id,
is_fixable=self.entry.options[CONF_FILTER_SOURCE] != EntityFilterSource.YAML,
is_persistent=True,
severity=ir.IssueSeverity.WARNING,
data={"entry_id": self.entry.entry_id},
translation_key=issue_id,
translation_placeholders={
"entry_title": self.entry.title,
"entities": "\n".join(formatted_entities),
},
learn_more_url="https://docs.yaha-cloud.ru/v1.0.x/config/filter/",
)
async def _async_setup_notifiers(self, *_: Any) -> None:
"""Set up notifiers."""
if self.is_reporting_states or self.platform == SmartHomePlatform.VK:
ir.async_delete_issue(self._hass, DOMAIN, ISSUE_ID_MISSING_SKILL_DATA)
else:
ir.async_create_issue(
self._hass,
DOMAIN,
ISSUE_ID_MISSING_SKILL_DATA,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_ID_MISSING_SKILL_DATA,
translation_placeholders={"entry_title": self.entry.title},
)
return
if not self.linked_platforms:
return
track_templates = self._get_trackable_templates()
track_entity_states = self._get_trackable_entity_states()
extended_log = len(self._hass.config_entries.async_entries(DOMAIN)) > 1
match self.connection_type:
case ConnectionType.CLOUD:
for platform in self.linked_platforms:
config = NotifierConfig(
user_id=self.cloud_instance_id,
token=self.cloud_connection_token,
platform=platform,
extended_log=extended_log,
)
self._notifiers.append(
CloudNotifier(self._hass, self, config, track_templates, track_entity_states)
)
case ConnectionType.CLOUD_PLUS:
if self.platform == SmartHomePlatform.YANDEX and self.skill and self.skill.token:
config = NotifierConfig(
user_id=self.cloud_instance_id,
token=self.skill.token,
skill_id=self.skill.id,
extended_log=extended_log,
)
self._notifiers.append(
YandexDirectNotifier(self._hass, self, config, track_templates, track_entity_states)
)
case ConnectionType.DIRECT:
if self.platform == SmartHomePlatform.YANDEX and self.skill and self.skill.token:
config = NotifierConfig(
user_id=self.skill.user_id,
token=self.skill.token,
skill_id=self.skill.id,
extended_log=extended_log,
)
self._notifiers.append(
YandexDirectNotifier(self._hass, self, config, track_templates, track_entity_states)
)
if self._notifiers:
await asyncio.wait([asyncio.create_task(n.async_setup()) for n in self._notifiers])
return None
async def _async_setup_cloud_connection(self) -> None:
"""Set up the cloud connection."""
self._cloud_manager = CloudManager(self._hass, self)
self._hass.loop.create_task(self._cloud_manager.async_connect())
return self.entry.async_on_unload(
self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._cloud_manager.async_disconnect)
)
def _append_trackable_templates_with_capability(
self,
templates: dict[Template, list[CustomCapability | CustomProperty]],
capability_config: ConfigType,
capability_type: CapabilityType,
instance: str,
device_id: str,
) -> None:
"""Append custom capability to list of templates."""
try:
capability = get_custom_capability(
self._hass,
self,
capability_config,
capability_type,
instance,
device_id,
)
except APIError as e:
_LOGGER.debug(f"Failed to track custom capability: {e}")
return
template = capability_custom.get_value_template(self._hass, device_id, capability_config)
if template:
templates.setdefault(template, [])
templates[template].append(capability)
def _get_trackable_templates(self) -> dict[Template, list[CustomCapability | CustomProperty]]:
"""Return templates for track changes."""
templates: dict[Template, list[CustomCapability | CustomProperty]] = {}
for device_id, entity_config in self.entity_config.items():
if not self.should_expose(device_id):
continue
if (state_template := entity_config.get(CONF_STATE_TEMPLATE)) is not None:
self._append_trackable_templates_with_capability(
templates,
{CONF_STATE_TEMPLATE: state_template},
CapabilityType.ON_OFF,
OnOffCapabilityInstance.ON,
device_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 entity_config:
for instance in entity_config[config_key]:
capability_config = entity_config[config_key][instance]
if isinstance(capability_config, dict):
self._append_trackable_templates_with_capability(
templates, capability_config, capability_type, instance, device_id
)
for property_config in entity_config.get(CONF_ENTITY_PROPERTIES, []):
try:
if not (custom_property := get_custom_property(self._hass, self, property_config, device_id)):
continue
template = property_custom.get_value_template(self._hass, device_id, property_config)
templates.setdefault(template, [])
templates[template].append(custom_property)
except APIError as e:
_LOGGER.debug(f"Failed to track custom property: {e}")
return templates
def _get_trackable_entity_states(
self,
) -> dict[EntityId, list[tuple[DeviceId, type[StateProperty | StateCapability[Any]]]]]:
"""Return entity capability and property class types to track state changes."""
states: dict[EntityId, list[tuple[DeviceId, type[StateProperty | StateCapability[Any]]]]] = {}
def _states_append(_entity_id: str, _device_id: str, t: type[StateProperty | StateCapability[Any]]) -> None:
states.setdefault(_entity_id, [])
states[_entity_id].append((_device_id, t))
for device_id, entity_config in self.entity_config.items():
if not self.should_expose(device_id):
continue
for property_config in entity_config.get(CONF_ENTITY_PROPERTIES, []):
if event_platform_property := get_event_platform_custom_property_type(property_config):
entity_id: str = property_config[CONF_ENTITY_PROPERTY_ENTITY]
_states_append(entity_id, device_id, event_platform_property)
if backlight_entity_id := entity_config.get(CONF_BACKLIGHT_ENTITY_ID):
_states_append(backlight_entity_id, device_id, BacklightCapability)
return states

View File

@@ -0,0 +1,170 @@
"""The Yandex Smart Home request handlers."""
import logging
from typing import Any, Callable, Coroutine
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.util.decorator import Registry
from .const import ATTR_CAPABILITY, ATTR_ERROR_CODE, EVENT_DEVICE_ACTION
from .device import Device, async_get_device_description, async_get_device_states, async_get_devices
from .helpers import ActionNotAllowed, APIError, RequestData
from .schema import (
ActionRequest,
ActionResult,
ActionResultCapability,
ActionResultCapabilityState,
ActionResultDevice,
DeviceDescription,
DeviceList,
DeviceStates,
FailedActionResult,
Response,
ResponseCode,
ResponsePayload,
StatesRequest,
SuccessActionResult,
)
_LOGGER = logging.getLogger(__name__)
HANDLERS: Registry[
str,
Callable[
[HomeAssistant, RequestData, str],
Coroutine[Any, Any, ResponsePayload | None],
],
] = Registry()
async def async_handle_request(hass: HomeAssistant, data: RequestData, action: str, payload: str) -> Response:
"""Handle incoming API request."""
handler = HANDLERS.get(action)
if handler is None:
_LOGGER.error(f"Unexpected action '{action}'")
return Response(request_id=data.request_id)
try:
return Response(request_id=data.request_id, payload=await handler(hass, data, payload))
except APIError as err:
_LOGGER.error(f"{err.message} ({err.code})")
return Response(request_id=data.request_id)
except Exception:
# return always 200 due to blocking error on device page
_LOGGER.exception("Unexpected exception")
return Response(request_id=data.request_id)
@HANDLERS.register("/user/devices")
async def async_device_list(hass: HomeAssistant, data: RequestData, _payload: str) -> DeviceList:
"""Handle request that return information about supported user devices.
https://yandex.ru/dev/dialogs/smart-home/doc/reference/get-devices.html
"""
assert data.request_user_id
devices: list[DeviceDescription] = []
for device in await async_get_devices(hass, data.entry_data):
if (description := await async_get_device_description(hass, device)) is not None:
devices.append(description)
data.entry_data.link_platform(data.platform)
return DeviceList(user_id=data.request_user_id, devices=devices)
@HANDLERS.register("/user/devices/query")
async def async_devices_query(hass: HomeAssistant, data: RequestData, payload: str) -> DeviceStates:
"""Handle request that return information about the states of user devices.
https://yandex.ru/dev/dialogs/smart-home/doc/reference/post-devices-query.html
"""
request = StatesRequest.parse_raw(payload)
states = await async_get_device_states(hass, data.entry_data, [rd.id for rd in request.devices])
return DeviceStates(devices=states)
@HANDLERS.register("/user/devices/action")
async def async_devices_action(hass: HomeAssistant, data: RequestData, payload: str) -> ActionResult:
"""Handle request that changes current state of user devices.
https://yandex.ru/dev/dialogs/smart-home/doc/reference/post-action.html
"""
request = ActionRequest.parse_raw(payload)
results: list[ActionResultDevice] = []
for device_id, actions in [(rd.id, rd.capabilities) for rd in request.payload.devices]:
state = hass.states.get(device_id)
device = Device(hass, data.entry_data, device_id, state)
if state and not device.should_expose:
data.entry_data.mark_entity_unexposed(state.entity_id)
if device.unavailable:
hass.bus.async_fire(
EVENT_DEVICE_ACTION,
{ATTR_ENTITY_ID: device_id, ATTR_ERROR_CODE: ResponseCode.DEVICE_UNREACHABLE.value},
context=data.context,
)
results.append(
ActionResultDevice(
id=device_id, action_result=FailedActionResult(error_code=ResponseCode.DEVICE_UNREACHABLE)
)
)
continue
capability_results: list[ActionResultCapability] = []
for action in actions:
try:
value = await device.execute(data.context, action)
hass.bus.async_fire(
EVENT_DEVICE_ACTION,
{ATTR_ENTITY_ID: device_id, ATTR_CAPABILITY: action.as_dict()},
context=data.context,
)
except (APIError, ActionNotAllowed) as err:
if isinstance(err, APIError):
_LOGGER.error(f"{err.message} ({err.code.value})")
hass.bus.async_fire(
EVENT_DEVICE_ACTION,
{ATTR_ENTITY_ID: device_id, ATTR_CAPABILITY: action.as_dict(), ATTR_ERROR_CODE: err.code.value},
context=data.context,
)
capability_results.append(
ActionResultCapability(
type=action.type,
state=ActionResultCapabilityState(
instance=action.state.instance,
action_result=FailedActionResult(error_code=ResponseCode(err.code)),
),
)
)
continue
capability_results.append(
ActionResultCapability(
type=action.type,
state=ActionResultCapabilityState(
instance=action.state.instance,
value=value,
action_result=SuccessActionResult(),
),
)
)
results.append(ActionResultDevice(id=device_id, capabilities=capability_results))
return ActionResult(devices=results)
@HANDLERS.register("/user/unlink")
async def async_user_unlink(_hass: HomeAssistant, data: RequestData, _payload: str) -> None:
"""Handle request indicates that the user has unlink the account.
https://yandex.ru/dev/dialogs/smart-home/doc/reference/unlink.html
"""
data.entry_data.unlink_platform(data.platform)

View File

@@ -0,0 +1,172 @@
"""Helper classes for Yandex Smart Home integration."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
from urllib.parse import urlparse
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er
from homeassistant.helpers.storage import Store
from .const import DOMAIN
from .schema import ResponseCode
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
STORE_CACHE_ATTRS = "attrs"
@callback
def _get_registry_entries(hass: HomeAssistant, entity_id: str) -> tuple[
er.RegistryEntry | None,
dr.DeviceEntry | None,
ar.AreaEntry | None,
]:
"""Get registry entries."""
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
area_reg = ar.async_get(hass)
if (entity_entry := ent_reg.async_get(entity_id)) and entity_entry.device_id:
device_entry = dev_reg.devices.get(entity_entry.device_id)
else:
device_entry = None
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:
area_id = None
if area_id is not None:
area_entry = area_reg.async_get_area(area_id)
else:
area_entry = None
return entity_entry, device_entry, area_entry
class APIError(HomeAssistantError):
"""Base API error."""
def __init__(self, code: ResponseCode, message: str):
"""Init the error."""
super().__init__(message)
self.code = code
self.message = message
class ActionNotAllowed(HomeAssistantError):
"""Error producted when change capability state is not allowed, no logging."""
def __init__(self, code: ResponseCode = ResponseCode.REMOTE_CONTROL_DISABLED):
"""Init the error."""
self.code = code
class CacheStore:
"""Cache store for Yandex Smart Home."""
_STORAGE_VERSION = 1
_STORAGE_KEY = f"{DOMAIN}.cache"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a cache store."""
self._hass = hass
self._store = Store[dict[str, Any]](hass, self._STORAGE_VERSION, self._STORAGE_KEY)
self._data: dict[str, dict[str, Any]] = {STORE_CACHE_ATTRS: {}}
def get_attr_value(self, entity_id: str, attr: str) -> Any | None:
"""Return a cached value of attribute for entity."""
if entity_id not in self._data[STORE_CACHE_ATTRS]:
return None
return self._data[STORE_CACHE_ATTRS][entity_id].get(attr)
@callback
def save_attr_value(self, entity_id: str, attr: str, value: Any) -> None:
"""Cache entity's attribute value to disk."""
if entity_id not in self._data[STORE_CACHE_ATTRS]:
self._data[STORE_CACHE_ATTRS][entity_id] = {}
has_changed = True
else:
has_changed = self._data[STORE_CACHE_ATTRS][entity_id][attr] != value
self._data[STORE_CACHE_ATTRS][entity_id][attr] = value
if has_changed:
self._store.async_delay_save(lambda: self._data, 5.0)
return None
async def async_load(self) -> None:
"""Load store data."""
data = await self._store.async_load()
if data:
self._data = data
return None
class SmartHomePlatform(StrEnum):
"""Supported smart home platform."""
YANDEX = "yandex"
VK = "vk"
@classmethod
def from_client_id(cls, client_id: str) -> SmartHomePlatform | None:
"""Return platform for OAuth2 client id."""
host = urlparse(client_id).netloc
if "yandex" in host:
return cls.YANDEX
elif host == "vc.go.mail.ru":
return cls.VK
return None
@dataclass
class RequestData:
"""Hold data associated with a particular request."""
entry_data: ConfigEntryData
context: Context
platform: SmartHomePlatform
request_user_id: str | None
request_id: str | None
class HasInstance(Protocol):
"""Protocol type for objects that has instance attribute."""
instance: Any
_HasInstanceT = TypeVar("_HasInstanceT", bound=type[HasInstance])
class DictRegistry(dict[str, _HasInstanceT]):
"""Dict Registry for types with instance attribute."""
def register(self, obj: _HasInstanceT) -> _HasInstanceT:
"""Register decorated type."""
self[obj.instance] = obj
return obj
class ListRegistry[_T](list[_T]):
"""List Registry of items."""
def register(self, obj: _T) -> _T:
"""Register decorated type."""
self.append(obj)
return obj

View File

@@ -0,0 +1,161 @@
"""The Yandex Smart Home HTTP interface."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, TypeVar
from aiohttp.web import HTTPServiceUnavailable, Request, Response, json_response
from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID, HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from . import DOMAIN, handlers
from .const import ISSUE_ID_MISSING_INTEGRATION
from .helpers import RequestData, SmartHomePlatform
if TYPE_CHECKING:
from . import YandexSmartHome
_LOGGER = logging.getLogger(__name__)
_T = TypeVar("_T", bound="YandexSmartHomeView")
async def _log_request(request: Request) -> None:
"""Log the request."""
if body := await request.text():
_LOGGER.debug(f"Request: {request.url} ({request.method} data: {body})")
else:
_LOGGER.debug(f"Request: {request.url} ({request.method})")
@callback
def async_register_http(hass: HomeAssistant, component: YandexSmartHome) -> None:
"""Register HTTP views for Yandex Smart Home."""
hass.http.register_view(YandexSmartHomeUnauthorizedView(component))
return hass.http.register_view(YandexSmartHomeAPIView(component))
def async_http_request(
func: Callable[[_T, HomeAssistant, Request, RequestData], Awaitable[Response]]
) -> Callable[[_T, Request], Coroutine[Any, Any, Response]]:
"""Decorate an async function to handle authorized HTTP requests."""
async def decorator(self: _T, request: Request) -> Response:
"""Decorate."""
await _log_request(request)
hass: HomeAssistant = request.app[KEY_HASS]
context = self.context(request)
refresh_token = hass.auth.async_get_refresh_token(request[KEY_HASS_REFRESH_TOKEN_ID])
assert refresh_token is not None
platform = SmartHomePlatform.from_client_id(refresh_token.client_id or "")
if not platform:
_LOGGER.error(f"Request from unsupported platform, client_id: {refresh_token.client_id}")
raise HTTPServiceUnavailable()
entry_data = self._component.get_direct_connection_entry_data(platform, refresh_token.user.id)
if not entry_data and len(hass.config_entries.async_entries(DOMAIN)) == 1:
# backward compatibility
entry_data = self._component.get_direct_connection_entry_data(platform, None)
issue_id = f"{ISSUE_ID_MISSING_INTEGRATION}_{platform}_{refresh_token.user.id}"
if not entry_data:
_LOGGER.error(
f"Failed to find Yandex Smart Home integration for request "
f"from {platform} (user {refresh_token.user.name})"
)
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key=ISSUE_ID_MISSING_INTEGRATION,
translation_placeholders={
"platform": platform,
"username": refresh_token.user.name or refresh_token.user.id,
},
)
raise HTTPServiceUnavailable()
else:
ir.async_delete_issue(hass, DOMAIN, issue_id)
data = RequestData(
entry_data=entry_data,
context=context,
platform=platform,
request_user_id=context.user_id,
request_id=request.headers.get("X-Request-Id"),
)
if entry_data.skill:
data.request_user_id = entry_data.skill.user_id
return await func(self, hass, request, data)
return decorator
class YandexSmartHomeView(HomeAssistantView):
def __init__(self, component: YandexSmartHome):
self._component = component
class YandexSmartHomeUnauthorizedView(YandexSmartHomeView):
"""View to handle Yandex Smart Home unauthorized HTTP requests."""
url = f"/api/{DOMAIN}/v1.0"
extra_urls = [
url + "/ping",
]
name = f"api:{DOMAIN}:unauthorized"
requires_auth = False
@staticmethod
async def head(request: Request) -> Response:
"""Handle Yandex Smart Home HEAD requests."""
await _log_request(request)
return Response(status=200)
@staticmethod
async def get(request: Request) -> Response:
"""Handle Yandex Smart Home GET requests."""
await _log_request(request)
return Response(text="Yandex Smart Home", status=200)
class YandexSmartHomeAPIView(YandexSmartHomeView):
"""View to handle Yandex Smart Home HTTP requests."""
url = f"/api/{DOMAIN}/v1.0"
extra_urls = [
url + "/user/unlink",
url + "/user/devices",
url + "/user/devices/query",
url + "/user/devices/action",
]
name = f"api:{DOMAIN}"
requires_auth = True
async def _async_handle_request(self, hass: HomeAssistant, request: Request, data: RequestData) -> Response:
"""Handle Yandex Smart Home requests."""
assert self.url is not None
result = await handlers.async_handle_request(
hass, data, action=request.path.replace(self.url, "", 1), payload=await request.text()
)
response = json_response(text=result.as_json())
_LOGGER.debug(f"Response: {response.text}")
return response
@async_http_request
async def post(self, hass: HomeAssistant, request: Request, data: RequestData) -> Response:
"""Handle Yandex Smart Home POST requests."""
return await self._async_handle_request(hass, request, data)
@async_http_request
async def get(self, hass: HomeAssistant, request: Request, data: RequestData) -> Response:
"""Handle Yandex Smart Home GET requests."""
return await self._async_handle_request(hass, request, data)

View File

@@ -0,0 +1,14 @@
{
"domain": "yandex_smart_home",
"name": "Yandex Smart Home",
"config_flow": true,
"documentation": "https://docs.yaha-cloud.ru/v1.0.x/",
"issue_tracker": "https://github.com/dext0r/yandex_smart_home/issues",
"requirements": [],
"after_dependencies": ["local_calendar"],
"iot_class": "cloud_polling",
"type": "service",
"dependencies": ["http"],
"codeowners": ["@dext0r"],
"version": "1.0.2"
}

View File

@@ -0,0 +1,493 @@
"""Implement the Yandex Smart Home event notification service (notifier)."""
from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
import itertools
import logging
from random import randint
from typing import TYPE_CHECKING, Any, Mapping, Protocol, Self, Sequence
from aiohttp import ClientTimeout, JsonPayload, hdrs
from aiohttp.client_exceptions import ClientConnectionError
from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED
from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, State
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE, async_create_clientsession
from homeassistant.helpers.event import (
EventStateChangedData,
TrackTemplate,
TrackTemplateResult,
TrackTemplateResultInfo,
async_call_later,
async_track_template_result,
)
from homeassistant.helpers.template import Template
from pydantic.v1 import ValidationError
from . import DOMAIN
from .capability import Capability
from .const import CLOUD_BASE_URL, EntityId
from .device import Device, DeviceId
from .helpers import APIError, SmartHomePlatform
from .property import Property
from .schema import (
CallbackDiscoveryRequest,
CallbackDiscoveryRequestPayload,
CallbackRequest,
CallbackResponse,
CallbackStatesRequest,
CallbackStatesRequestPayload,
CapabilityInstanceState,
DeviceState,
PropertyInstanceState,
)
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
_LOGGER = logging.getLogger(__name__)
INITIAL_REPORT_DELAY = timedelta(seconds=15)
DISCOVERY_REQUEST_DELAY = timedelta(seconds=5)
HEARTBEAT_REPORT_INTERVAL = timedelta(hours=1)
REPORT_STATE_WINDOW = timedelta(seconds=1)
@dataclass
class NotifierConfig:
"""Hold configuration for a notifier."""
user_id: str
token: str
skill_id: str | None = None
platform: SmartHomePlatform | None = None
extended_log: bool = False
class ReportableDeviceState(Protocol):
"""Protocol type for device capabilities and properties."""
device_id: str
@property
@abstractmethod
def time_sensitive(self) -> bool:
"""Test if value changes should be reported immediately."""
...
@abstractmethod
def check_value_change(self, other: Self | None) -> bool:
"""Test if the state value differs from other state."""
...
@abstractmethod
def get_value(self) -> Any:
"""Return the current state value."""
...
@abstractmethod
def get_instance_state(self) -> CapabilityInstanceState | PropertyInstanceState | None:
"""Return a state for a state query request."""
...
class ReportableDeviceStateFromEntityState(ReportableDeviceState, Protocol):
@abstractmethod
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State):
"""Initialize a capability or property for the state."""
...
class ReportableTemplateDeviceState(ReportableDeviceState, Protocol):
"""Protocol type for custom properties and capabilities."""
@abstractmethod
def new_with_value(self, value: Any) -> Self:
"""Return copy of the state with new value."""
...
class PendingStates:
"""Hold states that about to be reported."""
def __init__(self) -> None:
"""Initialize."""
self._device_states: dict[str, list[ReportableDeviceState]] = {}
self._lock = asyncio.Lock()
async def async_add(
self,
new_states: Sequence[ReportableDeviceState],
old_states: Sequence[ReportableDeviceState],
) -> list[ReportableDeviceState]:
"""Add changed states to pending and return list of them."""
scheduled_states: list[ReportableDeviceState] = []
async with self._lock:
for state in new_states:
try:
old_state = old_states[old_states.index(state)]
except ValueError:
old_state = None
try:
if state.check_value_change(old_state):
device_states = self._device_states.setdefault(state.device_id, [])
with suppress(ValueError):
device_states.remove(state)
device_states.append(state)
scheduled_states.append(state)
except APIError as e:
_LOGGER.warning(e)
return scheduled_states
async def async_get_all(self) -> dict[str, list[ReportableDeviceState]]:
"""Return all states and clear pending."""
async with self._lock:
states = self._device_states.copy()
self._device_states.clear()
return states
@property
def empty(self) -> bool:
"""Test if pending states exist."""
return not bool(self._device_states)
@property
def time_sensitive(self) -> bool:
"""Test if pending states should be sent immediately."""
for state in itertools.chain(*self._device_states.values()):
if state.time_sensitive:
return True
return False
class Notifier(ABC):
"""Base class for a notifier."""
def __init__(
self,
hass: HomeAssistant,
entry_data: ConfigEntryData,
config: NotifierConfig,
track_templates: Mapping[Template, Sequence[ReportableTemplateDeviceState]],
track_entity_states: Mapping[EntityId, Sequence[tuple[DeviceId, type[ReportableDeviceStateFromEntityState]]]],
):
"""Initialize."""
self._hass = hass
self._entry_data = entry_data
self._config = config
self._session = async_create_clientsession(hass)
self._pending = PendingStates()
self._track_entity_states = track_entity_states
self._track_templates = track_templates
self._template_changes_tracker: TrackTemplateResultInfo | None = None
self._unsub_state_changed: CALLBACK_TYPE | None = None
self._unsub_initial_report: CALLBACK_TYPE | None = None
self._unsub_heartbeat_report: CALLBACK_TYPE | None = None
self._unsub_report_states: CALLBACK_TYPE | None = None
self._unsub_discovery: CALLBACK_TYPE | None = None
async def async_setup(self) -> None:
"""Set up the notifier."""
self._unsub_state_changed = self._hass.bus.async_listen(EVENT_STATE_CHANGED, self._async_state_changed)
self._unsub_initial_report = async_call_later(
self._hass, INITIAL_REPORT_DELAY, HassJob(self._async_initial_report)
)
self._unsub_heartbeat_report = async_call_later(
self._hass,
delay=HEARTBEAT_REPORT_INTERVAL + timedelta(minutes=randint(1, 15)),
action=HassJob(self._async_hearbeat_report),
)
self._unsub_discovery = async_call_later(
self._hass, DISCOVERY_REQUEST_DELAY, HassJob(self.async_send_discovery)
)
if self._track_templates:
self._template_changes_tracker = async_track_template_result(
self._hass,
[TrackTemplate(t, None) for t in self._track_templates],
self._async_template_result_changed,
)
self._template_changes_tracker.async_refresh()
return None
async def async_unload(self) -> None:
"""Unload the notifier."""
for unsub in [
self._unsub_state_changed,
self._unsub_initial_report,
self._unsub_heartbeat_report,
self._unsub_report_states,
self._unsub_discovery,
]:
if unsub:
unsub()
self._unsub_state_changed = None
self._unsub_initial_report = None
self._unsub_heartbeat_report = None
self._unsub_report_states = None
self._unsub_discovery = None
if self._template_changes_tracker is not None:
self._template_changes_tracker.async_remove()
self._template_changes_tracker = None
return None
async def async_send_discovery(self, *_: Any) -> None:
"""Send notification about change of devices' parameters."""
self._debug_log("Sending discovery request")
request = CallbackDiscoveryRequest(payload=CallbackDiscoveryRequestPayload(user_id=self._config.user_id))
return await self._async_send_request(f"{self._base_url}/discovery", request)
@property
@abstractmethod
def _base_url(self) -> str:
"""Return base URL."""
pass
@property
@abstractmethod
def _request_headers(self) -> dict[str, str]:
"""Return headers for a request."""
pass
def _format_log_message(self, message: str) -> str:
"""Format a message."""
if self._config.extended_log:
return f"{self._entry_data.entry.title}: {message}"
return message
def _debug_log(self, message: str) -> None:
"""Log a debug message."""
if self._config.extended_log:
message = f"({self._entry_data.entry.entry_id[:6]}) {message}"
_LOGGER.debug(message)
async def _async_report_states(self, *_: Any) -> None:
"""Send notification about device state change."""
states: list[DeviceState] = []
for device_id, device_states in (await self._pending.async_get_all()).items():
capabilities: list[CapabilityInstanceState] = []
properties: list[PropertyInstanceState] = []
for c in [c for c in device_states if isinstance(c, Capability)]:
try:
if (capability_state := c.get_instance_state()) is not None:
capabilities.append(capability_state)
except APIError as e:
_LOGGER.warning(e)
for p in [p for p in device_states if isinstance(p, Property)]:
try:
if (property_state := p.get_instance_state()) is not None:
properties.append(property_state)
except APIError as e:
_LOGGER.warning(e)
if capabilities or properties:
states.append(
DeviceState(
id=device_id,
capabilities=capabilities or None,
properties=properties or None,
)
)
if states:
request = CallbackStatesRequest(
payload=CallbackStatesRequestPayload(user_id=self._config.user_id, devices=states)
)
asyncio.create_task(self._async_send_request(f"{self._base_url}/state", request))
if self._pending.empty:
self._unsub_report_states = None
else:
self._unsub_report_states = async_call_later(
self._hass,
delay=0 if self._pending.time_sensitive else REPORT_STATE_WINDOW,
action=HassJob(self._async_report_states),
)
return None
async def _async_send_request(self, url: str, request: CallbackRequest) -> None:
"""Send a request to the url."""
try:
self._debug_log(f"Request: {url} (POST data: {request.as_json()})")
r = await self._session.post(
url,
headers=self._request_headers,
data=JsonPayload(request.as_json(), dumps=lambda p: p),
timeout=ClientTimeout(total=5),
)
response_body, error_message = await r.read(), ""
try:
response = CallbackResponse.parse_raw(response_body)
if response.error_message:
error_message = response.error_message
elif response.error_code:
error_message = response.error_code
except ValidationError:
error_message = response_body.decode("utf-8").strip()[:100]
if r.status != 202 or error_message:
_LOGGER.warning(
self._format_log_message(f"State notification request failed: {error_message or r.status}")
)
except ClientConnectionError as e:
_LOGGER.warning(self._format_log_message(f"State notification request failed: {e!r}"))
except asyncio.TimeoutError as e:
self._debug_log(f"State notification request failed: {e!r}")
except Exception:
_LOGGER.exception(self._format_log_message("Unexpected exception"))
return None
async def _async_template_result_changed(
self,
event_type: Event[EventStateChangedData] | None,
updates: list[TrackTemplateResult],
) -> None:
"""Handle track template changes."""
if event_type is None: # update during setup
return None
for result in updates:
if isinstance(result.result, TemplateError):
_LOGGER.warning(f"Error while processing template: {result.template.template}", exc_info=result.result)
continue
if isinstance(result.last_result, TemplateError):
result.last_result = None
for state in self._track_templates[result.template]:
old_state = state.new_with_value(result.last_result)
new_state = state.new_with_value(result.result)
for pending_state in await self._pending.async_add([new_state], [old_state]):
self._debug_log(
f"State report with value '{pending_state.get_value()}' scheduled for {pending_state!r}"
)
return self._schedule_report_states()
async def _async_state_changed(self, event: Event[EventStateChangedData]) -> None:
"""Handle state changes."""
entity_id = str(event.data.get(ATTR_ENTITY_ID))
old_state: State | None = event.data.get("old_state")
new_state: State | None = event.data.get("new_state")
if not new_state:
return None
old_device_states: list[ReportableDeviceState] = []
new_device_states: list[ReportableDeviceState] = []
for device_id, cls in self._track_entity_states.get(entity_id, []):
new_device_states.append(cls(self._hass, self._entry_data, device_id, new_state))
if old_state:
old_device_states.append(cls(self._hass, self._entry_data, device_id, old_state))
new_device = Device(self._hass, self._entry_data, entity_id, new_state)
if new_device.should_expose:
new_device_states.extend(new_device.get_state_capabilities())
new_device_states.extend(new_device.get_state_properties())
if old_state:
old_device = Device(self._hass, self._entry_data, entity_id, old_state)
old_device_states.extend(old_device.get_state_capabilities())
old_device_states.extend(old_device.get_state_properties())
for pending_state in await self._pending.async_add(new_device_states, old_device_states):
self._debug_log(f"State report with value '{pending_state.get_value()}' scheduled for {pending_state!r}")
return self._schedule_report_states()
async def _async_initial_report(self, *_: Any) -> None:
"""Schedule initial report."""
self._debug_log("Reporting initial states")
for state in self._hass.states.async_all():
device = Device(self._hass, self._entry_data, state.entity_id, state)
if device.should_expose:
await self._pending.async_add(device.get_capabilities(), [])
await self._pending.async_add([p for p in device.get_properties() if p.heartbeat_report], [])
return self._schedule_report_states()
async def _async_hearbeat_report(self, *_: Any) -> None:
"""Schedule periodical state report."""
self._debug_log("Reporting states (heartbeat)")
for state in self._hass.states.async_all():
device = Device(self._hass, self._entry_data, state.entity_id, state)
if device.should_expose:
await self._pending.async_add([p for p in device.get_properties() if p.heartbeat_report], [])
self._unsub_heartbeat_report = async_call_later(
self._hass,
delay=HEARTBEAT_REPORT_INTERVAL,
action=HassJob(self._async_hearbeat_report),
)
return self._schedule_report_states()
def _schedule_report_states(self) -> None:
"""Schedule run report states job if there are pending states."""
if self._pending.empty or self._unsub_report_states:
return None
self._unsub_report_states = async_call_later(
self._hass,
delay=0 if self._pending.time_sensitive else REPORT_STATE_WINDOW,
action=HassJob(self._async_report_states),
)
return None
class YandexDirectNotifier(Notifier):
"""Notifier for direct connection."""
@property
def _base_url(self) -> str:
"""Return base URL."""
return f"https://dialogs.yandex.net/api/v1/skills/{self._config.skill_id}/callback"
@property
def _request_headers(self) -> dict[str, str]:
"""Return headers for a request."""
return {hdrs.AUTHORIZATION: f"OAuth {self._config.token}"}
class CloudNotifier(Notifier):
"""Notifier for cloud connections."""
@property
def _base_url(self) -> str:
"""Return base URL."""
return f"{CLOUD_BASE_URL}/api/home_assistant/v2/callback/{self._config.platform}"
@property
def _request_headers(self) -> dict[str, str]:
"""Return headers for a request."""
return {
hdrs.AUTHORIZATION: f"Bearer {self._config.token}",
hdrs.USER_AGENT: f"{SERVER_SOFTWARE} {DOMAIN}/{self._entry_data.component_version}",
}

View File

@@ -0,0 +1,140 @@
"""Implement the Yandex Smart Home base device property."""
from __future__ import annotations
from abc import abstractmethod
from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
from homeassistant.const import ATTR_DEVICE_CLASS
from homeassistant.core import HomeAssistant, State
from .helpers import ListRegistry
from .schema import (
PropertyDescription,
PropertyInstance,
PropertyInstanceState,
PropertyInstanceStateValue,
PropertyParameters,
PropertyType,
)
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
@runtime_checkable
class Property(Protocol):
"""Base class for a device property."""
device_id: str
type: PropertyType
instance: PropertyInstance
_hass: HomeAssistant
_entry_data: ConfigEntryData
@property
@abstractmethod
def supported(self) -> bool:
"""Test if the property is supported."""
...
@property
def retrievable(self) -> bool:
"""Test if the property can return the current value."""
return True
@property
def reportable(self) -> bool:
"""Test if the property can report value changes."""
return self._entry_data.is_reporting_states
@property
def heartbeat_report(self) -> bool:
"""Test if property value should be reported on startup and periodically."""
return True
@property
def time_sensitive(self) -> bool:
"""Test if value changes should be reported immediately."""
return False
@property
@abstractmethod
def parameters(self) -> PropertyParameters:
"""Return parameters for a devices list request."""
...
@abstractmethod
def get_description(self) -> PropertyDescription:
"""Return a description for a device list request."""
...
@abstractmethod
def get_value(self) -> Any:
"""Return the current property value."""
...
def get_instance_state(self) -> PropertyInstanceState | None:
"""Return a state for a state query request."""
if (value := self.get_value()) is not None:
return PropertyInstanceState(
type=self.type, state=PropertyInstanceStateValue(instance=self.instance, value=value)
)
return None
@abstractmethod
def check_value_change(self, other: Self | None) -> bool:
"""Test if the property value differs from other property."""
...
def __str__(self) -> str:
"""Return string representation."""
return f"instance {self.instance} of {self.type.short} property of {self.device_id}"
def __repr__(self) -> str:
"""Return the representation."""
return (
f"<{self.__class__.__name__}"
f" device_id={self.device_id }"
f" type={self.type}"
f" instance={self.instance}"
f">"
)
def __eq__(self, other: Any) -> bool:
"""Compare properties."""
return bool(
isinstance(other, Property)
and self.type == other.type
and self.instance == other.instance
and self.device_id == other.device_id
)
@runtime_checkable
class StateProperty(Property, Protocol):
"""Base class for a device property based on the state."""
state: State
device_id: str
_hass: HomeAssistant
_entry_data: ConfigEntryData
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State):
"""Initialize a property for the state."""
self._hass = hass
self._entry_data = entry_data
self.state = state
self.device_id = device_id
@property
def _state_device_class(self) -> str | None:
"""Return state device class."""
return self.state.attributes.get(ATTR_DEVICE_CLASS)
STATE_PROPERTIES_REGISTRY = ListRegistry[type[StateProperty]]()

View File

@@ -0,0 +1,489 @@
"""Implement the Yandex Smart Home custom properties."""
from __future__ import annotations
from functools import cached_property
import logging
from typing import TYPE_CHECKING, Any, Protocol, Self, cast
from homeassistant.components import binary_sensor, event
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import (
CONF_ENTITY_PROPERTY_ATTRIBUTE,
CONF_ENTITY_PROPERTY_ENTITY,
CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT,
CONF_ENTITY_PROPERTY_TYPE,
CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT,
CONF_ENTITY_PROPERTY_VALUE_TEMPLATE,
PropertyInstanceType,
)
from .helpers import APIError, DictRegistry
from .property import Property
from .property_event import (
BatteryLevelEventProperty,
ButtonPressEventProperty,
EventPlatformProperty,
EventProperty,
FoodLevelEventProperty,
GasEventProperty,
MotionEventProperty,
OpenEventProperty,
ReactiveEventProperty,
SensorEventProperty,
SmokeEventProperty,
VibrationEventProperty,
WaterLeakEventProperty,
WaterLevelEventProperty,
)
from .property_float import (
BatteryLevelPercentageProperty,
CO2LevelProperty,
ElectricCurrentProperty,
ElectricityMeterProperty,
ElectricPowerProperty,
FloatProperty,
FoodLevelPercentageProperty,
GasMeterProperty,
HeatMeterProperty,
HumidityProperty,
IlluminationProperty,
MeterProperty,
PM1DensityProperty,
PM10DensityProperty,
PM25DensityProperty,
PressureProperty,
TemperatureProperty,
TVOCConcentrationProperty,
VoltageProperty,
WaterLevelPercentageProperty,
WaterMeterProperty,
)
from .schema import PropertyType, ResponseCode
from .unit_conversion import UnitOfPressure, UnitOfTemperature
if TYPE_CHECKING:
from .entry_data import ConfigEntryData
_LOGGER = logging.getLogger(__name__)
class CustomProperty(Property, Protocol):
"""Base class for a property that user can set up using yaml configuration."""
device_id: str
_hass: HomeAssistant
_entry_data: ConfigEntryData
_config: ConfigType
_value_template: Template
_value: Any | UndefinedType
def __init__(
self,
hass: HomeAssistant,
entry_data: ConfigEntryData,
config: ConfigType,
device_id: str,
value_template: Template,
value: Any | UndefinedType = UNDEFINED,
):
"""Initialize a custom property."""
self._hass = hass
self._entry_data = entry_data
self._config = config
self._value_template = value_template
self._value = value
self.device_id = device_id
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return True
def _get_native_value(self) -> str:
"""Return the current property value without conversion."""
if self._value is not UNDEFINED:
return str(self._value).strip()
try:
return str(self._value_template.async_render()).strip()
except TemplateError as exc:
raise APIError(ResponseCode.INVALID_VALUE, f"Failed to get current value for {self}: {exc!r}")
def new_with_value(self, value: Any) -> Self:
"""Return copy of the state with new value."""
return self.__class__(
self._hass,
self._entry_data,
self._config,
self.device_id,
self._value_template,
value,
)
def __repr__(self) -> str:
"""Return the representation."""
return (
f"<{self.__class__.__name__}"
f" device_id={self.device_id }"
f" instance={self.instance}"
f" value_template={self._value_template}"
f" value={self._value}"
f">"
)
class EventPlatformCustomProperty(EventPlatformProperty, Protocol):
"Base class for an event property of event platform that user can set up using yaml configuration."
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return True
class CustomEventProperty(CustomProperty, EventProperty[Any], Protocol):
"""Base class for an event property that user can set up using yaml configuration."""
EVENT_PROPERTIES_REGISTRY = DictRegistry[type[CustomEventProperty]]()
@EVENT_PROPERTIES_REGISTRY.register
class OpenCustomEventProperty(SensorEventProperty, OpenEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class MotionCustomEventProperty(SensorEventProperty, MotionEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class GasCustomEventProperty(SensorEventProperty, GasEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class SmokeCustomEventProperty(SensorEventProperty, SmokeEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class BatteryLevelCustomEventProperty(SensorEventProperty, BatteryLevelEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class FoodLevelCustomEventProperty(SensorEventProperty, FoodLevelEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class WaterLevelCustomEventProperty(SensorEventProperty, WaterLevelEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class WaterLeakCustomEventProperty(SensorEventProperty, WaterLeakEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class ButtonPressCustomEventProperty(ReactiveEventProperty, ButtonPressEventProperty, CustomEventProperty):
pass
@EVENT_PROPERTIES_REGISTRY.register
class VibrationCustomEventProperty(ReactiveEventProperty, VibrationEventProperty, CustomEventProperty):
pass
EVENT_PLATFORM_PROPERTIES_REGISTRY = DictRegistry[type[EventPlatformCustomProperty]]()
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class OpenEventPlatformCustomProperty(OpenEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class MotionEventPlatformCustomProperty(MotionEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class GasEventPlatformCustomProperty(GasEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class SmokeEventPlatformCustomProperty(SmokeEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class BatteryLevelEventPlatformCustomProperty(BatteryLevelEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class FoodLevelEventPlatformCustomProperty(FoodLevelEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class WaterLevelEventPlatformCustomProperty(WaterLevelEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class WaterLeakEventPlatformCustomProperty(WaterLeakEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class ButtonPressEventPlatformCustomProperty(ButtonPressEventProperty, EventPlatformCustomProperty):
pass
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
class VibrationEventPlatformCustomProperty(VibrationEventProperty, EventPlatformCustomProperty):
pass
class CustomFloatProperty(CustomProperty, FloatProperty, Protocol):
"""Base class for a float property that user can set up using yaml configuration."""
def _get_native_value(self) -> str:
"""Return the current property value without conversion."""
return super()._get_native_value()
@cached_property
def _native_unit_of_measurement(self) -> str | None:
"""Return the unit the native value is expressed in."""
if unit := self._config.get(CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT):
return str(unit)
for s in ("state_attr(", ".attributes"):
if s in self._value_template.template:
return None
info = self._value_template.async_render_to_info()
if len(info.entities) == 1:
entity_id = next(iter(info.entities))
state = self._hass.states.get(entity_id)
if state:
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
return None
FLOAT_PROPERTIES_REGISTRY = DictRegistry[type[CustomFloatProperty]]()
@FLOAT_PROPERTIES_REGISTRY.register
class TemperatureCustomFloatProperty(TemperatureProperty, CustomFloatProperty):
@property
def unit_of_measurement(self) -> UnitOfTemperature:
"""Return the unit the property value is expressed in."""
if unit := self._config.get(CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT):
return UnitOfTemperature(unit)
return super().unit_of_measurement
@FLOAT_PROPERTIES_REGISTRY.register
class HumidityCustomFloatProperty(HumidityProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class PressureCustomFloatProperty(PressureProperty, CustomFloatProperty):
@property
def unit_of_measurement(self) -> UnitOfPressure:
"""Return the unit the property value is expressed in."""
if unit := self._config.get(CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT):
return UnitOfPressure(unit)
return super().unit_of_measurement
@FLOAT_PROPERTIES_REGISTRY.register
class IlluminationCustomFloatProperty(IlluminationProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class FoodLevelCustomFloatProperty(FoodLevelPercentageProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class WaterLevelCustomFloatProperty(WaterLevelPercentageProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class CO2LevelCustomFloatProperty(CO2LevelProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class MeterCustomFloatProperty(MeterProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class ElectricityMeterCustomFloatProperty(ElectricityMeterProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class GasMeterCustomFloatProperty(GasMeterProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class HeatMeterCustomFloatProperty(HeatMeterProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class WaterMeterCustomFloatProperty(WaterMeterProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class PM1DensityCustomFloatProperty(PM1DensityProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class PM25DensityCustomFloatProperty(PM25DensityProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class PM10DensityCustomFloatProperty(PM10DensityProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class TVOCConcentrationCustomFloatProperty(TVOCConcentrationProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class VoltageCustomFloatProperty(VoltageProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class ElectricCurrentCustomFloatProperty(ElectricCurrentProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class ElectricPowerCustomFloatProperty(ElectricPowerProperty, CustomFloatProperty):
pass
@FLOAT_PROPERTIES_REGISTRY.register
class BatteryLevelCustomFloatProperty(BatteryLevelPercentageProperty, CustomFloatProperty):
pass
def get_custom_property(
hass: HomeAssistant, entry_data: ConfigEntryData, config: ConfigType, device_id: str
) -> CustomProperty | None:
"""Return initialized custom property based on property configuration."""
if _is_event_platform_entity(config.get(CONF_ENTITY_PROPERTY_ENTITY)):
return None
cls: type[CustomEventProperty] | type[CustomFloatProperty]
property_type: str = config[CONF_ENTITY_PROPERTY_TYPE]
value_template = get_value_template(hass, device_id, config)
vault_template_info = value_template.async_render_to_info()
if property_type.startswith(f"{PropertyInstanceType.EVENT}."):
cls = EVENT_PROPERTIES_REGISTRY[property_type.split(".", 1)[1]]
elif property_type.startswith(f"{PropertyInstanceType.FLOAT}."):
cls = FLOAT_PROPERTIES_REGISTRY[property_type.split(".", 1)[1]]
else:
instance = property_type
if instance not in FLOAT_PROPERTIES_REGISTRY and instance in EVENT_PROPERTIES_REGISTRY:
property_type = PropertyType.EVENT
else:
property_type = PropertyType.FLOAT
if len(vault_template_info.entities) == 1:
entity_id = next(iter(vault_template_info.entities))
domain, _ = split_entity_id(entity_id)
if domain == binary_sensor.DOMAIN:
if instance not in EVENT_PROPERTIES_REGISTRY:
raise APIError(
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
f"Unsupported entity {entity_id} for {instance} property of {device_id}",
)
property_type = PropertyType.EVENT
if property_type == PropertyType.EVENT:
cls = EVENT_PROPERTIES_REGISTRY[instance]
else:
cls = FLOAT_PROPERTIES_REGISTRY[instance]
for entity_id in vault_template_info.entities:
if _is_event_platform_entity(entity_id):
_LOGGER.warning(f"Entity {entity_id} is not supported in value_template, use state_entity instead")
return cls(hass, entry_data, config, device_id, value_template)
def get_event_platform_custom_property_type(config: ConfigType) -> type[EventPlatformCustomProperty] | None:
"""Return the class type of event platform custom property based on property configuration."""
entity_id = config.get(CONF_ENTITY_PROPERTY_ENTITY)
if not _is_event_platform_entity(entity_id):
return None
property_type: str = config[CONF_ENTITY_PROPERTY_TYPE]
if property_type.startswith(f"{PropertyInstanceType.EVENT}."):
return EVENT_PLATFORM_PROPERTIES_REGISTRY[property_type.split(".", 1)[1]]
try:
return EVENT_PLATFORM_PROPERTIES_REGISTRY[property_type]
except KeyError:
_LOGGER.warning(f"Property type {property_type} is not supported for entity {entity_id}")
return None
def get_value_template(hass: HomeAssistant, device_id: str, property_config: ConfigType) -> Template:
"""Return property value template from property configuration."""
if template := property_config.get(CONF_ENTITY_PROPERTY_VALUE_TEMPLATE):
return cast(Template, template)
entity_id = property_config.get(CONF_ENTITY_PROPERTY_ENTITY, device_id)
attribute = property_config.get(CONF_ENTITY_PROPERTY_ATTRIBUTE)
if attribute:
return Template("{{ state_attr('%s', '%s') }}" % (entity_id, attribute), hass)
return Template("{{ states('%s') }}" % entity_id, hass)
def _is_event_platform_entity(entity_id: str | None) -> bool:
"""Check if the entity provided by event platform."""
if not entity_id:
return False
domain, _ = split_entity_id(entity_id)
return domain == event.DOMAIN

View File

@@ -0,0 +1,557 @@
"""Implement the Yandex Smart Home event properties."""
from abc import abstractmethod
from functools import cached_property
from itertools import chain
import logging
from typing import Any, Protocol, Self, cast
from homeassistant.components import binary_sensor, sensor
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.event import ATTR_EVENT_TYPE, DOMAIN as EVENT_DOMAIN, EventDeviceClass
from homeassistant.const import (
CONF_DEVICE_CLASS,
STATE_CLOSED,
STATE_OFF,
STATE_ON,
STATE_OPEN,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_ENTITY_EVENT_MAP, STATE_EMPTY, STATE_NONE, STATE_NONE_UI, XGW3DeviceClass
from .property import STATE_PROPERTIES_REGISTRY, Property, StateProperty
from .schema import (
BatteryLevelEventPropertyParameters,
BatteryLevelInstanceEvent,
ButtonEventPropertyParameters,
ButtonInstanceEvent,
EventInstanceEvent,
EventInstanceEventT,
EventPropertyDescription,
EventPropertyInstance,
EventPropertyParameters,
FoodLevelEventPropertyParameters,
FoodLevelInstanceEvent,
GasEventPropertyParameters,
GasInstanceEvent,
MotionEventPropertyParameters,
MotionInstanceEvent,
OpenEventPropertyParameters,
OpenInstanceEvent,
PropertyType,
SmokeEventPropertyParameters,
SmokeInstanceEvent,
VibrationEventPropertyParameters,
VibrationInstanceEvent,
WaterLeakEventPropertyParameters,
WaterLeakInstanceEvent,
WaterLevelEventPropertyParameters,
WaterLevelInstanceEvent,
)
from .schema.property_event import get_event_class_for_instance
_LOGGER = logging.getLogger(__name__)
_BOOLEAN_TRUE = ["yes", "true", "1", STATE_ON]
_BOOLEAN_FALSE = ["no", "false", "0", STATE_OFF]
type EventMapT[EventInstanceEventT] = dict[EventInstanceEventT, list[str]]
class EventProperty(Property, Protocol[EventInstanceEventT]):
"""Base class for event properties."""
type: PropertyType = PropertyType.EVENT
instance: EventPropertyInstance
_event_map_default: EventMapT[EventInstanceEventT] = {}
@property
@abstractmethod
def parameters(self) -> EventPropertyParameters[EventInstanceEventT]:
"""Return parameters for a devices list request."""
...
def get_description(self) -> EventPropertyDescription:
"""Return a description for a device list request."""
return EventPropertyDescription(
retrievable=self.retrievable, reportable=self.reportable, parameters=self.parameters
)
@property
def heartbeat_report(self) -> bool:
"""Test if property value should be reported on startup and periodically."""
return False
@property
def time_sensitive(self) -> bool:
"""Test if value changes should be reported immediately."""
return True
@property
def event_map(self) -> dict[EventInstanceEventT, list[str]]:
"""Return an event mapping between Yandex and HA."""
return self.event_map_config or self._event_map_default
@property
def event_map_config(self) -> dict[EventInstanceEventT, list[str]]:
"""Return an event mapping from a entity configuration."""
if CONF_ENTITY_EVENT_MAP in self._entity_config:
event_cls = get_event_class_for_instance(self.instance)
return cast(
dict[EventInstanceEventT, list[str]],
{event_cls(k): v for k, v in self._entity_config[CONF_ENTITY_EVENT_MAP].get(self.instance, {}).items()},
)
return {}
def get_value(self) -> EventInstanceEvent | None:
"""Return the current property value."""
value = str(self._get_native_value()).lower()
if value in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE, STATE_NONE_UI, STATE_EMPTY):
return None
for event, values in self.event_map.items():
if value in values:
return event
_LOGGER.debug(f"Unknown event {value} for instance {self.instance} of {self.device_id}")
return None
@cached_property
def _entity_config(self) -> ConfigType:
"""Return additional configuration for the device."""
return self._entry_data.get_entity_config(self.device_id)
@abstractmethod
def _get_native_value(self) -> str | None:
"""Return the current property value without conversion."""
...
@cached_property
def _supported_native_values(self) -> list[str]:
"""Return a list of supported native values."""
return list(chain.from_iterable(self.event_map.values()))
class SensorEventProperty(EventProperty[Any]):
"""Represent a binary-like property with stable current value."""
def check_value_change(self, other: Self | None) -> bool:
"""Test if the property value differs from other property."""
if other is None:
return False
value, other_value = self.get_value(), other.get_value()
if value is None or other_value is None:
return False
return bool(value != other_value)
class ReactiveEventProperty(EventProperty[Any], Protocol):
"""Represent a button-like event property (sensor and binary_sensor platforms)."""
def check_value_change(self, other: Self | None) -> bool:
"""Test if the property value differs from other property."""
value = self.get_value()
if value is None:
return False
if other is None:
return True
return value != other.get_value()
class OpenEventProperty(EventProperty[OpenInstanceEvent], Protocol):
"""Base class for event property that detect opening of something."""
instance: EventPropertyInstance = EventPropertyInstance.OPEN
_event_map_default: EventMapT[OpenInstanceEvent] = {
OpenInstanceEvent.OPENED: _BOOLEAN_TRUE + [STATE_OPEN],
OpenInstanceEvent.CLOSED: _BOOLEAN_FALSE + [STATE_CLOSED],
}
@property
def parameters(self) -> OpenEventPropertyParameters:
"""Return parameters for a devices list request."""
return OpenEventPropertyParameters()
class MotionEventProperty(EventProperty[MotionInstanceEvent], Protocol):
"""Base class for event property that detect motion, presence or occupancy."""
instance: EventPropertyInstance = EventPropertyInstance.MOTION
_event_map_default: EventMapT[MotionInstanceEvent] = {
MotionInstanceEvent.DETECTED: _BOOLEAN_TRUE + ["motion", "motion_detected"],
MotionInstanceEvent.NOT_DETECTED: _BOOLEAN_FALSE,
}
@property
def parameters(self) -> MotionEventPropertyParameters:
"""Return parameters for a devices list request."""
return MotionEventPropertyParameters()
class GasEventProperty(EventProperty[GasInstanceEvent], Protocol):
"""Base class for event property that detect gas presence."""
instance: EventPropertyInstance = EventPropertyInstance.GAS
_event_map_default: EventMapT[GasInstanceEvent] = {
GasInstanceEvent.DETECTED: _BOOLEAN_TRUE,
GasInstanceEvent.NOT_DETECTED: _BOOLEAN_FALSE,
GasInstanceEvent.HIGH: ["high"],
}
@property
def parameters(self) -> GasEventPropertyParameters:
"""Return parameters for a devices list request."""
return GasEventPropertyParameters()
class SmokeEventProperty(EventProperty[SmokeInstanceEvent], Protocol):
"""Base class for event property that detect smoke presence."""
instance: EventPropertyInstance = EventPropertyInstance.SMOKE
_event_map_default: EventMapT[SmokeInstanceEvent] = {
SmokeInstanceEvent.DETECTED: _BOOLEAN_TRUE,
SmokeInstanceEvent.NOT_DETECTED: _BOOLEAN_FALSE,
SmokeInstanceEvent.HIGH: ["high"],
}
@property
def parameters(self) -> SmokeEventPropertyParameters:
"""Return parameters for a devices list request."""
return SmokeEventPropertyParameters()
class BatteryLevelEventProperty(EventProperty[BatteryLevelInstanceEvent], Protocol):
"""Base class for event property that detect low level of a battery."""
instance: EventPropertyInstance = EventPropertyInstance.BATTERY_LEVEL
_event_map_default: EventMapT[BatteryLevelInstanceEvent] = {
BatteryLevelInstanceEvent.LOW: _BOOLEAN_TRUE + ["low"],
BatteryLevelInstanceEvent.NORMAL: _BOOLEAN_FALSE + ["normal"],
BatteryLevelInstanceEvent.HIGH: ["high"],
}
@property
def parameters(self) -> BatteryLevelEventPropertyParameters:
"""Return parameters for a devices list request."""
return BatteryLevelEventPropertyParameters()
class FoodLevelEventProperty(EventProperty[FoodLevelInstanceEvent], Protocol):
"""Base class for event property that detect food level."""
instance: EventPropertyInstance = EventPropertyInstance.FOOD_LEVEL
_event_map_default: EventMapT[FoodLevelInstanceEvent] = {
FoodLevelInstanceEvent.EMPTY: ["empty"],
FoodLevelInstanceEvent.LOW: ["low"],
FoodLevelInstanceEvent.NORMAL: ["normal"],
}
@property
def parameters(self) -> FoodLevelEventPropertyParameters:
"""Return parameters for a devices list request."""
return FoodLevelEventPropertyParameters()
class WaterLevelEventProperty(EventProperty[WaterLevelInstanceEvent], Protocol):
"""Base class for event property that detect low level of water."""
instance: EventPropertyInstance = EventPropertyInstance.WATER_LEVEL
_event_map_default: EventMapT[WaterLevelInstanceEvent] = {
WaterLevelInstanceEvent.EMPTY: ["empty"],
WaterLevelInstanceEvent.LOW: _BOOLEAN_TRUE + ["low"],
WaterLevelInstanceEvent.NORMAL: _BOOLEAN_FALSE + ["normal"],
}
@property
def parameters(self) -> WaterLevelEventPropertyParameters:
"""Return parameters for a devices list request."""
return WaterLevelEventPropertyParameters()
class WaterLeakEventProperty(EventProperty[WaterLeakInstanceEvent], Protocol):
"""Base class for event property that detect water leakage."""
instance: EventPropertyInstance = EventPropertyInstance.WATER_LEAK
_event_map_default: EventMapT[WaterLeakInstanceEvent] = {
WaterLeakInstanceEvent.DRY: _BOOLEAN_FALSE + ["dry"],
WaterLeakInstanceEvent.LEAK: _BOOLEAN_TRUE + ["leak"],
}
@property
def parameters(self) -> WaterLeakEventPropertyParameters:
"""Return parameters for a devices list request."""
return WaterLeakEventPropertyParameters()
class ButtonPressEventProperty(EventProperty[ButtonInstanceEvent], Protocol):
"""Base class for event property that detect a button interaction."""
instance: EventPropertyInstance = EventPropertyInstance.BUTTON
_event_map_default: EventMapT[ButtonInstanceEvent] = {
ButtonInstanceEvent.CLICK: ["click", "single", "press", "pressed"],
ButtonInstanceEvent.DOUBLE_CLICK: [
"double_click",
"double_press",
"double",
"many",
"quadruple",
"triple",
"triple_press",
"long_triple_press",
"long_double_press",
],
ButtonInstanceEvent.LONG_PRESS: [
"hold",
"long_click_press",
"long_click",
"long_press",
"long",
],
}
@property
def retrievable(self) -> bool:
"""Test if the property can return the current value."""
return False
@property
def parameters(self) -> ButtonEventPropertyParameters:
"""Return parameters for a devices list request."""
return ButtonEventPropertyParameters()
class VibrationEventProperty(EventProperty[VibrationInstanceEvent], Protocol):
"""Base class for event property that detect vibration."""
instance: EventPropertyInstance = EventPropertyInstance.VIBRATION
_event_map_default: EventMapT[VibrationInstanceEvent] = {
VibrationInstanceEvent.VIBRATION: _BOOLEAN_TRUE
+ [
"vibration",
"vibrate",
"actively",
"move",
"tap_twice",
"shake_air",
"swing",
],
VibrationInstanceEvent.TILT: ["tilt", "flip90", "flip180", "rotate"],
VibrationInstanceEvent.FALL: ["fall", "free_fall", "drop"],
}
@property
def retrievable(self) -> bool:
"""Test if the property can return the current value."""
return False
@property
def parameters(self) -> VibrationEventPropertyParameters:
"""Return parameters for a devices list request."""
return VibrationEventPropertyParameters()
class StateEventProperty(StateProperty, EventProperty[Any], Protocol):
"""Base class for a event property based on the state."""
def _get_native_value(self) -> str | None:
"""Return the current property value without conversion."""
return self.state.state
class EventPlatformProperty(StateProperty, EventProperty[Any], Protocol):
"""Base class for a event property based on the state of an event platform entity."""
@property
def retrievable(self) -> bool:
"""Test if the property can return the current value."""
return False
def check_value_change(self, other: Self | None) -> bool:
"""Test if the property value differs from other property."""
value = self.get_value()
if value is None:
return False
if other is None:
return True
if self.state.state == other.state.state:
return False
return True
def _get_native_value(self) -> str | None:
"""Return the current property value without conversion."""
return self.state.attributes.get(ATTR_EVENT_TYPE)
class OpenStateEventProperty(StateEventProperty, SensorEventProperty, OpenEventProperty):
"""Represents the state event property that detect opening of something."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class in (
BinarySensorDeviceClass.DOOR,
BinarySensorDeviceClass.GARAGE_DOOR,
BinarySensorDeviceClass.WINDOW,
BinarySensorDeviceClass.OPENING,
)
class MotionStateEventProperty(SensorEventProperty, StateEventProperty, MotionEventProperty):
"""Represents the state event property that detect motion, presence or occupancy."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class in (
BinarySensorDeviceClass.MOTION,
BinarySensorDeviceClass.OCCUPANCY,
BinarySensorDeviceClass.PRESENCE,
)
class MotionEventPlatformProperty(EventPlatformProperty, MotionEventProperty):
"""Represents the event platform property that detect motion."""
@property
def parameters(self) -> MotionEventPropertyParameters:
"""Return parameters for a devices list request."""
return MotionEventPropertyParameters(events=[{"value": MotionInstanceEvent.DETECTED}])
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == EVENT_DOMAIN and self._state_device_class == EventDeviceClass.MOTION
class GasStateEventProperty(StateEventProperty, SensorEventProperty, GasEventProperty):
"""Represents the state event property that detect gas presence."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class == BinarySensorDeviceClass.GAS
class SmokeStateEventProperty(StateEventProperty, SensorEventProperty, SmokeEventProperty):
"""Represents the state event property that detect smoke presence."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class == BinarySensorDeviceClass.SMOKE
class BatteryLevelStateEvent(StateEventProperty, SensorEventProperty, BatteryLevelEventProperty):
"""Represents the state event property that detect low level of a battery."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class == BinarySensorDeviceClass.BATTERY
class WaterLeakStateEventProperty(StateEventProperty, SensorEventProperty, WaterLeakEventProperty):
"""Represents the state event property that detect water leakage."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return (
self.state.domain == binary_sensor.DOMAIN and self._state_device_class == BinarySensorDeviceClass.MOISTURE
)
class ButtonPressStateEventProperty(StateEventProperty, ReactiveEventProperty, ButtonPressEventProperty):
"""Represents the state property that detect a button interaction."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
if self.state.domain == EVENT_DOMAIN:
return False
if self._state_device_class == EventDeviceClass.BUTTON:
return True
if self._entry_data.get_entity_config(self.device_id).get(CONF_DEVICE_CLASS) == EventDeviceClass.BUTTON:
return True
if self.state.domain == sensor.DOMAIN and self._state_device_class == XGW3DeviceClass.ACTION:
possible_actions = self._supported_native_values
possible_actions.extend(
[
"long_click_release",
"release",
]
)
return self.state.attributes.get("action") in possible_actions
return False
class ButtonPressEventPlatformProperty(EventPlatformProperty, ButtonPressEventProperty):
"""Represents the event platform property that detect a button interaction."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
if self.state.domain == EVENT_DOMAIN:
if self._state_device_class in [EventDeviceClass.DOORBELL, EventDeviceClass.BUTTON]:
return True
if self._entry_data.get_entity_config(self.device_id).get(CONF_DEVICE_CLASS) == EventDeviceClass.BUTTON:
return True
return False
class VibrationStateEventProperty(StateEventProperty, ReactiveEventProperty, VibrationEventProperty):
"""Represents the state event property that detect vibration."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
if self.state.domain == binary_sensor.DOMAIN:
if self._state_device_class == BinarySensorDeviceClass.VIBRATION:
return True
if self.state.domain == sensor.DOMAIN and self._state_device_class == XGW3DeviceClass.ACTION:
return self.state.attributes.get("action") in self._supported_native_values
return False
STATE_PROPERTIES_REGISTRY.register(OpenStateEventProperty)
STATE_PROPERTIES_REGISTRY.register(MotionStateEventProperty)
STATE_PROPERTIES_REGISTRY.register(MotionEventPlatformProperty)
STATE_PROPERTIES_REGISTRY.register(GasStateEventProperty)
STATE_PROPERTIES_REGISTRY.register(SmokeStateEventProperty)
STATE_PROPERTIES_REGISTRY.register(BatteryLevelStateEvent)
STATE_PROPERTIES_REGISTRY.register(WaterLeakStateEventProperty)
STATE_PROPERTIES_REGISTRY.register(ButtonPressStateEventProperty)
STATE_PROPERTIES_REGISTRY.register(ButtonPressEventPlatformProperty)
STATE_PROPERTIES_REGISTRY.register(VibrationStateEventProperty)

View File

@@ -0,0 +1,914 @@
"""Implement the Yandex Smart Home float properties."""
from abc import ABC, abstractmethod
from contextlib import suppress
from functools import cached_property
import logging
from typing import Protocol, Self
from homeassistant.components import air_quality, climate, fan, humidifier, light, sensor, switch, water_heater
from homeassistant.components.air_quality import ATTR_CO2, ATTR_PM_0_1, ATTR_PM_2_5, ATTR_PM_10
from homeassistant.components.climate import ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_HUMIDITY
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_TEMPERATURE,
ATTR_UNIT_OF_MEASUREMENT,
ATTR_VOLTAGE,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
PERCENTAGE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.util.unit_conversion import (
BaseUnitConverter,
ElectricCurrentConverter,
ElectricPotentialConverter,
EnergyConverter,
PowerConverter,
TemperatureConverter,
VolumeConverter,
)
from .const import (
ATTR_CURRENT,
ATTR_CURRENT_CONSUMPTION,
ATTR_ILLUMINANCE,
ATTR_LOAD_POWER,
ATTR_POWER,
ATTR_TVOC,
ATTR_WATER_LEVEL,
STATE_CHARGING,
STATE_EMPTY,
STATE_LOW,
STATE_NONE,
STATE_NONE_UI,
XGW3DeviceClass,
)
from .helpers import APIError
from .property import STATE_PROPERTIES_REGISTRY, Property, StateProperty
from .schema import (
AmperageFloatPropertyParameters,
BatteryLevelFloatPropertyParameters,
CO2LevelFloatPropertyParameters,
ElectricityMeterFloatPropertyParameters,
FloatPropertyDescription,
FloatPropertyInstance,
FloatPropertyParameters,
FoodLevelFloatPropertyParameters,
GasMeterFloatPropertyParameters,
HeatMeterFloatPropertyParameters,
HumidityFloatPropertyParameters,
IlluminationFloatPropertyParameters,
MeterFloatPropertyParameters,
PM1DensityFloatPropertyParameters,
PM10DensityFloatPropertyParameters,
PM25DensityFloatPropertyParameters,
PowerFloatPropertyParameters,
PressureFloatPropertyParameters,
PropertyType,
ResponseCode,
TemperatureFloatPropertyParameters,
TVOCFloatPropertyParameters,
VoltageFloatPropertyParameters,
WaterLevelFloatPropertyParameters,
WaterMeterFloatPropertyParameters,
)
from .unit_conversion import PressureConverter, TVOCConcentrationConverter, UnitOfPressure, UnitOfTemperature
_LOGGER = logging.getLogger(__name__)
class FloatProperty(Property, Protocol):
"""Base class for float properties (sensors)."""
type: PropertyType = PropertyType.FLOAT
instance: FloatPropertyInstance
@property
@abstractmethod
def parameters(self) -> FloatPropertyParameters:
"""Return parameters for a devices list request."""
...
def get_description(self) -> FloatPropertyDescription:
"""Return a description for a device list request."""
return FloatPropertyDescription(
retrievable=self.retrievable, reportable=self.reportable, parameters=self.parameters
)
def get_value(self) -> float | None:
"""Return the current property value."""
value = self._get_native_value()
if value is None:
return None
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE, STATE_NONE_UI, STATE_EMPTY):
return None
try:
float_value = float(value)
except (ValueError, TypeError):
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Unsupported value '{value}' for {self}")
if self._native_unit_of_measurement and self.unit_of_measurement and self._unit_converter:
if self._native_unit_of_measurement in self._unit_converter.VALID_UNITS:
float_value = self._unit_converter.convert(
float_value, self._native_unit_of_measurement, self.unit_of_measurement
)
else:
_LOGGER.warning(
f"Unsupported unit of measurement '{self._native_unit_of_measurement}' for {self}. "
f"Valid units are: %s" % ", ".join(sorted(map(str, self._unit_converter.VALID_UNITS)))
)
lower_limit, upper_limit = self.parameters.range
if lower_limit is not None and float_value < lower_limit:
return lower_limit
if upper_limit is not None and float_value > upper_limit:
return upper_limit
return round(float_value, 2)
def check_value_change(self, other: Self | None) -> bool:
"""Test if the property value differs from other property."""
if other is None:
return True
value, other_value = self.get_value(), other.get_value()
if value is None:
return False
if other_value is None or value != other_value:
return True
return False
@property
def unit_of_measurement(self) -> str | None:
"""Return the unit the property value is expressed in."""
return None
@abstractmethod
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
...
@cached_property
@abstractmethod
def _native_unit_of_measurement(self) -> str | None:
"""Return the unit the native value is expressed in."""
...
@property
def _unit_converter(self) -> BaseUnitConverter | None:
"""Return the unit converter."""
return None # pragma: nocover
class TemperatureProperty(FloatProperty, ABC):
"""Base class for temperature properties."""
instance = FloatPropertyInstance.TEMPERATURE
@property
def parameters(self) -> TemperatureFloatPropertyParameters:
"""Return parameters for a devices list request."""
return TemperatureFloatPropertyParameters(unit=self.unit_of_measurement.as_property_unit)
@property
def unit_of_measurement(self) -> UnitOfTemperature:
"""Return the unit the property value is expressed in."""
if self._native_unit_of_measurement:
with suppress(ValueError):
unit = UnitOfTemperature(self._native_unit_of_measurement)
if unit.as_property_unit:
return unit
return UnitOfTemperature.CELSIUS
@property
def _unit_converter(self) -> TemperatureConverter:
"""Return the unit converter."""
return TemperatureConverter()
class HumidityProperty(FloatProperty, ABC):
"""Base class for humidity properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.HUMIDITY
@property
def parameters(self) -> HumidityFloatPropertyParameters:
"""Return parameters for a devices list request."""
return HumidityFloatPropertyParameters()
class PressureProperty(FloatProperty, ABC):
"""Base class for pressure properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.PRESSURE
@property
def parameters(self) -> PressureFloatPropertyParameters:
"""Return parameters for a devices list request."""
return PressureFloatPropertyParameters(unit=self.unit_of_measurement.as_property_unit)
@property
def unit_of_measurement(self) -> UnitOfPressure:
"""Return the unit the property value is expressed in."""
if self._native_unit_of_measurement:
with suppress(ValueError):
unit = UnitOfPressure(self._native_unit_of_measurement)
if unit.as_property_unit:
return unit
return UnitOfPressure.MMHG
@property
def _unit_converter(self) -> PressureConverter:
"""Return the unit converter."""
return PressureConverter()
class IlluminationProperty(FloatProperty, ABC):
"""Base class for illumination properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.ILLUMINATION
@property
def parameters(self) -> IlluminationFloatPropertyParameters:
"""Return parameters for a devices list request."""
return IlluminationFloatPropertyParameters()
class FoodLevelPercentageProperty(FloatProperty, Protocol):
"""Base class for food level (%) properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.FOOD_LEVEL
@property
def parameters(self) -> FoodLevelFloatPropertyParameters:
"""Return parameters for a devices list request."""
return FoodLevelFloatPropertyParameters()
class WaterLevelPercentageProperty(FloatProperty, Protocol):
"""Base class for water level (%) properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.WATER_LEVEL
@property
def parameters(self) -> WaterLevelFloatPropertyParameters:
"""Return parameters for a devices list request."""
return WaterLevelFloatPropertyParameters()
class CO2LevelProperty(FloatProperty, Protocol):
"""Base class for CO2 level properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.CO2_LEVEL
@property
def parameters(self) -> CO2LevelFloatPropertyParameters:
"""Return parameters for a devices list request."""
return CO2LevelFloatPropertyParameters()
class MeterProperty(FloatProperty, Protocol):
"""Base class for meter properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.METER
@property
def parameters(self) -> MeterFloatPropertyParameters:
"""Return parameters for a devices list request."""
return MeterFloatPropertyParameters()
class ElectricityMeterProperty(FloatProperty, Protocol):
"""Base class for electricity meter properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.ELECTRICITY_METER
@property
def parameters(self) -> ElectricityMeterFloatPropertyParameters:
"""Return parameters for a devices list request."""
return ElectricityMeterFloatPropertyParameters()
@property
def unit_of_measurement(self) -> UnitOfEnergy:
"""Return the unit the property value is expressed in."""
return UnitOfEnergy.KILO_WATT_HOUR
@property
def _unit_converter(self) -> EnergyConverter:
"""Return the unit converter."""
return EnergyConverter()
class GasMeterProperty(FloatProperty, Protocol):
"""Base class for gas meter properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.GAS_METER
@property
def parameters(self) -> GasMeterFloatPropertyParameters:
"""Return parameters for a devices list request."""
return GasMeterFloatPropertyParameters()
@property
def unit_of_measurement(self) -> UnitOfVolume:
"""Return the unit the property value is expressed in."""
return UnitOfVolume.CUBIC_METERS
@property
def _unit_converter(self) -> VolumeConverter:
"""Return the unit converter."""
return VolumeConverter()
class HeatMeterProperty(FloatProperty, Protocol):
"""Base class for heat meter properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.HEAT_METER
@property
def parameters(self) -> HeatMeterFloatPropertyParameters:
"""Return parameters for a devices list request."""
return HeatMeterFloatPropertyParameters()
class WaterMeterProperty(FloatProperty, Protocol):
"""Base class for water meter properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.WATER_METER
@property
def parameters(self) -> WaterMeterFloatPropertyParameters:
"""Return parameters for a devices list request."""
return WaterMeterFloatPropertyParameters()
@property
def unit_of_measurement(self) -> UnitOfVolume:
"""Return the unit the property value is expressed in."""
return UnitOfVolume.CUBIC_METERS
@property
def _unit_converter(self) -> VolumeConverter:
"""Return the unit converter."""
return VolumeConverter()
class PM1DensityProperty(FloatProperty, Protocol):
"""Base class for PM1 density properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.PM1_DENSITY
@property
def parameters(self) -> PM1DensityFloatPropertyParameters:
"""Return parameters for a devices list request."""
return PM1DensityFloatPropertyParameters()
class PM25DensityProperty(FloatProperty, Protocol):
"""Base class for PM2.5 density properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.PM2_5_DENSITY
@property
def parameters(self) -> PM25DensityFloatPropertyParameters:
"""Return parameters for a devices list request."""
return PM25DensityFloatPropertyParameters()
class PM10DensityProperty(FloatProperty, Protocol):
"""Base class for PM10 density properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.PM10_DENSITY
@property
def parameters(self) -> PM10DensityFloatPropertyParameters:
"""Return parameters for a devices list request."""
return PM10DensityFloatPropertyParameters()
class TVOCConcentrationProperty(FloatProperty, Protocol):
"""Base class for TVOC concentration properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.TVOC
@property
def parameters(self) -> TVOCFloatPropertyParameters:
"""Return parameters for a devices list request."""
return TVOCFloatPropertyParameters()
@property
def unit_of_measurement(self) -> str:
"""Return the unit the property value is expressed in."""
return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
@property
def _unit_converter(self) -> TVOCConcentrationConverter:
"""Return the unit converter."""
return TVOCConcentrationConverter()
class VoltageProperty(FloatProperty, Protocol):
"""Base class for voltage properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.VOLTAGE
@property
def parameters(self) -> VoltageFloatPropertyParameters:
"""Return parameters for a devices list request."""
return VoltageFloatPropertyParameters()
@property
def unit_of_measurement(self) -> str:
"""Return the unit the property value is expressed in."""
return UnitOfElectricPotential.VOLT
@property
def _unit_converter(self) -> ElectricPotentialConverter:
"""Return the unit converter."""
return ElectricPotentialConverter()
class ElectricCurrentProperty(FloatProperty, Protocol):
"""Base class for electric current properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.AMPERAGE
@property
def parameters(self) -> AmperageFloatPropertyParameters:
"""Return parameters for a devices list request."""
return AmperageFloatPropertyParameters()
@property
def unit_of_measurement(self) -> str:
"""Return the unit the property value is expressed in."""
return UnitOfElectricCurrent.AMPERE
@property
def _unit_converter(self) -> ElectricCurrentConverter:
"""Return the unit converter."""
return ElectricCurrentConverter()
class ElectricPowerProperty(FloatProperty, Protocol):
"""Base class for electric power properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.POWER
@property
def parameters(self) -> PowerFloatPropertyParameters:
"""Return parameters for a devices list request."""
return PowerFloatPropertyParameters()
@property
def unit_of_measurement(self) -> str:
"""Return the unit the property value is expressed in."""
return UnitOfPower.WATT
@property
def _unit_converter(self) -> PowerConverter:
"""Return the unit converter."""
return PowerConverter()
class BatteryLevelPercentageProperty(FloatProperty, Protocol):
"""Base class for battery level (%) properties."""
instance: FloatPropertyInstance = FloatPropertyInstance.BATTERY_LEVEL
@property
def parameters(self) -> BatteryLevelFloatPropertyParameters:
"""Return parameters for a devices list request."""
return BatteryLevelFloatPropertyParameters()
class StateFloatProperty(StateProperty, FloatProperty):
"""Base class for a float property based on the state."""
@cached_property
def _native_unit_of_measurement(self) -> str | None:
"""Return the unit the native value is expressed in."""
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
return None
class TemperatureSensor(StateFloatProperty, TemperatureProperty):
"""Representaton of the state as a temperature sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
if self._state_device_class == SensorDeviceClass.TEMPERATURE:
return True
if self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) in UnitOfTemperature.__members__.values():
return True
case air_quality.DOMAIN:
return self.state.attributes.get(ATTR_TEMPERATURE) is not None
case climate.DOMAIN | fan.DOMAIN | humidifier.DOMAIN | water_heater.DOMAIN:
return self.state.attributes.get(ATTR_CURRENT_TEMPERATURE) is not None
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
match self.state.domain:
case air_quality.DOMAIN:
return self.state.attributes.get(ATTR_TEMPERATURE)
case climate.DOMAIN | fan.DOMAIN | humidifier.DOMAIN | water_heater.DOMAIN:
return self.state.attributes.get(ATTR_CURRENT_TEMPERATURE)
return self.state.state
class HumiditySensor(StateFloatProperty, HumidityProperty):
"""Representaton of the state as a humidity sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
return self._state_device_class in (SensorDeviceClass.HUMIDITY, SensorDeviceClass.MOISTURE)
case air_quality.DOMAIN:
return self.state.attributes.get(ATTR_HUMIDITY) is not None
case climate.DOMAIN | fan.DOMAIN | humidifier.DOMAIN:
return self.state.attributes.get(ATTR_CURRENT_HUMIDITY) is not None
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
match self.state.domain:
case air_quality.DOMAIN:
return self.state.attributes.get(ATTR_HUMIDITY)
case climate.DOMAIN | fan.DOMAIN | humidifier.DOMAIN:
return self.state.attributes.get(ATTR_CURRENT_HUMIDITY)
return self.state.state
class PressureSensor(StateFloatProperty, PressureProperty):
"""Representaton of the state as a pressure sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == sensor.DOMAIN and self._state_device_class in (
SensorDeviceClass.PRESSURE,
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
)
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
return self.state.state
class IlluminationSensor(StateFloatProperty, IlluminationProperty):
"""Representaton of the state as a illumination sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
if self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.ILLUMINANCE:
return True
if self.state.domain in (sensor.DOMAIN, light.DOMAIN, fan.DOMAIN):
return ATTR_ILLUMINANCE in self.state.attributes
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.ILLUMINANCE:
return self.state.state
return self.state.attributes.get(ATTR_ILLUMINANCE)
class WaterLevelPercentageSensor(StateFloatProperty, WaterLevelPercentageProperty):
"""Representaton of the state as a water level sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
if self.state.domain in (fan.DOMAIN, humidifier.DOMAIN):
return ATTR_WATER_LEVEL in self.state.attributes
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
return self.state.attributes.get(ATTR_WATER_LEVEL)
class CO2LevelSensor(StateFloatProperty, CO2LevelProperty):
"""Representaton of the state as a CO2 level sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
return self._state_device_class == SensorDeviceClass.CO2
case air_quality.DOMAIN | fan.DOMAIN:
return ATTR_CO2 in self.state.attributes
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == sensor.DOMAIN:
return self.state.state
return self.state.attributes.get(ATTR_CO2)
class ElectricityMeterSensor(StateFloatProperty, ElectricityMeterProperty):
"""Representaton of the state as a electricity meter sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.ENERGY
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
return self.state.state
class GasMeterSensor(StateFloatProperty, GasMeterProperty):
"""Representaton of the state as a gas meter sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.GAS
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
return self.state.state
class WaterMeterSensor(StateFloatProperty, WaterMeterProperty):
"""Representaton of the state as a water meter sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.WATER
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
return self.state.state
class PM1DensitySensor(StateFloatProperty, PM1DensityProperty):
"""Representaton of the state as a PM1 density sensor."""
instance = FloatPropertyInstance.PM1_DENSITY
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
return self._state_device_class == SensorDeviceClass.PM1
case air_quality.DOMAIN:
return ATTR_PM_0_1 in self.state.attributes
return False
@property
def parameters(self) -> PM1DensityFloatPropertyParameters:
"""Return parameters for a devices list request."""
return PM1DensityFloatPropertyParameters()
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == sensor.DOMAIN:
return self.state.state
return self.state.attributes.get(ATTR_PM_0_1)
class PM25DensitySensor(StateFloatProperty, PM25DensityProperty):
"""Representaton of the state as a PM2.5 density sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
return self._state_device_class == SensorDeviceClass.PM25
case air_quality.DOMAIN:
return ATTR_PM_2_5 in self.state.attributes
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == sensor.DOMAIN:
return self.state.state
return self.state.attributes.get(ATTR_PM_2_5)
class PM10DensitySensor(StateFloatProperty, PM10DensityProperty):
"""Representaton of the state as a PM10 density sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
return self._state_device_class == SensorDeviceClass.PM10
case air_quality.DOMAIN:
return ATTR_PM_10 in self.state.attributes
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == sensor.DOMAIN:
return self.state.state
return self.state.attributes.get(ATTR_PM_10)
class TVOCConcentrationSensor(StateFloatProperty, TVOCConcentrationProperty):
"""Representaton of the state as a TVOC concentration sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
return self._state_device_class in (
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
XGW3DeviceClass.TVOC,
)
case air_quality.DOMAIN:
return ATTR_TVOC in self.state.attributes
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == sensor.DOMAIN:
return self.state.state
return self.state.attributes.get(ATTR_TVOC)
class VOCConcentrationSensor(StateFloatProperty, TVOCConcentrationProperty):
"""Representaton of the state as a VOC concentration sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
return (
self.state.domain == sensor.DOMAIN
and self._state_device_class == SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
return self.state.state
@property
def _native_unit_of_measurement(self) -> str | None:
return None
class VoltageSensor(StateFloatProperty, VoltageProperty):
"""Representaton of the state as a voltage sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
return self._state_device_class == SensorDeviceClass.VOLTAGE
case switch.DOMAIN | light.DOMAIN:
return ATTR_VOLTAGE in self.state.attributes
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == sensor.DOMAIN:
return self.state.state
return self.state.attributes.get(ATTR_VOLTAGE)
class ElectricCurrentSensor(StateFloatProperty, ElectricCurrentProperty):
"""Representaton of the state as a electric current sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
match self.state.domain:
case sensor.DOMAIN:
return self._state_device_class == SensorDeviceClass.CURRENT
case switch.DOMAIN | light.DOMAIN:
return ATTR_CURRENT in self.state.attributes
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == sensor.DOMAIN:
return self.state.state
return self.state.attributes.get(ATTR_CURRENT)
class ElectricPowerSensor(StateFloatProperty, ElectricPowerProperty):
"""Representaton of the state as a electric power sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
if self.state.domain == sensor.DOMAIN:
return self._state_device_class == SensorDeviceClass.POWER
if self.state.domain == switch.DOMAIN:
for attribute in (ATTR_POWER, ATTR_LOAD_POWER, ATTR_CURRENT_CONSUMPTION):
if attribute in self.state.attributes:
return True
return False
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
if self.state.domain == switch.DOMAIN:
for attribute in (ATTR_POWER, ATTR_LOAD_POWER, ATTR_CURRENT_CONSUMPTION):
if attribute in self.state.attributes:
return self.state.attributes.get(attribute)
return self.state.state
class BatteryLevelPercentageSensor(StateFloatProperty, BatteryLevelPercentageProperty):
"""Representaton of the state as battery level sensor."""
@property
def supported(self) -> bool:
"""Test if the property is supported."""
if (
self._state_device_class == SensorDeviceClass.BATTERY
and self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
):
return True
return ATTR_BATTERY_LEVEL in self.state.attributes
def _get_native_value(self) -> float | str | None:
"""Return the current property value without conversion."""
value = None
if self._state_device_class == SensorDeviceClass.BATTERY:
value = self.state.state
elif ATTR_BATTERY_LEVEL in self.state.attributes:
value = self.state.attributes.get(ATTR_BATTERY_LEVEL)
if value in [STATE_LOW, STATE_CHARGING]:
return 0
return value
STATE_PROPERTIES_REGISTRY.register(TemperatureSensor)
STATE_PROPERTIES_REGISTRY.register(HumiditySensor)
STATE_PROPERTIES_REGISTRY.register(PressureSensor)
STATE_PROPERTIES_REGISTRY.register(IlluminationSensor)
STATE_PROPERTIES_REGISTRY.register(WaterLevelPercentageSensor)
STATE_PROPERTIES_REGISTRY.register(CO2LevelSensor)
STATE_PROPERTIES_REGISTRY.register(ElectricityMeterSensor)
STATE_PROPERTIES_REGISTRY.register(GasMeterSensor)
STATE_PROPERTIES_REGISTRY.register(WaterMeterSensor)
STATE_PROPERTIES_REGISTRY.register(PM1DensitySensor)
STATE_PROPERTIES_REGISTRY.register(PM25DensitySensor)
STATE_PROPERTIES_REGISTRY.register(PM10DensitySensor)
STATE_PROPERTIES_REGISTRY.register(TVOCConcentrationSensor)
STATE_PROPERTIES_REGISTRY.register(VOCConcentrationSensor)
STATE_PROPERTIES_REGISTRY.register(VoltageSensor)
STATE_PROPERTIES_REGISTRY.register(ElectricCurrentSensor)
STATE_PROPERTIES_REGISTRY.register(ElectricPowerSensor)
STATE_PROPERTIES_REGISTRY.register(BatteryLevelPercentageSensor)

View File

@@ -0,0 +1,123 @@
"""Repairs for the Yandex Smart Home."""
from typing import TYPE_CHECKING, cast
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import entity_registry as er, issue_registry as ir, label_registry as lr
from homeassistant.helpers.entityfilter import CONF_INCLUDE_ENTITIES
from homeassistant.helpers.selector import BooleanSelector
import voluptuous as vol
from . import DOMAIN
from .const import CONF_ADD_LABEL, CONF_FILTER, CONF_LABEL, ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND, EntityFilterSource
from .entry_data import ConfigEntryData
if TYPE_CHECKING:
from . import YandexSmartHome
class EmptyRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow without any side effects."""
async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return self.async_create_entry(data={})
class UnexposedEntityFoundConfigEntryRepairFlow(RepairsFlow):
"""Handler for an "unexposed entity found" issue fixing flow."""
def __init__(self, entry_data: ConfigEntryData) -> None:
"""Initialize the flow."""
self._entry_data = entry_data
async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
if user_input[CONF_INCLUDE_ENTITIES]:
entry = self._entry_data.entry
options = entry.options.copy()
options[CONF_FILTER] = {
CONF_INCLUDE_ENTITIES: sorted(
set(entry.options[CONF_FILTER][CONF_INCLUDE_ENTITIES]) | self._entry_data.unexposed_entities
)
}
self.hass.config_entries.async_update_entry(entry, options=options)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({vol.Required(CONF_INCLUDE_ENTITIES): BooleanSelector()}),
)
class UnexposedEntityFoundLabelRepairFlow(RepairsFlow):
"""Handler for an "unexposed entity found" issue fixing flow."""
def __init__(self, entry_data: ConfigEntryData) -> None:
"""Initialize the flow."""
self._entry_data = entry_data
async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> FlowResult:
"""Handle the confirm step of a fix flow."""
label = self._entry_data.entry.options[CONF_LABEL]
label_entry = lr.async_get(self.hass).async_get_label(label)
if user_input is not None:
if user_input[CONF_ADD_LABEL]:
registry = er.async_get(self.hass)
for entity_id in self._entry_data.unexposed_entities:
if entity := registry.async_get(entity_id):
registry.async_update_entity(
entity.entity_id,
labels=entity.labels | {label},
)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({vol.Required(CONF_ADD_LABEL): BooleanSelector()}),
description_placeholders={CONF_LABEL: label_entry.name if label_entry else label},
)
async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None
) -> RepairsFlow:
"""Create flow."""
assert data is not None
entry = hass.config_entries.async_get_entry(cast(str, data["entry_id"]))
if not entry or DOMAIN not in hass.data:
return EmptyRepairFlow()
component: YandexSmartHome = hass.data[DOMAIN]
try:
entry_data = component.get_entry_data(entry)
except KeyError:
return EmptyRepairFlow()
if issue_id == ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + EntityFilterSource.CONFIG_ENTRY:
return UnexposedEntityFoundConfigEntryRepairFlow(entry_data)
if issue_id == ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + EntityFilterSource.LABEL:
return UnexposedEntityFoundLabelRepairFlow(entry_data)
raise NotImplementedError
def delete_unexposed_entity_found_issues(hass: HomeAssistant) -> None:
"""Delete repair issues for an unexposed entity."""
for filter_source in EntityFilterSource:
ir.async_delete_issue(hass, DOMAIN, ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + filter_source)

View File

@@ -0,0 +1,16 @@
"""Yandex Smart Home API schemas."""
# ruff: noqa
from .callback import *
from .capability import *
from .capability_color import *
from .capability_mode import *
from .capability_onoff import *
from .capability_range import *
from .capability_toggle import *
from .capability_video import *
from .device import *
from .property import *
from .property_event import *
from .property_float import *
from .response import *

View File

@@ -0,0 +1,22 @@
"""Base class for API response schemas."""
from typing import Any
from pydantic.v1 import BaseModel
from pydantic.v1.generics import GenericModel
class APIModel(BaseModel):
"""Base API response model."""
def as_json(self) -> str:
"""Generate a JSON representation of the model."""
return super().json(exclude_none=True, ensure_ascii=False)
def as_dict(self) -> dict[str, Any]:
"""Generate a dictionary representation of the model."""
return super().dict(exclude_none=True)
class GenericAPIModel(GenericModel, APIModel):
"""Base generic API response model."""

View File

@@ -0,0 +1,57 @@
"""Schema for event notification service.
https://yandex.ru/dev/dialogs/smart-home/doc/reference-alerts/resources-alerts.html
"""
from enum import StrEnum
import time
from pydantic.v1 import Field
from .base import APIModel
from .device import DeviceState
class CallbackStatesRequestPayload(APIModel):
"""Payload of request body for notification about device state change."""
user_id: str
devices: list[DeviceState]
class CallbackStatesRequest(APIModel):
"""Request body for notification about device state change."""
ts: float = Field(default_factory=lambda: time.time())
payload: CallbackStatesRequestPayload
class CallbackDiscoveryRequestPayload(APIModel):
"""Payload of request body for notification about change of devices' parameters."""
user_id: str
class CallbackDiscoveryRequest(APIModel):
"""Request body for notification about change of devices' parameters."""
ts: float = Field(default_factory=lambda: time.time())
payload: CallbackDiscoveryRequestPayload
class CallbackResponseStatus(StrEnum):
"""Status of a callback request."""
OK = "ok"
ERROR = "error"
class CallbackResponse(APIModel):
"""Response on a callback request."""
status: CallbackResponseStatus
error_code: str | None
error_message: str | None
CallbackRequest = CallbackDiscoveryRequest | CallbackStatesRequest

View File

@@ -0,0 +1,160 @@
"""Schema for device capabilities."""
from enum import StrEnum
from typing import Annotated, Any, Literal, TypeVar, Union
from pydantic.v1 import Field
from .base import APIModel
from .capability_color import (
ColorSettingCapabilityInstance,
ColorSettingCapabilityInstanceActionState,
ColorSettingCapabilityParameters,
RGBInstanceActionState,
SceneInstanceActionState,
TemperatureKInstanceActionState,
)
from .capability_mode import ModeCapabilityInstance, ModeCapabilityInstanceActionState, ModeCapabilityParameters
from .capability_onoff import OnOffCapabilityInstance, OnOffCapabilityInstanceActionState, OnOffCapabilityParameters
from .capability_range import RangeCapabilityInstance, RangeCapabilityInstanceActionState, RangeCapabilityParameters
from .capability_toggle import ToggleCapabilityInstance, ToggleCapabilityInstanceActionState, ToggleCapabilityParameters
from .capability_video import (
GetStreamInstanceActionResultValue,
GetStreamInstanceActionState,
VideoStreamCapabilityInstance,
VideoStreamCapabilityParameters,
)
class CapabilityType(StrEnum):
"""Capability type."""
ON_OFF = "devices.capabilities.on_off"
COLOR_SETTING = "devices.capabilities.color_setting"
MODE = "devices.capabilities.mode"
RANGE = "devices.capabilities.range"
TOGGLE = "devices.capabilities.toggle"
VIDEO_STREAM = "devices.capabilities.video_stream"
@property
def short(self) -> str:
"""Return short version of the capability type."""
return str(self).replace("devices.capabilities.", "")
CapabilityParameters = (
OnOffCapabilityParameters
| ColorSettingCapabilityParameters
| ModeCapabilityParameters
| RangeCapabilityParameters
| ToggleCapabilityParameters
| VideoStreamCapabilityParameters
)
"""Parameters of a capability for a device list request."""
CapabilityInstance = (
OnOffCapabilityInstance
| ColorSettingCapabilityInstance
| ModeCapabilityInstance
| RangeCapabilityInstance
| ToggleCapabilityInstance
| VideoStreamCapabilityInstance
)
"""All capability instances."""
class CapabilityDescription(APIModel):
"""Description of a capability for a device list request."""
type: CapabilityType
retrievable: bool
reportable: bool
parameters: CapabilityParameters | None
class CapabilityInstanceStateValue(APIModel):
"""Capability instance value."""
instance: CapabilityInstance
value: Any
class CapabilityInstanceState(APIModel):
"""Capability state for state query and callback requests."""
type: CapabilityType
state: CapabilityInstanceStateValue
class OnOffCapabilityInstanceAction(APIModel):
"""New capability state for a state change request of on_off capability."""
type: Literal[CapabilityType.ON_OFF] = CapabilityType.ON_OFF
state: OnOffCapabilityInstanceActionState
class ColorSettingCapabilityInstanceAction(APIModel):
"""New capability state for a state change request of color_setting capability."""
type: Literal[CapabilityType.COLOR_SETTING] = CapabilityType.COLOR_SETTING
state: ColorSettingCapabilityInstanceActionState
class ModeCapabilityInstanceAction(APIModel):
"""New capability state for a state change request of mode capability."""
type: Literal[CapabilityType.MODE] = CapabilityType.MODE
state: ModeCapabilityInstanceActionState
class RangeCapabilityInstanceAction(APIModel):
"""New capability state for a state change request of range capability."""
type: Literal[CapabilityType.RANGE] = CapabilityType.RANGE
state: RangeCapabilityInstanceActionState
class ToggleCapabilityInstanceAction(APIModel):
"""New capability state for a state change request of toggle capability."""
type: Literal[CapabilityType.TOGGLE] = CapabilityType.TOGGLE
state: ToggleCapabilityInstanceActionState
class VideoStreamCapabilityInstanceAction(APIModel):
"""New capability state for a state change request of video_stream capability."""
type: Literal[CapabilityType.VIDEO_STREAM] = CapabilityType.VIDEO_STREAM
state: GetStreamInstanceActionState
CapabilityInstanceAction = Annotated[
Union[
OnOffCapabilityInstanceAction,
ColorSettingCapabilityInstanceAction,
ModeCapabilityInstanceAction,
RangeCapabilityInstanceAction,
ToggleCapabilityInstanceAction,
VideoStreamCapabilityInstanceAction,
],
Field(discriminator="type"),
]
"""New capability state including type for a state change request."""
CapabilityInstanceActionState = TypeVar(
"CapabilityInstanceActionState",
OnOffCapabilityInstanceActionState,
ColorSettingCapabilityInstanceActionState,
RGBInstanceActionState,
TemperatureKInstanceActionState,
SceneInstanceActionState,
ModeCapabilityInstanceActionState,
RangeCapabilityInstanceActionState,
ToggleCapabilityInstanceActionState,
GetStreamInstanceActionState,
contravariant=True,
)
"""New capability state for a state change request."""
CapabilityInstanceActionResultValue = GetStreamInstanceActionResultValue | None
"""Result of a capability state change."""

View File

@@ -0,0 +1,111 @@
"""Schema for color_setting capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/color_setting.html
"""
from enum import StrEnum
from typing import Annotated, Any, Literal, Self, Union
from pydantic.v1 import Field, root_validator
from .base import APIModel
class ColorSettingCapabilityInstance(StrEnum):
"""Instance of a color_setting capability."""
BASE = "base"
RGB = "rgb"
HSV = "hsv"
TEMPERATURE_K = "temperature_k"
SCENE = "scene"
class ColorScene(StrEnum):
"""Color scene."""
ALARM = "alarm"
ALICE = "alice"
CANDLE = "candle"
DINNER = "dinner"
FANTASY = "fantasy"
GARLAND = "garland"
JUNGLE = "jungle"
MOVIE = "movie"
NEON = "neon"
NIGHT = "night"
OCEAN = "ocean"
PARTY = "party"
READING = "reading"
REST = "rest"
ROMANCE = "romance"
SIREN = "siren"
SUNRISE = "sunrise"
SUNSET = "sunset"
class CapabilityParameterColorModel(StrEnum):
"""Color model."""
RGB = "rgb"
HSV = "hsv"
class CapabilityParameterTemperatureK(APIModel):
"""Color temperature range."""
min: int
max: int
class CapabilityParameterColorScene(APIModel):
"""Parameter of a scene instance."""
scenes: list[dict[Literal["id"], ColorScene]]
@classmethod
def from_list(cls, scenes: list[ColorScene]) -> Self:
return cls(scenes=[{"id": s} for s in scenes])
class ColorSettingCapabilityParameters(APIModel):
"""Parameters of a color_setting capability."""
color_model: CapabilityParameterColorModel | None = None
temperature_k: CapabilityParameterTemperatureK | None = None
color_scene: CapabilityParameterColorScene | None = None
@root_validator
def any_of(cls, values: dict[str, Any]) -> dict[str, Any]:
if not any(values.values()):
raise ValueError("one of color_model, temperature_k or color_scene must have a value")
return values
class RGBInstanceActionState(APIModel):
"""New value for a rgb instance."""
instance: Literal[ColorSettingCapabilityInstance.RGB] = ColorSettingCapabilityInstance.RGB
value: int
class TemperatureKInstanceActionState(APIModel):
"""New value for a temperature_k instance."""
instance: Literal[ColorSettingCapabilityInstance.TEMPERATURE_K] = ColorSettingCapabilityInstance.TEMPERATURE_K
value: int
class SceneInstanceActionState(APIModel):
"""New value for a scene instance."""
instance: Literal[ColorSettingCapabilityInstance.SCENE] = ColorSettingCapabilityInstance.SCENE
value: ColorScene
ColorSettingCapabilityInstanceActionState = Annotated[
Union[RGBInstanceActionState, TemperatureKInstanceActionState, SceneInstanceActionState],
Field(discriminator="instance"),
]
"""New value for an instance of color_setting capability."""

View File

@@ -0,0 +1,133 @@
"""Schema for mode capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode.html
"""
from enum import StrEnum
from typing import Literal, Self
from .base import APIModel
class ModeCapabilityInstance(StrEnum):
"""Instance of a mode capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance.html
"""
CLEANUP_MODE = "cleanup_mode"
COFFEE_MODE = "coffee_mode"
DISHWASHING = "dishwashing"
FAN_SPEED = "fan_speed"
HEAT = "heat"
INPUT_SOURCE = "input_source"
PROGRAM = "program"
SWING = "swing"
TEA_MODE = "tea_mode"
THERMOSTAT = "thermostat"
VENTILATION_MODE = "ventilation_mode"
WORK_SPEED = "work_speed"
class ModeCapabilityMode(StrEnum):
"""Mode value of a mode capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance-modes.html
"""
WET_CLEANING = "wet_cleaning"
DRY_CLEANING = "dry_cleaning"
MIXED_CLEANING = "mixed_cleaning"
AUTO = "auto"
ECO = "eco"
SMART = "smart"
TURBO = "turbo"
COOL = "cool"
DRY = "dry"
FAN_ONLY = "fan_only"
HEAT = "heat"
PREHEAT = "preheat"
HIGH = "high"
LOW = "low"
MEDIUM = "medium"
MAX = "max"
MIN = "min"
FAST = "fast"
SLOW = "slow"
EXPRESS = "express"
NORMAL = "normal"
QUIET = "quiet"
HORIZONTAL = "horizontal"
STATIONARY = "stationary"
VERTICAL = "vertical"
SUPPLY_AIR = "supply_air"
EXTRACTION_AIR = "extraction_air"
ONE = "one"
TWO = "two"
THREE = "three"
FOUR = "four"
FIVE = "five"
SIX = "six"
SEVEN = "seven"
EIGHT = "eight"
NINE = "nine"
TEN = "ten"
AMERICANO = "americano"
CAPPUCCINO = "cappuccino"
DOUBLE = "double"
ESPRESSO = "espresso"
DOUBLE_ESPRESSO = "double_espresso"
LATTE = "latte"
BLACK_TEA = "black_tea"
FLOWER_TEA = "flower_tea"
GREEN_TEA = "green_tea"
HERBAL_TEA = "herbal_tea"
OOLONG_TEA = "oolong_tea"
PUERH_TEA = "puerh_tea"
RED_TEA = "red_tea"
WHITE_TEA = "white_tea"
GLASS = "glass"
INTENSIVE = "intensive"
PRE_RINSE = "pre_rinse"
ASPIC = "aspic"
BABY_FOOD = "baby_food"
BAKING = "baking"
BREAD = "bread"
BOILING = "boiling"
CEREALS = "cereals"
CHEESECAKE = "cheesecake"
DEEP_FRYER = "deep_fryer"
DESSERT = "dessert"
FOWL = "fowl"
FRYING = "frying"
MACARONI = "macaroni"
MILK_PORRIDGE = "milk_porridge"
MULTICOOKER = "multicooker"
PASTA = "pasta"
PILAF = "pilaf"
PIZZA = "pizza"
SAUCE = "sauce"
SLOW_COOK = "slow_cook"
SOUP = "soup"
STEAM = "steam"
STEWING = "stewing"
VACUUM = "vacuum"
YOGURT = "yogurt"
class ModeCapabilityParameters(APIModel):
"""Parameters of a mode capability."""
instance: ModeCapabilityInstance
modes: list[dict[Literal["value"], ModeCapabilityMode]]
@classmethod
def from_list(cls, instance: ModeCapabilityInstance, modes: list[ModeCapabilityMode]) -> Self:
return cls(instance=instance, modes=[{"value": m} for m in modes])
class ModeCapabilityInstanceActionState(APIModel):
"""New value for a mode capability."""
instance: ModeCapabilityInstance
value: ModeCapabilityMode

View File

@@ -0,0 +1,27 @@
"""Schema for on_off capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/on_off.html
"""
from enum import StrEnum
from .base import APIModel
class OnOffCapabilityInstance(StrEnum):
"""Instance of an on_off capability."""
ON = "on"
class OnOffCapabilityParameters(APIModel):
"""Parameters of a on_off capability."""
split: bool
class OnOffCapabilityInstanceActionState(APIModel):
"""New value for an on_off capability."""
instance: OnOffCapabilityInstance
value: bool

View File

@@ -0,0 +1,99 @@
"""Schema for range capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/range.html
"""
from enum import StrEnum
from typing import Any
from pydantic.v1 import root_validator, validator
from .base import APIModel
class RangeCapabilityUnit(StrEnum):
"""Unit used in a range capability."""
PERCENT = "unit.percent"
TEMPERATURE_CELSIUS = "unit.temperature.celsius"
class RangeCapabilityInstance(StrEnum):
"""Instance of a range capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/range-instance.html
"""
BRIGHTNESS = "brightness"
CHANNEL = "channel"
HUMIDITY = "humidity"
OPEN = "open"
TEMPERATURE = "temperature"
VOLUME = "volume"
class RangeCapabilityRange(APIModel):
"""Value range of a range capability."""
min: float
max: float
precision: float
def __str__(self) -> str:
return f"[{self.min}, {self.max}]"
class RangeCapabilityParameters(APIModel):
"""Parameters of a range capability."""
instance: RangeCapabilityInstance
unit: RangeCapabilityUnit | None = None
random_access: bool
range: RangeCapabilityRange | None = None
@root_validator
def compute_unit(cls, values: dict[str, Any]) -> dict[str, Any]:
"""Return value unit for a capability instance."""
match values.get("instance"):
case RangeCapabilityInstance.BRIGHTNESS:
values["unit"] = RangeCapabilityUnit.PERCENT
case RangeCapabilityInstance.HUMIDITY:
values["unit"] = RangeCapabilityUnit.PERCENT
case RangeCapabilityInstance.OPEN:
values["unit"] = RangeCapabilityUnit.PERCENT
case RangeCapabilityInstance.TEMPERATURE:
values["unit"] = RangeCapabilityUnit.TEMPERATURE_CELSIUS
return values
@root_validator
def validate_range(cls, values: dict[str, Any]) -> dict[str, Any]:
"""Force range boundaries for a capability instance."""
r: RangeCapabilityRange | None
if r := values.get("range"):
match values.get("instance"):
case RangeCapabilityInstance.HUMIDITY | RangeCapabilityInstance.OPEN:
r.min, r.max = max([0.0, r.min]), min([100.0, r.max])
case RangeCapabilityInstance.BRIGHTNESS:
r.min = max(min(r.min, 1.0), 0.0)
r.max = 100.0
r.precision = 1.0
return values
class RangeCapabilityInstanceActionState(APIModel):
"""New value for a range capability."""
instance: RangeCapabilityInstance
value: float
relative: bool = False
@validator("relative", pre=True, always=True)
def set_relative(cls, v: Any) -> Any:
"""Update relative value."""
if v is None: # VK
return False
return v

View File

@@ -0,0 +1,36 @@
"""Schema for toggle capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/toggle.html
"""
from enum import StrEnum
from .base import APIModel
class ToggleCapabilityInstance(StrEnum):
"""Instance of a toggle capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/toggle-instance.html
"""
BACKLIGHT = "backlight"
CONTROLS_LOCKED = "controls_locked"
IONIZATION = "ionization"
KEEP_WARM = "keep_warm"
MUTE = "mute"
OSCILLATION = "oscillation"
PAUSE = "pause"
class ToggleCapabilityParameters(APIModel):
"""Parameters of a toggle capability."""
instance: ToggleCapabilityInstance
class ToggleCapabilityInstanceActionState(APIModel):
"""New value for a toggle capability."""
instance: ToggleCapabilityInstance
value: bool

View File

@@ -0,0 +1,43 @@
"""Schema for video_stream capability.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/video_stream.html
"""
from enum import StrEnum
from typing import Literal
from .base import APIModel
StreamProtocols = list[Literal["hls"]]
class VideoStreamCapabilityInstance(StrEnum):
"""Instance of a video_stream capability."""
GET_STREAM = "get_stream"
class VideoStreamCapabilityParameters(APIModel):
"""Parameters of a video_stream capability."""
protocols: StreamProtocols
class GetStreamInstanceActionStateValue(APIModel):
"""New state value for a get_stream instance."""
protocols: StreamProtocols
class GetStreamInstanceActionState(APIModel):
"""New value for a get_stream instance."""
instance: Literal[VideoStreamCapabilityInstance.GET_STREAM]
value: GetStreamInstanceActionStateValue
class GetStreamInstanceActionResultValue(APIModel):
"""New value after a get_stream instance state changed."""
stream_url: str
protocol: Literal["hls"]

View File

@@ -0,0 +1,197 @@
"""Schema for an user device.
https://yandex.ru/dev/dialogs/smart-home/doc/reference/get-devices.html
https://yandex.ru/dev/dialogs/smart-home/doc/reference/post-devices-query.html
https://yandex.ru/dev/dialogs/smart-home/doc/reference/post-action.html
"""
from enum import StrEnum
from typing import Any, Literal
from .base import APIModel
from .capability import (
CapabilityDescription,
CapabilityInstance,
CapabilityInstanceAction,
CapabilityInstanceActionResultValue,
CapabilityInstanceState,
CapabilityType,
)
from .property import PropertyDescription, PropertyInstanceState
from .response import ResponseCode, ResponsePayload
class DeviceType(StrEnum):
"""User device type.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/device-types.html
"""
LIGHT = "devices.types.light"
LIGHT_STRIP = "devices.types.light.strip"
LIGHT_CEILING = "devices.types.light.ceiling"
LIGHT_LAMP = "devices.types.light.lamp"
LIGHT_GARLAND = "devices.types.light.garland"
SOCKET = "devices.types.socket"
SWITCH = "devices.types.switch"
SWITCH_RELAY = "devices.types.switch.relay"
THERMOSTAT = "devices.types.thermostat"
THERMOSTAT_AC = "devices.types.thermostat.ac"
MEDIA_DEVICE = "devices.types.media_device"
MEDIA_DEVICE_TV = "devices.types.media_device.tv"
MEDIA_DEVICE_TV_BOX = "devices.types.media_device.tv_box"
MEDIA_DEVICE_RECIEVER = "devices.types.media_device.receiver"
CAMERA = "devices.types.camera"
COOKING = "devices.types.cooking"
COFFEE_MAKER = "devices.types.cooking.coffee_maker"
KETTLE = "devices.types.cooking.kettle"
MULTICOOKER = "devices.types.cooking.multicooker"
OPENABLE = "devices.types.openable"
OPENABLE_CURTAIN = "devices.types.openable.curtain"
OPENABLE_VALVE = "devices.types.openable.valve"
HUMIDIFIER = "devices.types.humidifier"
PURIFIER = "devices.types.purifier"
VACUUM_CLEANER = "devices.types.vacuum_cleaner"
WASHING_MACHINE = "devices.types.washing_machine"
DISHWASHER = "devices.types.dishwasher"
IRON = "devices.types.iron"
SENSOR = "devices.types.sensor"
SENSOR_MOTION = "devices.types.sensor.motion"
SENSOR_VIBRATION = "devices.types.sensor.vibration"
SENSOR_ILLUMINATION = "devices.types.sensor.illumination"
SENSOR_OPEN = "devices.types.sensor.open"
SENSOR_CLIMATE = "devices.types.sensor.climate"
SENSOR_WATER_LEAK = "devices.types.sensor.water_leak"
SENSOR_BUTTON = "devices.types.sensor.button"
SENSOR_GAS = "devices.types.sensor.gas"
SENSOR_SMOKE = "devices.types.sensor.smoke"
SMART_METER = "devices.types.smart_meter"
SMART_METER_COLD_WATER = "devices.types.smart_meter.cold_water"
SMART_METER_ELECTRICITY = "devices.types.smart_meter.electricity"
SMART_METER_GAS = "devices.types.smart_meter.gas"
SMART_METER_HEAT = "devices.types.smart_meter.heat"
SMART_METER_HOT_WATER = "devices.types.smart_meter.hot_water"
PET_DRINKING_FOUNTAIN = "devices.types.pet_drinking_fountain"
PET_FEEDER = "devices.types.pet_feeder"
VENTILATION = "devices.types.ventilation"
VENTILATION_FAN = "devices.types.ventilation.fan"
OTHER = "devices.types.other"
class DeviceInfo(APIModel):
"""Extended device info."""
manufacturer: str | None = None
model: str | None = None
hw_version: str | None = None
sw_version: str | None = None
class DeviceDescription(APIModel):
"""Device description for a device list request."""
id: str
name: str
description: str | None = None
room: str | None = None
type: DeviceType
capabilities: list[CapabilityDescription] | None = None
properties: list[PropertyDescription] | None = None
device_info: DeviceInfo | None = None
class DeviceState(APIModel):
"""Device state for a state query request."""
id: str
capabilities: list[CapabilityInstanceState] | None = None
properties: list[PropertyInstanceState] | None = None
error_code: ResponseCode | None = None
error_message: str | None = None
class DeviceList(ResponsePayload):
"""Response payload for a device list request."""
user_id: str
devices: list[DeviceDescription]
class DeviceStates(ResponsePayload):
"""Response payload for a state query request."""
devices: list[DeviceState]
class StatesRequestDevice(APIModel):
"""Device for a state query request."""
id: str
custom_data: dict[str, Any] | None = None
class StatesRequest(APIModel):
"""Request body for a state query request."""
devices: list[StatesRequestDevice]
class ActionRequestDevice(APIModel):
"""Device for a state change request."""
id: str
capabilities: list[CapabilityInstanceAction]
class ActionRequestPayload(APIModel):
"""Request payload for state change request."""
devices: list[ActionRequestDevice]
class ActionRequest(APIModel):
"""Request body for a state change request."""
payload: ActionRequestPayload
class SuccessActionResult(APIModel):
"""Success device action result."""
status: Literal["DONE"] = "DONE"
class FailedActionResult(APIModel):
"""Failed device action result."""
status: Literal["ERROR"] = "ERROR"
error_code: ResponseCode
class ActionResultCapabilityState(APIModel):
"""Result of capability instance state change."""
instance: CapabilityInstance
value: CapabilityInstanceActionResultValue | None = None
action_result: SuccessActionResult | FailedActionResult
class ActionResultCapability(APIModel):
"""Result of capability state change."""
type: CapabilityType
state: ActionResultCapabilityState
class ActionResultDevice(APIModel):
"""Device for a state change response."""
id: str
capabilities: list[ActionResultCapability] | None = None
action_result: FailedActionResult | SuccessActionResult | None = None
class ActionResult(ResponsePayload):
"""Response for a device state change."""
devices: list[ActionResultDevice]

View File

@@ -0,0 +1,65 @@
"""Schema for device property.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/properties-types.html
"""
from enum import StrEnum
from typing import Any, Literal
from .base import APIModel
from .property_event import EventPropertyInstance, EventPropertyParameters
from .property_float import FloatPropertyInstance, FloatPropertyParameters
class PropertyType(StrEnum):
"""Property type."""
FLOAT = "devices.properties.float"
EVENT = "devices.properties.event"
@property
def short(self) -> str:
"""Return short version of the property type."""
return str(self).replace("devices.properties.", "")
class FloatPropertyDescription(APIModel):
"""Description of a float property for a device list request."""
type: Literal[PropertyType.FLOAT] = PropertyType.FLOAT
retrievable: bool
reportable: bool
parameters: FloatPropertyParameters
class EventPropertyDescription(APIModel):
"""Description of an event property for a device list request."""
type: Literal[PropertyType.EVENT] = PropertyType.EVENT
retrievable: bool
reportable: bool
parameters: EventPropertyParameters[Any]
PropertyDescription = FloatPropertyDescription | EventPropertyDescription
"""Description of a property for a device list request."""
PropertyParameters = FloatPropertyParameters | EventPropertyParameters[Any]
"""Parameters of a property for a device list request."""
PropertyInstance = FloatPropertyInstance | EventPropertyInstance
"""All property instances."""
class PropertyInstanceStateValue(APIModel):
"""Property instance value."""
instance: PropertyInstance
value: Any
class PropertyInstanceState(APIModel):
"""Property state for state query and callback requests."""
type: PropertyType
state: PropertyInstanceStateValue

View File

@@ -0,0 +1,192 @@
"""Schema for event property.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/event.html
"""
from enum import StrEnum
from typing import Any, Generic, Literal, TypeVar
from pydantic.v1 import validator
from .base import GenericAPIModel
class EventPropertyInstance(StrEnum):
"""Instance of an event property.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/event-instance.html
"""
VIBRATION = "vibration"
OPEN = "open"
BUTTON = "button"
MOTION = "motion"
SMOKE = "smoke"
GAS = "gas"
BATTERY_LEVEL = "battery_level"
FOOD_LEVEL = "food_level"
WATER_LEVEL = "water_level"
WATER_LEAK = "water_leak"
class EventInstanceEvent(StrEnum):
"""Base class for an instance event."""
...
class VibrationInstanceEvent(EventInstanceEvent):
"""Event of a vibration instance."""
TILT = "tilt"
FALL = "fall"
VIBRATION = "vibration"
class OpenInstanceEvent(EventInstanceEvent):
"""Event of a open instance."""
OPENED = "opened"
CLOSED = "closed"
class ButtonInstanceEvent(EventInstanceEvent):
"""Event of a button instance."""
CLICK = "click"
DOUBLE_CLICK = "double_click"
LONG_PRESS = "long_press"
class MotionInstanceEvent(EventInstanceEvent):
"""Event of a motion instance."""
DETECTED = "detected"
NOT_DETECTED = "not_detected"
class SmokeInstanceEvent(EventInstanceEvent):
"""Event of a smoke instance."""
DETECTED = "detected"
NOT_DETECTED = "not_detected"
HIGH = "high"
class GasInstanceEvent(EventInstanceEvent):
"""Event of a gas instance."""
DETECTED = "detected"
NOT_DETECTED = "not_detected"
HIGH = "high"
class BatteryLevelInstanceEvent(EventInstanceEvent):
"""Event of a battery_level instance."""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
class FoodLevelInstanceEvent(EventInstanceEvent):
"""Event of a food_level instance."""
EMPTY = "empty"
LOW = "low"
NORMAL = "normal"
class WaterLevelInstanceEvent(EventInstanceEvent):
"""Event of a water_level instance."""
EMPTY = "empty"
LOW = "low"
NORMAL = "normal"
class WaterLeakInstanceEvent(EventInstanceEvent):
"""Event of a water_leak instance."""
DRY = "dry"
LEAK = "leak"
EventInstanceEventT = TypeVar("EventInstanceEventT", bound=EventInstanceEvent)
def get_event_class_for_instance(instance: EventPropertyInstance) -> type[EventInstanceEvent]:
"""Return EventInstanceEvent enum for event property instance."""
return {
EventPropertyInstance.VIBRATION: VibrationInstanceEvent,
EventPropertyInstance.OPEN: OpenInstanceEvent,
EventPropertyInstance.BUTTON: ButtonInstanceEvent,
EventPropertyInstance.MOTION: MotionInstanceEvent,
EventPropertyInstance.SMOKE: SmokeInstanceEvent,
EventPropertyInstance.GAS: GasInstanceEvent,
EventPropertyInstance.BATTERY_LEVEL: BatteryLevelInstanceEvent,
EventPropertyInstance.FOOD_LEVEL: FoodLevelInstanceEvent,
EventPropertyInstance.WATER_LEVEL: WaterLevelInstanceEvent,
EventPropertyInstance.WATER_LEAK: WaterLeakInstanceEvent,
}[instance]
def get_supported_events_for_instance(instance: EventPropertyInstance) -> list[EventInstanceEvent]:
"""Return list of supported events for event property instance."""
return list(get_event_class_for_instance(instance).__members__.values())
class EventPropertyParameters(GenericAPIModel, Generic[EventInstanceEventT]):
"""Parameters of an event property."""
instance: EventPropertyInstance
events: list[dict[Literal["value"], EventInstanceEventT]] = []
@validator("events", pre=True, always=True)
def set_events(cls, v: Any) -> Any:
"""Update events list value."""
if not v:
instance_event: type[EventInstanceEventT] = cls.__fields__["events"].type_.__args__[1]
return [{"value": m} for m in instance_event.__members__.values()]
return v # pragma: nocover
class VibrationEventPropertyParameters(EventPropertyParameters[VibrationInstanceEvent]):
instance: Literal[EventPropertyInstance.VIBRATION] = EventPropertyInstance.VIBRATION
class OpenEventPropertyParameters(EventPropertyParameters[OpenInstanceEvent]):
instance: Literal[EventPropertyInstance.OPEN] = EventPropertyInstance.OPEN
class ButtonEventPropertyParameters(EventPropertyParameters[ButtonInstanceEvent]):
instance: Literal[EventPropertyInstance.BUTTON] = EventPropertyInstance.BUTTON
class MotionEventPropertyParameters(EventPropertyParameters[MotionInstanceEvent]):
instance: Literal[EventPropertyInstance.MOTION] = EventPropertyInstance.MOTION
class SmokeEventPropertyParameters(EventPropertyParameters[SmokeInstanceEvent]):
instance: Literal[EventPropertyInstance.SMOKE] = EventPropertyInstance.SMOKE
class GasEventPropertyParameters(EventPropertyParameters[GasInstanceEvent]):
instance: Literal[EventPropertyInstance.GAS] = EventPropertyInstance.GAS
class BatteryLevelEventPropertyParameters(EventPropertyParameters[BatteryLevelInstanceEvent]):
instance: Literal[EventPropertyInstance.BATTERY_LEVEL] = EventPropertyInstance.BATTERY_LEVEL
class FoodLevelEventPropertyParameters(EventPropertyParameters[FoodLevelInstanceEvent]):
instance: Literal[EventPropertyInstance.FOOD_LEVEL] = EventPropertyInstance.FOOD_LEVEL
class WaterLevelEventPropertyParameters(EventPropertyParameters[WaterLevelInstanceEvent]):
instance: Literal[EventPropertyInstance.WATER_LEVEL] = EventPropertyInstance.WATER_LEVEL
class WaterLeakEventPropertyParameters(EventPropertyParameters[WaterLeakInstanceEvent]):
instance: Literal[EventPropertyInstance.WATER_LEAK] = EventPropertyInstance.WATER_LEAK

View File

@@ -0,0 +1,194 @@
"""Schema for float property.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/float.html
"""
from enum import StrEnum
from typing import Literal
from .base import APIModel
class FloatPropertyInstance(StrEnum):
"""Instance of an event property.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/float-instance.html
"""
AMPERAGE = "amperage"
BATTERY_LEVEL = "battery_level"
CO2_LEVEL = "co2_level"
ELECTRICITY_METER = "electricity_meter"
FOOD_LEVEL = "food_level"
GAS_METER = "gas_meter"
HEAT_METER = "heat_meter"
HUMIDITY = "humidity"
ILLUMINATION = "illumination"
METER = "meter"
PM10_DENSITY = "pm10_density"
PM1_DENSITY = "pm1_density"
PM2_5_DENSITY = "pm2.5_density"
POWER = "power"
PRESSURE = "pressure"
TEMPERATURE = "temperature"
TVOC = "tvoc"
VOLTAGE = "voltage"
WATER_LEVEL = "water_level"
WATER_METER = "water_meter"
class FloatUnit(StrEnum):
"""Unit used in a float property."""
AMPERE = "unit.ampere"
CUBIC_METER = "unit.cubic_meter"
GIGACALORIE = "unit.gigacalorie"
KILOWATT_HOUR = "unit.kilowatt_hour"
LUX = "unit.illumination.lux"
MCG_M3 = "unit.density.mcg_m3"
PERCENT = "unit.percent"
PPM = "unit.ppm"
VOLT = "unit.volt"
WATT = "unit.watt"
class PressureUnit(StrEnum):
"""Pressure unit."""
PASCAL = "unit.pressure.pascal"
MMHG = "unit.pressure.mmhg"
ATM = "unit.pressure.atm"
BAR = "unit.pressure.bar"
class TemperatureUnit(StrEnum):
"""Temperature unit."""
CELSIUS = "unit.temperature.celsius"
KELVIN = "unit.temperature.kelvin"
class FloatPropertyParameters(APIModel):
"""Parameters of a float property."""
instance: FloatPropertyInstance
unit: FloatUnit | PressureUnit | TemperatureUnit | None
@property
def range(self) -> tuple[int | None, int | None]:
"""Return value range."""
return None, None
class FloatPropertyAboveZeroMixin:
"""Mixin for a property that has value only above zero."""
@property
def range(self) -> tuple[int | None, int | None]:
"""Return value range."""
return 0, None
class PercentFloatPropertyParameters(FloatPropertyParameters):
unit: Literal[FloatUnit.PERCENT] = FloatUnit.PERCENT
@property
def range(self) -> tuple[int | None, int | None]:
"""Return value range."""
return 0, 100
class DensityFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
unit: Literal[FloatUnit.MCG_M3] = FloatUnit.MCG_M3
class AmperageFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.AMPERAGE] = FloatPropertyInstance.AMPERAGE
unit: Literal[FloatUnit.AMPERE] = FloatUnit.AMPERE
class BatteryLevelFloatPropertyParameters(PercentFloatPropertyParameters):
instance: Literal[FloatPropertyInstance.BATTERY_LEVEL] = FloatPropertyInstance.BATTERY_LEVEL
class CO2LevelFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.CO2_LEVEL] = FloatPropertyInstance.CO2_LEVEL
unit: Literal[FloatUnit.PPM] = FloatUnit.PPM
class ElectricityMeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.ELECTRICITY_METER] = FloatPropertyInstance.ELECTRICITY_METER
unit: Literal[FloatUnit.KILOWATT_HOUR] = FloatUnit.KILOWATT_HOUR
class FoodLevelFloatPropertyParameters(PercentFloatPropertyParameters):
instance: Literal[FloatPropertyInstance.FOOD_LEVEL] = FloatPropertyInstance.FOOD_LEVEL
class GasMeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.GAS_METER] = FloatPropertyInstance.GAS_METER
unit: Literal[FloatUnit.CUBIC_METER] = FloatUnit.CUBIC_METER
class HeatMeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.HEAT_METER] = FloatPropertyInstance.HEAT_METER
unit: Literal[FloatUnit.GIGACALORIE] = FloatUnit.GIGACALORIE
class HumidityFloatPropertyParameters(PercentFloatPropertyParameters):
instance: Literal[FloatPropertyInstance.HUMIDITY] = FloatPropertyInstance.HUMIDITY
class IlluminationFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.ILLUMINATION] = FloatPropertyInstance.ILLUMINATION
unit: Literal[FloatUnit.LUX] = FloatUnit.LUX
class MeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.METER] = FloatPropertyInstance.METER
unit: None = None
class PM1DensityFloatPropertyParameters(DensityFloatPropertyParameters):
instance: Literal[FloatPropertyInstance.PM1_DENSITY] = FloatPropertyInstance.PM1_DENSITY
class PM25DensityFloatPropertyParameters(DensityFloatPropertyParameters):
instance: Literal[FloatPropertyInstance.PM2_5_DENSITY] = FloatPropertyInstance.PM2_5_DENSITY
class PM10DensityFloatPropertyParameters(DensityFloatPropertyParameters):
instance: Literal[FloatPropertyInstance.PM10_DENSITY] = FloatPropertyInstance.PM10_DENSITY
class PowerFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.POWER] = FloatPropertyInstance.POWER
unit: Literal[FloatUnit.WATT] = FloatUnit.WATT
class PressureFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.PRESSURE] = FloatPropertyInstance.PRESSURE
unit: PressureUnit
class TemperatureFloatPropertyParameters(FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.TEMPERATURE] = FloatPropertyInstance.TEMPERATURE
unit: TemperatureUnit
class TVOCFloatPropertyParameters(DensityFloatPropertyParameters):
instance: Literal[FloatPropertyInstance.TVOC] = FloatPropertyInstance.TVOC
class VoltageFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.VOLTAGE] = FloatPropertyInstance.VOLTAGE
unit: Literal[FloatUnit.VOLT] = FloatUnit.VOLT
class WaterLevelFloatPropertyParameters(PercentFloatPropertyParameters):
instance: Literal[FloatPropertyInstance.WATER_LEVEL] = FloatPropertyInstance.WATER_LEVEL
class WaterMeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
instance: Literal[FloatPropertyInstance.WATER_METER] = FloatPropertyInstance.WATER_METER
unit: Literal[FloatUnit.CUBIC_METER] = FloatUnit.CUBIC_METER

View File

@@ -0,0 +1,52 @@
"""Schema for an API response.
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/response-codes.html
"""
from enum import StrEnum
from .base import APIModel
class ResponseCode(StrEnum):
"""Response code."""
DOOR_OPEN = "DOOR_OPEN"
LID_OPEN = "LID_OPEN"
REMOTE_CONTROL_DISABLED = "REMOTE_CONTROL_DISABLED"
NOT_ENOUGH_WATER = "NOT_ENOUGH_WATER"
LOW_CHARGE_LEVEL = "LOW_CHARGE_LEVEL"
CONTAINER_FULL = "CONTAINER_FULL"
CONTAINER_EMPTY = "CONTAINER_EMPTY"
DRIP_TRAY_FULL = "DRIP_TRAY_FULL"
DEVICE_STUCK = "DEVICE_STUCK"
DEVICE_OFF = "DEVICE_OFF"
FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE"
NOT_ENOUGH_DETERGENT = "NOT_ENOUGH_DETERGENT"
HUMAN_INVOLVEMENT_NEEDED = "HUMAN_INVOLVEMENT_NEEDED"
DEVICE_UNREACHABLE = "DEVICE_UNREACHABLE"
DEVICE_BUSY = "DEVICE_BUSY"
INTERNAL_ERROR = "INTERNAL_ERROR"
INVALID_ACTION = "INVALID_ACTION"
INVALID_VALUE = "INVALID_VALUE"
NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE"
ACCOUNT_LINKING_ERROR = "ACCOUNT_LINKING_ERROR"
DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND"
class ResponsePayload(APIModel):
"""Base class for an API response payload."""
class Error(ResponsePayload):
"""Error payload."""
error_code: ResponseCode
error_message: str | None = None
class Response(APIModel):
"""Base API response."""
request_id: str | None = None
payload: ResponsePayload | None = None

View File

@@ -0,0 +1,2 @@
reload:
description: Reload yandex_smart_home yaml configuration.

View File

@@ -0,0 +1,350 @@
{
"config": {
"abort": {
"missing_external_url": "**Использование прямого подключения невозможно**: не задан внешний URL-адрес сервера Home Assistant.\n\nДля исправления:\n* Включите расширенный режим в [настройках профиля](https://my.home-assistant.io/redirect/profile/)\n* Задайте URL-адрес сервера в Настройки > Система > [Сеть](https://my.home-assistant.io/redirect/network/) или в параметре [`external_url`](https://www.home-assistant.io/integrations/homeassistant/#external_url) в `configuration.yaml`\n* Повторно добавьте интеграцию"
},
"error": {
"cannot_connect": "Не удалось подключиться, проверьте журнал сервера",
"entities_not_selected": "Необходимо выбрать хотя бы один объект",
"missing_config_entry": "Не найдено подходящих интеграций",
"already_configured": "Для этого пользователя уже настроена интеграция {entry_title}"
},
"create_entry": {
"direct_yandex": "**Прямое подключение настроено!**\n\nТеперь вы можете добавить Home Assistant в Умный дом Яндекса, для этого:\n * Откройте приложение [Дом с Алисой](https://ya.cc/iot_app)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите навык, который вы создали\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя имя пользователя, указанное при настройке интеграции",
"direct_vk": "**Прямое подключение настроено!**\n\nТеперь вы можете управлять устройствами Home Assistant через Марусю, для этого:\n* Откройте приложение [Маруся](https://trk.mail.ru/c/u2dc49)\n* Нажмите иконку \"Домик\" в правом верхнем углу\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Подключить устройство\"\n* Найдите в списке и выберите приложение, которое вы создали\n* Откроется страница авторизации\n* Выполните привязку используя имя пользователя, указанное при настройке интеграции",
"cloud": "**Облачное подключение настроено!**\n\nРеквизиты для привязки Home Assistant к навыку Yaha Cloud:\n* **Одноразовый код**: `{otp}`\n\nили:\n* **ID**: `{id}`\n* **Пароль**: `{password}`\n\nРеквизиты продублированы в настройках интеграции, сохранять их не требуется.\n\nТеперь вы можете добавить Home Assistant в Умный дом Яндекса, для этого:\n * Откройте приложение [Дом с Алисой](https://ya.cc/iot_app)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите производителя \"Yaha Cloud\"\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nДля управления устройствами через Марусю:\n* Откройте приложение [Маруся](https://trk.mail.ru/c/u2dc49)\n* Нажмите иконку \"Домик\" в правом верхнем углу\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Подключить устройство\"\n* Найдите в списке и выберите бренд \"Yaha Cloud\"\n* Откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nВ [настройках интеграции](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) вы можете выбрать пользователя, который будет отображаться в Журнале событий.",
"cloud_plus_yandex": "**Облачное Плюс подключение настроено!**\n\nРеквизиты для привязки Home Assistant к навыку {skill}:\n* **Одноразовый код**: `{otp}`\n\nили:\n* **ID**: `{id}`\n* **Пароль**: `{password}`\n\nРеквизиты продублированы в настройках интеграции, сохранять их не требуется.\n\nТеперь вы можете добавить Home Assistant в Умный дом Яндекса, для этого:\n * Откройте приложение [Дом с Алисой](https://ya.cc/iot_app)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите производителя \"{skill}\"\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nВ [настройках интеграции](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) вы можете выбрать пользователя, который будет отображаться в Журнале событий.",
"cloud_plus_vk": "**Облачное Плюс подключение настроено!**\n\nРеквизиты для привязки Home Assistant к приложению {skill}:\n* **Одноразовый код**: `{otp}`\n\nили:\n* **ID**: `{id}`\n* **Пароль**: `{password}`\n\nРеквизиты продублированы в настройках интеграции, сохранять их не требуется.\n\nТеперь вы можете управлять устройствами Home Assistant через Марусю, для этого:\n * Откройте приложение [Маруся](https://trk.mail.ru/c/u2dc49)\n* Нажмите иконку \"Домик\" в правом верхнем углу\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Подключить устройство\"\n* Найдите в списке и выберите бренд \"{skill}\"\n* Откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nВ [настройках интеграции](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) вы можете выбрать пользователя, который будет отображаться в Журнале событий."
},
"step": {
"user" : {
"description": "Добро пожаловать в мастер добавления интеграции Yandex Smart Home!\n\nПосле добавления интеграции вы можете изменить любые её параметры в [настройках](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui), удалять интеграцию и настраивать заново не требуется.\n\nПолезные ссылки:\n* Документация: https://docs.yaha-cloud.ru/v1.0.x/\n* Чат в Телеграм: https://t.me/yandex_smart_home"
},
"connection_type": {
"title": "Выберите тип подключения",
"description": "* **Облачное**: лёгкое и быстрое подключение через навык [Yaha Cloud](https://dialogs.yandex.ru/store/skills/cef326b2-home-assistant), рекомендуется для всех пользователей\n* **Облачное Плюс**: для подключения дополнительного Home Assistant через облако Yaha Cloud, потребуется создать навык вручную (не используйте этот тип, если у вас только один Home Assistant)\n* **Прямое**: требуется доступ к Home Assistant по HTTPS через интернет, сложная [многоступенчатая настройка](https://docs.yaha-cloud.ru/v1.0.x/advanced/direct-connection/), только для продвинутых пользователей",
"data": {
"connection_type": "Тип подключения"
},
"data_description": {
"connection_type": "\\* [Какой тип подключения лучше?](https://docs.yaha-cloud.ru/v1.0.x/config/connection-type/#compare)\n\\* [Подключение нескольких HA](https://docs.yaha-cloud.ru/v1.0.x/config/multi-ha/)"
}
},
"platform_direct": {
"title": "Прямое подключение",
"description": "Перед настройкой прямого подключения убедитесь, что сервер Home Assistant доступен из интернета и локальной сети по адресу **`{external_url}`** ([подробнее](https://docs.yaha-cloud.ru/v1.0.x/advanced/direct-connection/) о требованиях и как проверить доступность)",
"data": {
"platform": "Платформа умного дома"
}
},
"platform_cloud_plus": {
"title": "Платформа умного дома",
"data": {
"platform": " "
}
},
"skill_yandex_direct": {
"title": "Параметры навыка",
"description": "Для интеграции требуется создать навык на платформе Яндекс Диалоги:\n\n1\\. Зайдите в консоль [Яндекс Диалоги](https://dialogs.yandex.ru/developer) > Создать диалог > Умный дом\n2\\. Заполните параметры на вкладке **Настройки**:\n* Язык: **`Русский`**\n* Название: **`Home Assistant`** (или другое)\n* Подключение устройств: `Любой текст`\n* Backend: `{external_url}/api/yandex_smart_home`\n* Тип доступа: **`Приватный`**\n* Имя разработчика: **`Любое`**\n* Иконка: **`Любая`** (например [эта](https://community-assets.home-assistant.io/original/3X/6/a/6a99ebb8d0b585a00b407123ff76964cb3e18780.png))\n\n3\\. Нажмите **Сохранить** и заполните на вкладке **Связка аккаунтов**:\n* Идентификатор приложения: `https://social.yandex.net/`\n* Секрет приложения: **`secret`**\n* URL авторизации: `{external_url}/auth/authorize`\n* URL для получения токена: `{external_url}/auth/token`\n* URL для обновления токена: `{external_url}/auth/token`\n\n4\\. Нажмите **Сохранить**, перейдите на вкладку **Общие сведения**\n5\\. Нажмите **Опубликовать**\n6\\. Появились ошибки валидации? Не страшно, [устраните](https://docs.yaha-cloud.ru/v1.0.x/advanced/direct-connection/#validation-errors) их **после** завершения мастера настройки",
"data": {
"user_id": "Пользователь",
"id": "Идентификатор диалога",
"token": "Токен"
},
"data_description": {
"user_id": "Пользователь Home Assistant, который будет использовать эту интеграцию\nПри связывании аккаунтов **обязательно** использовать его при входе\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему)",
"id": "С вкладки **Общие сведения** настроек вашего навыка (в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer))",
"token": "Получите по [ссылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога"
}
},
"skill_vk_direct": {
"title": "Параметры приложения",
"description": "Для интеграции требуется создать приложение на платформе VK для разработчиков:\n\n1\\. Зайдите на [platform.vk.com](https://platform.vk.com) и зарегистрируйтесь как физическое лицо\n2\\. Создайте проект с любым названием\n3\\. Перейдите в созданный проект > Добавить приложение:\n* Название: **`Home Assistant`** (или другое)\n* Тип: `Умный дом с Марусей`\n\n4\\. Перейдите в созданное приложение и заполните параметры:\n* Адрес API: `{external_url}/api/yandex_smart_home/v1.0`\n* Адрес страницы авторизации: `{external_url}/auth/authorize`\n* Адрес для получения тоĸена: `{external_url}/auth/token`\n* Адрес для обновления токена: `{external_url}/auth/token`\n* Авторизационный client_id: `https://vc.go.mail.ru`\n* Авторизационный secret: `secret`\n* Протокол интеграции: `Яндекс`\n\n5\\. Нажмите **Сохранить изменения**",
"data": {
"user_id": "Пользователь",
"id": "ID приложения"
},
"data_description": {
"user_id": "Пользователь Home Assistant, который будет использовать эту интеграцию\nПри связывании аккаунтов **обязательно** использовать его при входе\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему)",
"id": "Из настроек приложения на платформе [VK для разработчиков](https://platform.vk.com)"
}
},
"skill_yandex_cloud_plus": {
"title": "Параметры навыка",
"description": "Для интеграции требуется создать навык на платформе Яндекс Диалоги:\n\n1\\. Зайдите в консоль [Яндекс Диалоги](https://dialogs.yandex.ru/developer) > Создать диалог > Умный дом\n2\\. Заполните параметры на вкладке **Настройки**:\n* Язык: **`Русский`**\n* Название: **`Yaha Cloud Plus`** (или другое)\n* Подключение устройств: `Любой текст`\n* Backend: `{cloud_base_url}/api/yandex_smart_home`\n* Тип доступа: **`Приватный`**\n* Имя разработчика: **`Любое`**\n* Иконка: **`Любая`** (например [вариант 1](https://docs.yaha-cloud.ru/v1.0.x/assets/logo/logo-plus-1.png), [вариант 2](https://docs.yaha-cloud.ru/v1.0.x/assets/logo/logo-plus-2.png))\n\n3\\. Нажмите **Сохранить** и заполните на вкладке **Связка аккаунтов**:\n* Идентификатор приложения: `yandex_smart_home:{instance_id}`\n* Секрет приложения: **`secret`**\n* URL авторизации: `{cloud_base_url}/oauth/authorize`\n* URL для получения токена: `{cloud_base_url}/oauth/token`\n* URL для обновления токена: `{cloud_base_url}/oauth/token`\n\n4\\. Нажмите **Сохранить**, перейдите на вкладку **Общие сведения**\n5\\. Нажмите **Опубликовать**",
"data": {
"name": "Название диалога",
"id": "Идентификатор диалога",
"token": "Токен"
},
"data_description": {
"id": "С вкладки **Общие сведения** настроек вашего навыка (в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer))",
"token": "Получите по [ссылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога"
}
},
"skill_vk_cloud_plus": {
"title": "Параметры навыка",
"description": "Для интеграции требуется создать приложение на платформе VK для разработчиков:\n\n1\\. Зайдите на [platform.vk.com](https://platform.vk.com) и зарегистрируйтесь как физическое лицо\n2\\. Создайте проект с любым названием\n3\\. Перейдите в созданный проект > Добавить приложение:\n* Название: **`Yaha Cloud Plus`** (или другое)\n* Тип: `Умный дом с Марусей`\n\n4\\. Перейдите в созданное приложение и заполните параметры:\n* Адрес API: `{cloud_base_url}/api/yandex_smart_home/v1.0`\n* Адрес страницы авторизации: `{cloud_base_url}/oauth/authorize`\n* Адрес для получения тоĸена: `{cloud_base_url}/oauth/token`\n* Адрес для обновления токена: `{cloud_base_url}/oauth/token`\n* Авторизационный client_id: `vk_smart_home:{instance_id}`\n* Авторизационный secret: `secret`\n* Протокол интеграции: `Яндекс`\n\n5\\. Нажмите **Сохранить изменения**",
"data": {
"name": "Название приложения",
"id": "ID приложения"
},
"data_description": {
"id": "Из настроек приложения на платформе [VK для разработчиков](https://platform.vk.com)"
}
},
"expose_settings": {
"title": "Объекты для передачи в УДЯ",
"data": {
"filter_source": "Способ выбора объектов для передачи в УДЯ",
"entry_aliases": "Учитывать альтернативные названия устройств и комнат"
},
"data_description": {
"entry_aliases": "Отключите этот параметр, если используете альтернативные названия для функции Assist.\nСм. также: [особенности именования устройств и комнат](https://docs.yaha-cloud.ru/v1.0.x/quirks#naming)"
}
},
"update_filter": {
"title": "Объекты для передачи в УДЯ",
"description": "Выберите интеграцию с которой будут скопированы объекты для передачи в УДЯ. На следующем шаге вы сможете изменить этот список.",
"data": {
"id": "Интеграция",
"filter_source": "Выбрать другой способ выбора объектов для передачи в УДЯ"
}
},
"include_entities": {
"title": "Объекты для передачи в УДЯ",
"description": "Выберите объекты, которые будут переданы в УДЯ/Марусю в виде устройств.\n\nПри начальной настройке рекомендуется сократить этот список до минимума.",
"data": {
"entities": "Объекты"
}
},
"choose_label": {
"title": "Ярлык для отбора объектов",
"description": "Выберите ярлык, объекты с которым будут переданы в УДЯ/Марусю в виде устройств.\n\n Ярлыки на устройствах или зонах не поддерживаются. Ярлык можно создать непосредственно при редактировании объекта.\n\nЕсли устройство присутствует в УДЯ соответствующий объект всегда должен иметь этот ярлык.\n\nУдаление устройств из УДЯ возможно **только** вручную, для удаления всех устройств [отвяжите навык](https://docs.yaha-cloud.ru/v1.0.x/platforms/yandex/#unlink).\n\nМаруся всегда отображает только объекты с выбранным ярлыком.",
"data": {
"label": "Ярлык"
}
}
}
},
"options": {
"abort": {
"missing_external_url": "**Ошибка**\n\n**Использование прямого подключение невозможно**: не задан внешний URL-адрес сервера Home Assistant.\n\nДля исправления:\n* Включите расширенный режим в [настройках профиля](https://my.home-assistant.io/redirect/profile/)\n* Задайте URL-адрес сервера в Настройки > Система > [Сеть](https://my.home-assistant.io/redirect/network/) или в параметре [`external_url`](https://www.home-assistant.io/integrations/homeassistant/#external_url) в `configuration.yaml\n* Повторно откройте настройки интеграции"
},
"error": {
"unknown": "{error}",
"cannot_connect": "Не удалось подключиться, проверьте журнал сервера",
"entities_not_selected": "Необходимо выбрать хотя бы один объект",
"missing_config_entry": "Не найдено подходящих интеграций",
"already_configured": "Для этого пользователя уже настроена интеграция {entry_title}",
"manual_revoke_oauth_tokens": "Автоматическая отвязка навыков не поддерживается. Вы можете отвязать навыки через удаление Токенов обновления в настройках профиля на вкладке Безопасность"
},
"step": {
"init": {
"menu_options": {
"expose_settings": "Объекты для передачи в УДЯ",
"cloud_credentials": "Реквизиты для привязки",
"context_user": "Пользователь в журналах",
"skill_yandex_direct": "Параметры навыка",
"skill_vk_direct": "Параметры приложения",
"skill_yandex_cloud_plus": "Параметры навыка",
"skill_vk_cloud_plus": "Параметры приложения",
"maintenance": "Сервисное меню"
}
},
"expose_settings": {
"title": "Объекты для передачи в УДЯ",
"description": "Нажмите **Подтвердить** для перехода к списку объектов или выбору ярлыка.",
"data": {
"filter_source": "Способ выбора объектов для передачи в УДЯ",
"entry_aliases": "Учитывать альтернативные названия устройств и комнат"
},
"data_description": {
"entry_aliases": "Отключите этот параметр, если используете альтернативные названия для функции Assist.\nСм. также: [особенности именования устройств и комнат](https://docs.yaha-cloud.ru/v1.0.x/quirks#naming)"
}
},
"update_filter": {
"title": "Объекты для передачи в УДЯ",
"description": "Выберите интеграцию с которой будут скопированы объекты для передачи в УДЯ. На следующем шаге вы сможете изменить этот список.",
"data": {
"id": "Интеграция"
}
},
"include_entities": {
"title": "Объекты для передачи в УДЯ",
"description": "Выберите объекты, которые будут переданы в УДЯ/Марусю в виде устройств.\n\nЕсли устройство присутствует в УДЯ соответствующий объект всегда должен находиться в этом списке.\n\nУдаление устройств из УДЯ возможно **только** вручную, для удаления всех устройств [отвяжите навык](https://docs.yaha-cloud.ru/v1.0.x/platforms/yandex/#unlink).\n\nМаруся всегда отображает только выбранные объекты.\n\nНажмите **Подтвердить** для сохранения изменений.",
"data": {
"entities": "Объекты"
}
},
"choose_label": {
"title": "Ярлык для отбора объектов",
"description": "Выберите ярлык, объекты с которым будут переданы в УДЯ/Марусю в виде устройств.\n\n Ярлыки на устройствах или зонах не поддерживаются. Ярлык можно создать непосредственно при редактировании объекта.\n\nЕсли устройство присутствует в УДЯ соответствующий объект всегда должен иметь этот ярлык.\n\nУдаление устройств из УДЯ возможно **только** вручную, для удаления всех устройств [отвяжите навык](https://docs.yaha-cloud.ru/v1.0.x/platforms/yandex/#unlink).\n\nМаруся всегда отображает только объекты с выбранным ярлыком.\n\nНажмите **Подтвердить** для сохранения изменений.",
"data": {
"label": "Ярлык"
}
},
"context_user": {
"title": "Пользователь в журналах",
"description": "Выберите пользователя, от имени которого будут управляться устройства (отображается в Журнале событий).\n\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему).",
"data": {
"user_id": "Пользователь"
}
},
"cloud_credentials": {
"title": "Облачное подключение",
"description": "Реквизиты для привязки Home Assistant к навыку {skill}:\n* **Одноразовый код**: `{otp}`\n\nили:\n* **ID**: `{id}`\n* **Пароль**: `{password}`\n\nДля добавления Home Assistant в Умный дом Яндекса:\n * Откройте приложение [Дом с Алисой](https://ya.cc/iot_app)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите производителя \"{skill}\"\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nДля управления устройствами через Марусю:\n* Откройте приложение [Маруся](https://trk.mail.ru/c/u2dc49)\n* Нажмите иконку \"Домик\" в правом верхнем углу\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Подключить устройство\"\n* Найдите в списке и выберите бренд \"{skill}\"\n* Откроется страница авторизации\n* Выполните привязку используя реквизиты выше"
},
"skill_yandex_direct": {
"title": "Параметры навыка",
"description": "Параметры навыка можно изменить в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer)\n\nВкладка **Настройки**:\n* Backend: `{external_url}/api/yandex_smart_home`\n\nВкладка **Связка аккаунтов**:\n* Идентификатор приложения: `https://social.yandex.net/`\n* Секрет приложения: **`secret`**\n* URL авторизации: `{external_url}/auth/authorize`\n* URL для получения токена: `{external_url}/auth/token`\n* URL для обновления токена: `{external_url}/auth/token`\n\n**Важно!** При изменении идентификатора диалога или пользователя **обязательно** нажмите \"Обновить список устройств\" в приложении Дом с Алисой",
"data": {
"user_id": "Пользователь",
"id": "Идентификатор диалога",
"token": "Токен"
},
"data_description": {
"user_id": "Пользователь Home Assistant, который будет использовать эту интеграцию\nПри связывании аккаунтов **обязательно** использовать его при входе\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему)",
"id": "С вкладки **Общие сведения** настроек вашего навыка (в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer))",
"token": "Получите по [ссылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога"
}
},
"skill_vk_direct": {
"title": "Параметры приложения",
"description": "Параметры приложения можно изменить на платформе [VK для разработчиков](https://platform.vk.com):\n\n* Адрес API: `{external_url}/api/yandex_smart_home/v1.0`\n* Адрес страницы авторизации: `{external_url}/auth/authorize`\n* Адрес для получения тоĸена: `{external_url}/auth/token`\n* Адрес для обновления токена: `{external_url}/auth/token`\n* Авторизационный client_id: `https://vc.go.mail.ru`\n* Авторизационный secret: `secret`\n* Протокол интеграции: `Яндекс`",
"data": {
"user_id": "Пользователь",
"id": "ID приложения"
},
"data_description": {
"user_id": "Пользователь Home Assistant, который будет использовать эту интеграцию\nПри связывании аккаунтов **обязательно** использовать его при входе\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему)",
"id": "Из настроек приложения на платформе [VK для разработчиков](https://platform.vk.com)"
}
},
"skill_yandex_cloud_plus": {
"title": "Параметры навыка",
"description": "Параметры навыка можно изменить в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer)\n\nВкладка **Настройки**:\n* Backend: `{cloud_base_url}/api/yandex_smart_home`\n\nВкладка **Связка аккаунтов**:\n* Идентификатор приложения: `yandex_smart_home:{instance_id}`\n* Секрет приложения: **`secret`**\n* URL авторизации: `{cloud_base_url}/oauth/authorize`\n* URL для получения токена: `{cloud_base_url}/oauth/token`\n* URL для обновления токена: `{cloud_base_url}/oauth/token`\n\n**Важно!** При изменении идентификатора диалога **обязательно** нажмите \"Обновить список устройств\" в приложении Дом с Алисой",
"data": {
"name": "Название диалога",
"id": "Идентификатор диалога",
"token": "Токен"
},
"data_description": {
"id": "С вкладки **Общие сведения** настроек вашего навыка (в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer))",
"token": "Получите по [ссылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога"
}
},
"skill_vk_cloud_plus": {
"title": "Параметры приложения",
"description": "Параметры приложения можно изменить на платформе [VK для разработчиков](https://platform.vk.com):\n\n* Адрес API: `{cloud_base_url}/api/yandex_smart_home/v1.0`\n* Адрес страницы авторизации: `{cloud_base_url}/oauth/authorize`\n* Адрес для получения тоĸена: `{cloud_base_url}/oauth/token`\n* Адрес для обновления токена: `{cloud_base_url}/oauth/token`\n* Авторизационный client_id: `vk_smart_home:{instance_id}`\n* Авторизационный secret: `secret`\n* Протокол интеграции: `Яндекс`",
"data": {
"name": "Название приложения",
"id": "ID приложения"
},
"data_description": {
"id": "Из настроек приложения на платформе [VK для разработчиков](https://platform.vk.com)"
}
},
"maintenance": {
"title": "Сервисное меню",
"description": "**Внимание!** Действия в этом разделе могут привести к невозможности управлять устройствами через УДЯ или к неправильной работе интеграции.",
"data": {
"revoke_oauth_tokens": "Отвязать навыки",
"unlink_all_platforms": "Пометить навыки отвязанными",
"reset_cloud_instance_connection_token": "Обновить токен подключения к Yaha Cloud",
"transfer_entity_filter_from_yaml": "Перенести фильтр объектов из YAML"
},
"data_description": {
"revoke_oauth_tokens": "Очищает данные аутентификации для всех привязанных навыков, что приводит к невозможности управления устройствами через УДЯ/Марусю. После выполнения этой операции необходимо вручную отвязать навык в УДЯ/Марусе (без удаления устройств) и привязать повторно.",
"unlink_all_platforms": "Отключает отправку уведомлений о состоянии устройств по всем привязанным навыкам. Для возобновления отправки вручную обновите список устройств через УДЯ или зайдите в список устройств в приложение Маруся.",
"reset_cloud_instance_connection_token": "Обновляет служебный токен для подключения к Yaha Cloud, не изменяет ID и пароль.",
"transfer_entity_filter_from_yaml": "Переносит список объектов для передачи из YAML конфигурации (параметр `filter`) в настройки интеграции или проставляет этим объектам выбранный ярлык, подробнее в [документации](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#migration-from-yaml)."
}
}
}
},
"issues": {
"deprecated_pressure_unit": {
"title": "Устаревший параметр pressure_unit",
"description": "Параметр `pressure_unit` (раздел `settings`) больше не поддерживается, удалите его из YAML конфигурации.\n\nТеперь компонент автоматически пытается сохранить единицы измерения при передаче значений датчиков из Home Assistant в УДЯ ([подробнее о конвертации значений](https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#unit-conversion))"
},
"deprecated_yaml_notifier": {
"title": "Устаревшие настройки прямого подключения",
"description": "Служба уведомлений о состоянии устройств (нотификатор) теперь настраивается через [интерфейс](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) (раздел `Параметры навыка`).\n\nПараметры из YAML конфигурации были перенесены **автоматически**, удалите секцию **`notifier`** из YAML конфигурации.\n"
},
"deprecated_yaml_several_notifiers": {
"title": "Устаревшие настройки прямого подключения",
"description": "Служба уведомлений о состоянии устройств (нотификатор) теперь настраивается через [интерфейс](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) (раздел `Параметры навыка`).\n\nПеренесите настройки из секции `notifier` в настройки интеграции вручную, после этого удалите секцию `notifier` из YAML конфигурации.\n\nДля каждой записи в секции `notifier` потребуется создать отдельную интеграцию с прямым типом подключения."
},
"missing_integration": {
"title": "Не найдена подходящая интеграция",
"description": "Для входящего запроса от {platform} не найдена интеграция для пользователя {username}.\n\nДобавьте новую интеграцию Yandex Smart Home с прямым подключением и пользователем {username} в настройках навыка, или измените пользователя у существующей интеграции.\n\nЭта проблема закроется автоматически при следующем удачном запросе или перезапуске Home Assistant."
},
"missing_skill_data":{
"title": "Требуется дополнительная настройка",
"description": "В настройках интеграции {entry_title} требуется заполнить параметры навыка. Без этих данных интеграция не сможет передавать в УДЯ информацию об актуальном состоянии устройств."
},
"reconnecting_too_fast": {
"title": "Частые переподключения",
"description": "Интеграция {entry_title} слишком часто переподключается к облачному серверу. Это приводит к периодической невозможности управлять устройствами из УДЯ.\n\nСкорее всего одновременно запущено несколько Home Assistant, которые были развернуты из одной резервной копии."
},
"unexposed_entity_found_config_entry": {
"title": "Не выбран один или несколько объектов для передачи",
"fix_flow": {
"step": {
"confirm": {
"description": "В УДЯ существуют устройства, объекты которых не выбраны в списке объектов для передачи в настройках интеграции {entry_title}.\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Добавьте затронутые объекты в список для передачи в [настройках](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#config-flow) интеграции или выберите \"Добавить автоматически\" и нажмите \"Подтвердить\" для автоматического добавления\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}",
"data": {
"include_entities": "Добавить автоматически все затронутые объекты в список для передачи"
}
}
}
}
},
"unexposed_entity_found_yaml": {
"title": "Не выбран один или несколько объектов для передачи",
"description": "В УДЯ существуют устройства, объекты которых не попадают под [фильтры](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#yaml) в YAML конфигурации.\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Добавьте затронутые объекты список `include_entities` в параметре `yandex_smart_home.filter` YAML конфигурации и перезагрузите её через Панель разработчика\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}"
},
"unexposed_entity_found_label": {
"title": "Не выбран один или несколько объектов для передачи",
"fix_flow": {
"step": {
"confirm": {
"description": "В УДЯ существуют устройства, объекты которых не содержат ярлык \"{label}\".\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Вручную добавьте ярлык \"{label}\" на затронутые объекты или выберите \"Добавить автоматически\" и нажмите \"Подтвердить\" для автоматического добавления ярлыка\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}",
"data": {
"add_label": "Добавить автоматически ярлык \"{label}\" на все затронутые объекты"
}
}
}
}
}
},
"selector": {
"connection_type": {
"options": {
"cloud": "Облачное",
"cloud_plus": "Облачное Плюс",
"direct": "Прямое"
}
},
"filter_source": {
"options": {
"config_entry": "Через интерфейс (рекомендуется)",
"get_from_config_entry": "Через интерфейс: скопировать с другой интеграции",
"label": "Через ярлыки на объектах",
"yaml": "Через YAML конфигурацию"
}
},
"platform": {
"options": {
"yandex": "Дом с Алисой",
"vk": "Маруся"
}
},
"user_id": {
"options": {
"none": "Нет"
}
}
}
}

View File

@@ -0,0 +1,128 @@
"""Unit conversion helpers."""
from enum import StrEnum
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
)
from homeassistant.util.unit_conversion import (
_IN_TO_M,
_MERCURY_DENSITY,
_MM_TO_M,
_STANDARD_GRAVITY,
BaseUnitConverter,
)
from .schema import PressureUnit, TemperatureUnit
# EFEKTA iAQ3 (#570)
UNIT_OF_MEASUREMENT_VOC_INDEX_POINT = "VOC Index points"
class TVOCConcentrationConverter(BaseUnitConverter):
"""Utility to convert TVOC concentration values."""
UNIT_CLASS = "tvoc"
NORMALIZED_UNIT = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
VALID_UNITS = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_PARTS_PER_BILLION,
UNIT_OF_MEASUREMENT_VOC_INDEX_POINT,
}
# average molecular weight of tVOC = 110 g/mol
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1,
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: 1 / 35.3146667215,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1 / 1000,
CONCENTRATION_PARTS_PER_MILLION: 1 / 4496.29381184,
CONCENTRATION_PARTS_PER_BILLION: 1 / 4.49629381184,
UNIT_OF_MEASUREMENT_VOC_INDEX_POINT: 1,
}
class UnitOfTemperature(StrEnum):
"""Temperature units."""
CELSIUS = "°C"
FAHRENHEIT = "°F"
KELVIN = "K"
@property
def as_property_unit(self) -> TemperatureUnit:
"""Return value as property unit."""
match self:
case self.CELSIUS:
return TemperatureUnit.CELSIUS
case self.KELVIN:
return TemperatureUnit.KELVIN
raise ValueError
class UnitOfPressure(StrEnum):
"""Extended pressure units."""
ATM = "atm"
PA = "Pa"
HPA = "hPa"
KPA = "kPa"
BAR = "bar"
CBAR = "cbar"
MBAR = "mbar"
MMHG = "mmHg"
INHG = "inHg"
PSI = "psi"
@property
def as_property_unit(self) -> PressureUnit:
"""Return value as property unit."""
match self:
case self.PA:
return PressureUnit.PASCAL
case self.MMHG:
return PressureUnit.MMHG
case self.ATM:
return PressureUnit.ATM
case self.BAR:
return PressureUnit.BAR
raise ValueError
class PressureConverter(BaseUnitConverter):
"""Utility to convert pressure values."""
UNIT_CLASS = "pressure"
NORMALIZED_UNIT = UnitOfPressure.PA
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfPressure.PA: 1,
UnitOfPressure.HPA: 1 / 100,
UnitOfPressure.KPA: 1 / 1000,
UnitOfPressure.BAR: 1 / 100000,
UnitOfPressure.CBAR: 1 / 1000,
UnitOfPressure.MBAR: 1 / 100,
UnitOfPressure.INHG: 1 / (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
UnitOfPressure.PSI: 1 / 6894.757,
UnitOfPressure.MMHG: 1 / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
UnitOfPressure.ATM: 1 / 101325,
}
VALID_UNITS = {
UnitOfPressure.PA,
UnitOfPressure.HPA,
UnitOfPressure.KPA,
UnitOfPressure.BAR,
UnitOfPressure.CBAR,
UnitOfPressure.MBAR,
UnitOfPressure.INHG,
UnitOfPressure.PSI,
UnitOfPressure.MMHG,
UnitOfPressure.ATM,
}