first commit

This commit is contained in:
Your Name
2024-01-11 15:10:51 +03:00
commit eddf07c652
602 changed files with 67154 additions and 0 deletions
@@ -0,0 +1,302 @@
"""Support for Actions on Yandex Smart Home."""
from __future__ import annotations
import hashlib
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from . import ( # noqa: F401
capability_color,
capability_custom,
capability_mode,
capability_onoff,
capability_range,
capability_toggle,
capability_video,
config_validation as ycv,
const,
prop_custom,
prop_event,
prop_float,
)
from .cloud import CloudManager, delete_cloud_instance
from .cloud_stream import CloudStream
from .const import (
CLOUD_MANAGER,
CLOUD_STREAMS,
CONFIG,
DOMAIN,
EVENT_CONFIG_CHANGED,
EVENT_DEVICE_DISCOVERY,
NOTIFIERS,
YAML_CONFIG,
)
from .helpers import Config
from .http import async_register_http
from .notifier import YandexNotifier, async_setup_notifier, async_start_notifier, async_unload_notifier
_LOGGER = logging.getLogger(__name__)
ENTITY_PROPERTY_SCHEMA = vol.All(
cv.has_at_least_one_key(const.CONF_ENTITY_PROPERTY_ENTITY, const.CONF_ENTITY_PROPERTY_ATTRIBUTE),
vol.Schema({
vol.Required(const.CONF_ENTITY_PROPERTY_TYPE): vol.Schema(vol.All(str, ycv.property_type)),
vol.Optional(const.CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(const.CONF_ENTITY_PROPERTY_ENTITY): cv.entity_id,
vol.Optional(const.CONF_ENTITY_PROPERTY_ATTRIBUTE): cv.string,
}, extra=vol.PREVENT_EXTRA)
)
ENTITY_MODE_MAP_SCHEMA = vol.Schema({
vol.All(cv.string, ycv.mode_instance): vol.Schema({
vol.All(cv.string, ycv.mode): [cv.string]
})
})
ENTITY_RANGE_SCHEMA = vol.Schema({
vol.Optional(const.CONF_ENTITY_RANGE_MAX): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
vol.Optional(const.CONF_ENTITY_RANGE_MIN): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
vol.Optional(const.CONF_ENTITY_RANGE_PRECISION): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
}, extra=vol.PREVENT_EXTRA)
ENTITY_CUSTOM_MODE_SCHEMA = vol.Schema({
vol.All(cv.string, ycv.mode_instance): vol.Schema({
vol.Required(const.CONF_ENTITY_CUSTOM_MODE_SET_MODE): cv.SERVICE_SCHEMA,
vol.Optional(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
vol.Optional(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
})
})
ENTITY_CUSTOM_RANGE_SCHEMA = vol.Schema({
vol.All(cv.string, ycv.range_instance): vol.All(
cv.has_at_least_one_key(
const.CONF_ENTITY_CUSTOM_RANGE_SET_VALUE,
const.CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE,
const.CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE,
),
vol.Schema({
vol.Optional(const.CONF_ENTITY_CUSTOM_RANGE_SET_VALUE): vol.Any(cv.SERVICE_SCHEMA),
vol.Optional(const.CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE): vol.Any(cv.SERVICE_SCHEMA),
vol.Optional(const.CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE): vol.Any(cv.SERVICE_SCHEMA),
vol.Optional(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
vol.Optional(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
vol.Optional(const.CONF_ENTITY_RANGE): ENTITY_RANGE_SCHEMA,
})
)
})
ENTITY_CUSTOM_TOGGLE_SCHEMA = vol.Schema({
vol.All(cv.string, ycv.toggle_instance): vol.Schema({
vol.Required(const.CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON): cv.SERVICE_SCHEMA,
vol.Required(const.CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF): cv.SERVICE_SCHEMA,
vol.Optional(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
vol.Optional(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
})
})
ENTITY_SCHEMA = vol.All(
vol.Schema({
vol.Optional(const.CONF_NAME): cv.string,
vol.Optional(const.CONF_ROOM): cv.string,
vol.Optional(const.CONF_TYPE): vol.All(cv.string, ycv.device_type),
vol.Optional(const.CONF_TURN_ON): cv.SERVICE_SCHEMA,
vol.Optional(const.CONF_TURN_OFF): cv.SERVICE_SCHEMA,
vol.Optional(const.CONF_DEVICE_CLASS): vol.In(const.DEVICE_CLASS_BUTTON),
vol.Optional(const.CONF_FEATURES): vol.All(cv.ensure_list, ycv.entity_features),
vol.Optional(const.CONF_ENTITY_PROPERTIES, default=[]): [ENTITY_PROPERTY_SCHEMA],
vol.Optional(const.CONF_SUPPORT_SET_CHANNEL): cv.boolean,
vol.Optional(const.CONF_STATE_UNKNOWN): cv.boolean,
vol.Optional(const.CONF_COLOR_PROFILE): cv.string,
vol.Optional(const.CONF_ENTITY_RANGE, default={}): ENTITY_RANGE_SCHEMA,
vol.Optional(const.CONF_ENTITY_MODE_MAP, default={}): ENTITY_MODE_MAP_SCHEMA,
vol.Optional(const.CONF_ENTITY_CUSTOM_MODES, default={}): ENTITY_CUSTOM_MODE_SCHEMA,
vol.Optional(const.CONF_ENTITY_CUSTOM_TOGGLES, default={}): ENTITY_CUSTOM_TOGGLE_SCHEMA,
vol.Optional(const.CONF_ENTITY_CUSTOM_RANGES, default={}): ENTITY_CUSTOM_RANGE_SCHEMA,
})
)
NOTIFIER_SCHEMA = vol.Schema({
vol.Required(const.CONF_NOTIFIER_OAUTH_TOKEN): cv.string,
vol.Required(const.CONF_NOTIFIER_SKILL_ID): cv.string,
vol.Required(const.CONF_NOTIFIER_USER_ID): cv.string,
}, extra=vol.PREVENT_EXTRA)
SETTINGS_SCHEMA = vol.Schema({
vol.Optional(const.CONF_PRESSURE_UNIT, default=const.PRESSURE_UNIT_MMHG): vol.Schema(
vol.All(str, ycv.pressure_unit)
),
vol.Optional(const.CONF_BETA, default=False): cv.boolean,
vol.Optional(const.CONF_CLOUD_STREAM, default=False): cv.boolean
})
YANDEX_SMART_HOME_SCHEMA = vol.All(
vol.Schema({
vol.Optional(const.CONF_NOTIFIER, default=[]): vol.All(cv.ensure_list, [NOTIFIER_SCHEMA]),
vol.Optional(const.CONF_SETTINGS, default={}): vol.All(lambda value: value or {}, SETTINGS_SCHEMA),
vol.Optional(const.CONF_FILTER): BASE_FILTER_SCHEMA,
vol.Optional(const.CONF_ENTITY_CONFIG, default={}): vol.All(
lambda value: value or {},
{cv.entity_id: ENTITY_SCHEMA}
),
vol.Optional(const.CONF_COLOR_PROFILE, default={}): vol.Schema({
cv.string: {vol.In(const.COLOR_NAMES): vol.All(ycv.color_value)}
})
}, extra=vol.PREVENT_EXTRA))
CONFIG_SCHEMA = vol.Schema({
DOMAIN: YANDEX_SMART_HOME_SCHEMA
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass: HomeAssistant, yaml_config: ConfigType):
"""Activate Yandex Smart Home component."""
hass.data[DOMAIN] = {}
hass.data[DOMAIN][NOTIFIERS]: list[YandexNotifier] = []
hass.data[DOMAIN][CONFIG]: Config | None = None
hass.data[DOMAIN][YAML_CONFIG]: ConfigType | None = yaml_config.get(DOMAIN)
hass.data[DOMAIN][CLOUD_MANAGER]: CloudManager | None = None
hass.data[DOMAIN][CLOUD_STREAMS]: dict[str, CloudStream] = {}
async_register_http(hass)
async_setup_notifier(hass)
def _device_discovery_listener(_: Event):
for entry in hass.config_entries.async_entries(DOMAIN):
if not entry.data[const.CONF_DEVICES_DISCOVERED]:
data = dict(entry.data)
data[const.CONF_DEVICES_DISCOVERED] = True
hass.config_entries.async_update_entry(entry, data=data, options=entry.options)
hass.bus.async_listen(EVENT_DEVICE_DISCOVERY, _device_discovery_listener)
async def _handle_reload(*_):
config = await async_integration_yaml_config(hass, DOMAIN)
if config:
hass.data[DOMAIN][YAML_CONFIG] = config.get(DOMAIN)
_update_config_entries(hass)
hass.helpers.service.async_register_admin_service(DOMAIN, SERVICE_RELOAD, _handle_reload)
_update_config_entries(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
yaml_config = hass.data[DOMAIN][YAML_CONFIG] or {}
entity_config = yaml_config.get(const.CONF_ENTITY_CONFIG)
entity_filter_config = yaml_config.get(const.CONF_FILTER, entry.options.get(const.CONF_FILTER))
config = Config(
hass=hass,
entry=entry,
entity_config=entity_config,
entity_filter=FILTER_SCHEMA(entity_filter_config) if entity_filter_config else None
)
await config.async_init()
hass.data[DOMAIN][CONFIG] = config
if config.is_cloud_connection:
cloud_manager = CloudManager(hass, config, async_get_clientsession(hass))
hass.data[DOMAIN][CLOUD_MANAGER] = cloud_manager
hass.loop.create_task(cloud_manager.connect())
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, cloud_manager.disconnect
)
)
await async_start_notifier(hass)
return True
async def async_unload_entry(hass: HomeAssistant, _: ConfigEntry):
if hass.data[DOMAIN][CLOUD_MANAGER]:
hass.async_create_task(hass.data[DOMAIN][CLOUD_MANAGER].disconnect())
hass.data[DOMAIN][CONFIG]: Config | None = None
hass.data[DOMAIN][CLOUD_MANAGER]: CloudManager | None = None
await async_unload_notifier(hass)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry):
if entry.data[const.CONF_CONNECTION_TYPE] == const.CONNECTION_TYPE_CLOUD:
await delete_cloud_instance(hass, entry)
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
await hass.config_entries.async_reload(entry.entry_id)
hass.bus.async_fire(EVENT_CONFIG_CHANGED)
def get_config_entry_data_from_yaml_config(data: dict, options: dict, yaml_config: ConfigType | None) -> (dict, dict):
data, options = data.copy(), options.copy()
data.setdefault(const.CONF_CONNECTION_TYPE, const.CONNECTION_TYPE_DIRECT)
data.setdefault(const.CONF_DEVICES_DISCOVERED, True) # <0.3 migration
for v in [const.PRESSURE_UNIT_MMHG, const.CONF_BETA, const.CONF_CLOUD_STREAM,
const.CONF_NOTIFIER, const.YAML_CONFIG_HASH]:
if v in data:
del data[v]
for v in [const.CONF_COLOR_PROFILE]:
if v in options:
del options[v]
if yaml_config:
data.update({
const.CONF_NOTIFIER: yaml_config[const.CONF_NOTIFIER],
const.YAML_CONFIG_HASH: _yaml_config_checksum(yaml_config)
})
options.update({
const.CONF_COLOR_PROFILE: yaml_config[const.CONF_COLOR_PROFILE],
})
options.update(yaml_config[const.CONF_SETTINGS])
else:
options.update(SETTINGS_SCHEMA(data={}))
if data[const.CONF_CONNECTION_TYPE] == const.CONNECTION_TYPE_CLOUD:
options[const.CONF_CLOUD_STREAM] = True
return data, options
@callback
def _update_config_entries(hass: HomeAssistant):
for entry in hass.config_entries.async_entries(DOMAIN):
data, options = get_config_entry_data_from_yaml_config(
entry.data, entry.options, hass.data[DOMAIN][YAML_CONFIG]
)
hass.config_entries.async_update_entry(entry, data=data, options=options)
def _yaml_config_checksum(yaml_config: ConfigType) -> str:
def _order_dict(d):
return {k: _order_dict(v) if isinstance(v, dict) else v for k, v in sorted(d.items())}
return hashlib.md5(repr(_order_dict(yaml_config)).encode('utf8')).hexdigest()
@@ -0,0 +1,84 @@
"""Implement the Yandex Smart Home capabilities."""
from __future__ import annotations
from abc import ABC, abstractmethod
import logging
from typing import Any, Type
from homeassistant.core import HomeAssistant, State
from .helpers import Config, RequestData
_LOGGER = logging.getLogger(__name__)
PREFIX_CAPABILITIES = 'devices.capabilities.'
CAPABILITIES: list[Type[AbstractCapability]] = []
def register_capability(capability):
"""Decorate a function to register a capability."""
CAPABILITIES.append(capability)
return capability
class AbstractCapability(ABC):
"""Represents a Capability."""
type = ''
instance = ''
retrievable = True
def __init__(self, hass: HomeAssistant, config: Config, state: State):
"""Initialize a trait for a state."""
self.hass = hass
self.state = state
self.entity_config = config.get_entity_config(state.entity_id)
self.reportable = config.is_reporting_state
self._cache = config.cache
@abstractmethod
def supported(self) -> bool:
"""Test if capability is supported."""
pass
def description(self) -> dict[str, Any]:
"""Return description for a devices request."""
response = {
'type': self.type,
'retrievable': self.retrievable,
'reportable': self.reportable,
}
parameters = self.parameters()
if parameters is not None:
response['parameters'] = parameters
return response
def get_state(self) -> dict[str, Any]:
"""Return the state of this capability for this entity."""
value = self.get_value()
return {
'type': self.type,
'state': {
'instance': self.instance,
'value': value
}
} if value is not None else None
@abstractmethod
def parameters(self) -> dict[str, Any] | None:
"""Return parameters for a devices request."""
pass
@abstractmethod
def get_value(self) -> float | str | bool | None:
"""Return the state value of this capability for this entity."""
pass
@abstractmethod
async def set_state(self, data: RequestData, state: dict[str, Any]) -> dict[str, Any] | None:
"""Set device state."""
pass
@@ -0,0 +1,542 @@
"""Implement the Yandex Smart Home color capabilities."""
from __future__ import annotations
from abc import ABC
import logging
from math import sqrt
from typing import Any
from homeassistant.components import light
from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES
from homeassistant.core import HomeAssistant, State
from homeassistant.util.color import RGBColor, color_hs_to_RGB, color_temperature_mired_to_kelvin, color_xy_to_RGB
from . import const
from .capability import PREFIX_CAPABILITIES, AbstractCapability, register_capability
from .const import CONF_ENTITY_MODE_MAP, ERR_NOT_SUPPORTED_IN_CURRENT_MODE
from .error import SmartHomeError
from .helpers import Config, RequestData
_LOGGER = logging.getLogger(__name__)
CAPABILITIES_COLOR_SETTING = PREFIX_CAPABILITIES + 'color_setting'
COLOR_MODES_TEMP_TO_WHITE = (
light.ColorMode.RGBW,
light.ColorMode.RGB,
light.ColorMode.HS,
light.ColorMode.XY
)
COLOR_PROFILES: dict[str, dict[str]: tuple[int, int, int]] = {
'natural': {
const.COLOR_NAME_RED: 16711680,
const.COLOR_NAME_YELLOW: 16760576,
const.COLOR_NAME_GREEN: 65280,
const.COLOR_NAME_EMERALD: 2424612,
const.COLOR_NAME_TURQUOISE: 65471,
const.COLOR_NAME_CYAN: 65535,
const.COLOR_NAME_BLUE: 255,
const.COLOR_NAME_MOONLIGHT: 16763025,
const.COLOR_NAME_LAVENDER: 4129023,
const.COLOR_NAME_VIOLET: 8323327,
const.COLOR_NAME_PURPLE: 12517631,
const.COLOR_NAME_ORCHID: 16711765,
const.COLOR_NAME_RASPBERRY: 16713260
}
}
class ColorSettingCapability(AbstractCapability, ABC):
"""Base color setting functionality.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/color_setting-docpage/
"""
type = CAPABILITIES_COLOR_SETTING
scenes_map_default = {
const.COLOR_SCENE_ALARM: ['Тревога', 'Alarm', 'Shine', 'Strobe Mega'],
const.COLOR_SCENE_ALICE: ['Алиса', 'Alice', 'Meeting'],
const.COLOR_SCENE_CANDLE: ['Свеча', 'Огонь', 'Candle', 'Fire'],
const.COLOR_SCENE_DINNER: ['Ужин', 'Dinner'],
const.COLOR_SCENE_FANTASY: ['Фантазия', 'Fantasy', 'Random', 'Beautiful', 'Sinelon Rainbow'],
const.COLOR_SCENE_GARLAND: ['Гирлянда', 'Garland', 'Dynamic'],
const.COLOR_SCENE_JUNGLE: ['Джунгли', 'Jungle'],
const.COLOR_SCENE_MOVIE: ['Кино', 'Movie'],
const.COLOR_SCENE_NEON: ['Неон', 'Neon', 'Breath'],
const.COLOR_SCENE_NIGHT: ['Ночь', 'Night', 'Aurora'],
const.COLOR_SCENE_OCEAN: ['Океан', 'Ocean', 'Pacifica'],
const.COLOR_SCENE_PARTY: ['Вечеринка', 'Party', 'Juggle'],
const.COLOR_SCENE_READING: ['Чтение', 'Reading', 'Read'],
const.COLOR_SCENE_REST: ['Отдых', 'Rest', 'Soft'],
const.COLOR_SCENE_ROMANCE: ['Романтика', 'Romance', 'Leasure', 'Lake'],
const.COLOR_SCENE_SIREN: ['Сирена', 'Siren', 'Police', 'Rainbow'],
const.COLOR_SCENE_SUNRISE: ['Рассвет', 'Sunrise'],
const.COLOR_SCENE_SUNSET: ['Закат', 'Sunset']
}
default_white_temperature_k = 4500
cold_white_temperature_k = 6500
def __init__(self, hass: HomeAssistant, config: Config, state: State):
super().__init__(hass, config, state)
if self.state.domain == light.DOMAIN:
self._color_profiles = COLOR_PROFILES.copy()
self._color_profiles.update(config.color_profiles)
def parameters(self) -> dict[str, Any]:
"""Return parameters for a devices request."""
result = {}
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported_color_modes = self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
if self.support_color:
result['color_model'] = 'rgb'
if features & light.SUPPORT_COLOR_TEMP or light.color_temp_supported(supported_color_modes):
min_temp, max_temp = self._temperature_converter.supported_temperature_range
result['temperature_k'] = {
'min': min_temp,
'max': max_temp
}
else:
min_temp = self.default_white_temperature_k
max_temp = self.default_white_temperature_k
if light.ColorMode.RGBW in supported_color_modes or light.ColorMode.WHITE in supported_color_modes:
max_temp = self.cold_white_temperature_k
for color_mode in COLOR_MODES_TEMP_TO_WHITE:
if color_mode in supported_color_modes:
result['temperature_k'] = {
'min': min_temp,
'max': max_temp
}
break
if features & light.LightEntityFeature.EFFECT:
supported_scenes = self.get_supported_scenes(
self.get_scenes_map_from_config(self.entity_config),
self.state.attributes.get(light.ATTR_EFFECT_LIST) or []
)
if supported_scenes:
result['color_scene'] = {
'scenes': [
{'id': s}
for s in supported_scenes
]
}
return result
@property
def support_color(self) -> bool:
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported_color_modes = self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
if self.state.domain != light.DOMAIN:
return False
if features & light.SUPPORT_COLOR: # legacy
return True
for color_mode in supported_color_modes:
if color_mode in [light.ColorMode.RGB, light.ColorMode.RGBW, light.ColorMode.RGBWW,
light.ColorMode.HS, light.ColorMode.XY]:
return True
return False
@staticmethod
def get_supported_scenes(scenes_map: dict[str, list[str]],
entity_effect_list: list[str]) -> list[str]:
yandex_scenes = set()
for effect in entity_effect_list:
for yandex_scene, ha_effects in scenes_map.items():
if effect in ha_effects:
yandex_scenes.add(yandex_scene)
return sorted(list(yandex_scenes))
@staticmethod
def get_scenes_map_from_config(entity_config: dict[str, Any]) -> dict[str, list[str]]:
scenes_map = ColorSettingCapability.scenes_map_default.copy()
instance = const.COLOR_SETTING_SCENE
if CONF_ENTITY_MODE_MAP in entity_config:
modes = entity_config.get(CONF_ENTITY_MODE_MAP)
if instance in modes:
config_scenes = modes.get(instance)
for yandex_scene in scenes_map.keys():
if yandex_scene in config_scenes.keys():
scenes_map[yandex_scene] = config_scenes[yandex_scene]
return scenes_map
def get_yandex_scene_by_ha_effect(self, ha_effect: str) -> str | None:
scenes_map = self.get_scenes_map_from_config(self.entity_config)
for yandex_scene, ha_effects in scenes_map.items():
if str(ha_effect) in ha_effects:
return yandex_scene
return None
def get_ha_effect_by_yandex_scene(self, yandex_scene: str) -> str | None:
scenes_map = self.get_scenes_map_from_config(self.entity_config)
ha_effects = scenes_map.get(yandex_scene)
if not ha_effects:
return None
for ha_effect in ha_effects:
for am in self.state.attributes.get(light.ATTR_EFFECT_LIST, []):
if str(am) == ha_effect:
return ha_effect
@property
def _temperature_converter(self) -> TemperatureConverter:
color_profile = None
color_profile_name = self.entity_config.get(const.CONF_COLOR_PROFILE)
if color_profile_name:
try:
color_profile = self._color_profiles[color_profile_name]
except KeyError:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Color profile {color_profile_name!r} not found for instance {self.instance} '
f'of {self.state.entity_id}'
)
return TemperatureConverter(color_profile, self.state)
@register_capability
class RgbCapability(ColorSettingCapability):
"""RGB color functionality."""
instance = const.COLOR_SETTING_RGB
def supported(self) -> bool:
"""Test if capability is supported."""
return self.support_color
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
if self.state.attributes.get(light.ATTR_COLOR_MODE) == light.COLOR_MODE_COLOR_TEMP:
return None
rgb_color = self.state.attributes.get(light.ATTR_RGB_COLOR)
if rgb_color is None:
hs_color = self.state.attributes.get(light.ATTR_HS_COLOR)
if hs_color is not None:
rgb_color = color_hs_to_RGB(*hs_color)
xy_color = self.state.attributes.get(light.ATTR_XY_COLOR)
if xy_color is not None:
rgb_color = color_xy_to_RGB(*xy_color)
if rgb_color is not None:
if rgb_color == (255, 255, 255):
return None
return self._color_converter.get_yandex_color(RGBColor(*rgb_color))
async def set_state(self, data: RequestData, state: dict[str, Any]):
await self.hass.services.async_call(
light.DOMAIN,
light.SERVICE_TURN_ON, {
ATTR_ENTITY_ID: self.state.entity_id,
light.ATTR_RGB_COLOR: self._color_converter.get_ha_rgb_color(int(state['value']))
},
blocking=True,
context=data.context
)
@property
def _color_converter(self) -> ColorConverter:
color_profile_name = self.entity_config.get(const.CONF_COLOR_PROFILE)
if color_profile_name:
try:
return ColorConverter(self._color_profiles[color_profile_name])
except KeyError:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Color profile {color_profile_name!r} not found for instance {self.instance} '
f'of {self.state.entity_id}'
)
return ColorConverter()
@register_capability
class TemperatureKCapability(ColorSettingCapability):
"""Color temperature functionality."""
instance = const.COLOR_SETTING_TEMPERATURE_K
def supported(self) -> bool:
"""Test if capability is supported."""
if self.state.domain == light.DOMAIN:
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported_color_modes = self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
if features & light.SUPPORT_COLOR_TEMP or light.color_temp_supported(supported_color_modes):
return True
for color_mode in COLOR_MODES_TEMP_TO_WHITE:
if color_mode in supported_color_modes:
return True
return False
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
temperature_mired = self.state.attributes.get(light.ATTR_COLOR_TEMP)
supported_color_modes = self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
color_mode = self.state.attributes.get(light.ATTR_COLOR_MODE)
if temperature_mired is not None:
return self._temperature_converter.get_yandex_temperature(temperature_mired)
if color_mode == light.ColorMode.WHITE:
return self.default_white_temperature_k
if color_mode == light.ColorMode.RGBW:
rgbw_color = self.state.attributes.get(light.ATTR_RGBW_COLOR)
if rgbw_color is not None:
if rgbw_color[:3] == (0, 0, 0) and rgbw_color[3] > 0:
return self.default_white_temperature_k
elif rgbw_color[:3] == (255, 255, 255):
return self.cold_white_temperature_k
return None
if color_mode in [light.ColorMode.RGB, light.ColorMode.HS, light.ColorMode.XY]:
rgb_color = self.state.attributes.get(light.ATTR_RGB_COLOR)
if rgb_color is not None and rgb_color == (255, 255, 255):
if light.ColorMode.WHITE in supported_color_modes:
return self.cold_white_temperature_k
return self.default_white_temperature_k
return None
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported_color_modes = self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
value = state['value']
service_data = {}
if features & light.SUPPORT_COLOR_TEMP or \
light.color_temp_supported(self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])):
service_data[light.ATTR_KELVIN] = self._temperature_converter.get_ha_temperature(value)
elif light.ColorMode.WHITE in supported_color_modes and value == self.default_white_temperature_k:
service_data[light.ATTR_WHITE] = self.state.attributes.get(light.ATTR_BRIGHTNESS, 255)
elif light.ColorMode.RGBW in supported_color_modes:
if value == self.default_white_temperature_k:
service_data[light.ATTR_RGBW_COLOR] = (0, 0, 0, self.state.attributes.get(light.ATTR_BRIGHTNESS, 255))
else:
service_data[light.ATTR_RGBW_COLOR] = (255, 255, 255, 0)
elif light.ColorMode.RGB in supported_color_modes or \
light.ColorMode.HS in supported_color_modes or \
light.ColorMode.XY in supported_color_modes:
service_data[light.ATTR_RGB_COLOR] = (255, 255, 255)
if service_data:
service_data[ATTR_ENTITY_ID] = self.state.entity_id
await self.hass.services.async_call(
light.DOMAIN,
light.SERVICE_TURN_ON,
service_data,
blocking=True,
context=data.context
)
else:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Unsupported value {value!r} for instance {self.instance} of {self.state.entity_id}'
)
@register_capability
class ColorSceneCapability(ColorSettingCapability):
"""Color temperature functionality."""
instance = const.COLOR_SETTING_SCENE
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == light.DOMAIN and features & light.LightEntityFeature.EFFECT:
return bool(
self.get_supported_scenes(
self.get_scenes_map_from_config(self.entity_config),
self.state.attributes.get(light.ATTR_EFFECT_LIST) or []
)
)
def get_value(self) -> str | None:
"""Return the state value of this capability for this entity."""
return self.get_yandex_scene_by_ha_effect(self.state.attributes.get(light.ATTR_EFFECT))
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
await self.hass.services.async_call(
light.DOMAIN,
light.SERVICE_TURN_ON, {
ATTR_ENTITY_ID: self.state.entity_id,
light.ATTR_EFFECT: self.get_ha_effect_by_yandex_scene(state['value']),
},
blocking=True,
context=data.context
)
class ColorConverter:
_palette = {
const.COLOR_NAME_RED: 16714250,
const.COLOR_NAME_CORAL: 16729907,
const.COLOR_NAME_ORANGE: 16727040,
const.COLOR_NAME_YELLOW: 16740362,
const.COLOR_NAME_LIME: 13303562,
const.COLOR_NAME_GREEN: 720711,
const.COLOR_NAME_EMERALD: 720813,
const.COLOR_NAME_TURQUOISE: 720883,
const.COLOR_NAME_CYAN: 710399,
const.COLOR_NAME_BLUE: 673791,
const.COLOR_NAME_MOONLIGHT: 15067647,
const.COLOR_NAME_LAVENDER: 8719103,
const.COLOR_NAME_VIOLET: 11340543,
const.COLOR_NAME_PURPLE: 16714471,
const.COLOR_NAME_ORCHID: 16714393,
const.COLOR_NAME_MAUVE: 16722742,
const.COLOR_NAME_RASPBERRY: 16711765,
}
def __init__(self, profile: dict[str, int] | None = None):
profile = profile or {}
self._yandex_value_to_ha: dict[int, int] = {}
self._ha_value_to_yandex: dict[int, int] = {}
for name, yandex_value in self._palette.items():
ha_value = profile.get(name, yandex_value)
self._yandex_value_to_ha[yandex_value] = ha_value
self._ha_value_to_yandex[ha_value] = yandex_value
def get_ha_rgb_color(self, yandex_color: int) -> tuple[int, int, int]:
return self._int_to_rgb(
self._yandex_value_to_ha.get(yandex_color, yandex_color)
)
def get_yandex_color(self, ha_color: RGBColor) -> int:
for ha_color_value, yandex_color_value in self._ha_value_to_yandex.items():
if self._distance(ha_color, RGBColor(*self._int_to_rgb(ha_color_value))) <= 2:
return yandex_color_value
return self.rgb_to_int(*ha_color)
@staticmethod
def rgb_to_int(r: int, g: int, b: int) -> int:
rv = r
rv = (rv << 8) + g
rv = (rv << 8) + b
return rv
@staticmethod
def _int_to_rgb(i: int) -> tuple[int, int, int]:
return (
(i >> 16) & 0xFF,
(i >> 8) & 0xFF,
i & 0xFF
)
@staticmethod
def _distance(a: RGBColor, b: RGBColor) -> float:
return abs(sqrt(
(a.r - b.r) ** 2 +
(a.g - b.g) ** 2 +
(a.b - b.b) ** 2
))
class TemperatureConverter:
_palette = {
const.COLOR_TEMPERATURE_NAME_FIERY_WHITE: 1500,
const.COLOR_TEMPERATURE_NAME_SOFT_WHITE: 2700,
const.COLOR_TEMPERATURE_NAME_WARM_WHITE: 3400,
const.COLOR_TEMPERATURE_NAME_WHITE: 4500,
const.COLOR_TEMPERATURE_NAME_DAYLIGHT: 5600,
const.COLOR_TEMPERATURE_NAME_COLD_WHITE: 6500,
const.COLOR_TEMPERATURE_NAME_MISTY_WHITE: 7500,
const.COLOR_TEMPERATURE_NAME_HEAVENLY_WHITE: 9000
}
_temperature_steps = sorted(_palette.values())
def __init__(self, profile: dict[str, int] | None = None, state: State | None = None):
self._yandex_value_to_ha: dict[int, int] = {}
self._ha_value_to_yandex: dict[int, int] = {}
profile = profile or {}
range_extend_threshold = 200
min_temp = self._color_temperature_mired_to_kelvin(state.attributes.get(light.ATTR_MAX_MIREDS, 500))
max_temp = self._color_temperature_mired_to_kelvin(state.attributes.get(light.ATTR_MIN_MIREDS, 153))
for name, yandex_temperature in self._palette.items():
ha_temperature = self._round_kelvin_temperature(profile.get(name, yandex_temperature))
if ha_temperature < min_temp or ha_temperature > max_temp:
continue
self._map_values(yandex_temperature, ha_temperature)
if min_temp + range_extend_threshold < min(self._ha_value_to_yandex):
if yandex_temperature := self._first_available_temperature_step:
self._map_values(yandex_temperature, min_temp)
if max_temp - range_extend_threshold > max(self._ha_value_to_yandex):
if yandex_temperature := self._last_available_temperature_step:
self._map_values(yandex_temperature, max_temp)
def get_ha_temperature(self, yandex_temperature: int) -> int:
return self._yandex_value_to_ha.get(yandex_temperature, yandex_temperature)
def get_yandex_temperature(self, ha_temperature_mired: int) -> int:
ha_temperature_k = self._color_temperature_mired_to_kelvin(ha_temperature_mired)
return self._ha_value_to_yandex.get(ha_temperature_k, ha_temperature_k)
@property
def supported_temperature_range(self) -> (int, int):
return min(self._yandex_value_to_ha), max(self._yandex_value_to_ha)
@staticmethod
def _round_kelvin_temperature(kelvin_temperature: int) -> int:
return round(kelvin_temperature, -2)
def _color_temperature_mired_to_kelvin(self, mired_temperature: float) -> int:
return self._round_kelvin_temperature(color_temperature_mired_to_kelvin(mired_temperature))
@property
def _first_available_temperature_step(self) -> int | None:
min_temp = min(self._yandex_value_to_ha)
idx = self._temperature_steps.index(min_temp)
if idx != 0:
return self._temperature_steps[idx - 1]
@property
def _last_available_temperature_step(self) -> int | None:
max_temp = max(self._yandex_value_to_ha)
idx = self._temperature_steps.index(max_temp)
try:
return self._temperature_steps[idx + 1]
except IndexError:
return None
def _map_values(self, yandex_value: int, ha_value: int):
self._yandex_value_to_ha[yandex_value] = ha_value
self._ha_value_to_yandex[ha_value] = yandex_value
@@ -0,0 +1,203 @@
"""Implement the Yandex Smart Home custom capabilities."""
from __future__ import annotations
from abc import ABC
import itertools
import logging
from typing import Any
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.service import async_call_from_config
from . import const
from .capability import AbstractCapability
from .capability_mode import ModeCapability
from .capability_range import RangeCapability
from .capability_toggle import ToggleCapability
from .const import (
CONF_ENTITY_MODE_MAP,
CONF_ENTITY_RANGE,
CONF_ENTITY_RANGE_MAX,
CONF_ENTITY_RANGE_MIN,
CONF_ENTITY_RANGE_PRECISION,
ERR_DEVICE_UNREACHABLE,
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
)
from .error import SmartHomeError
from .helpers import Config, RequestData
_LOGGER = logging.getLogger(__name__)
class CustomCapability(AbstractCapability, ABC):
def __init__(self, hass: HomeAssistant, config: Config, state: State,
instance: str, capability_config: dict[str, Any]):
super().__init__(hass, config, state)
self.instance = instance
self.capability_config = capability_config
self.state_entity_id = self.capability_config.get(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID)
self.retrievable = bool(self.state_entity_id or self.state_value_attribute)
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return self.capability_config.get(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE)
def get_value(self) -> float | str | None:
"""Return the state value of this capability for this entity."""
if not self.retrievable:
return None
entity_state = self.state
if self.state_entity_id:
entity_state = self.hass.states.get(self.state_entity_id)
if not entity_state:
raise SmartHomeError(
ERR_DEVICE_UNREACHABLE,
f'Entity {self.state_entity_id} not found for {self.instance} instance of {self.state.entity_id}'
)
if self.state_value_attribute:
value = entity_state.attributes.get(self.state_value_attribute)
else:
value = entity_state.state
return value
class CustomModeCapability(CustomCapability, ModeCapability):
def __init__(self, hass: HomeAssistant, config: Config, state: State,
instance: str, capability_config: dict[str, Any]):
super().__init__(hass, config, state, instance, capability_config)
self.set_mode_config = self.capability_config[const.CONF_ENTITY_CUSTOM_MODE_SET_MODE]
@property
def supported_ha_modes(self) -> list[str]:
"""Returns list of supported HA modes for this entity."""
modes = self.entity_config.get(CONF_ENTITY_MODE_MAP, {}).get(self.instance, {})
rv = list(itertools.chain(*modes.values()))
return rv
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return None
def get_value(self) -> str | None:
"""Return the state value of this capability for this entity."""
if not self.retrievable:
return None
return self.get_yandex_mode_by_ha_mode(super().get_value())
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
await async_call_from_config(
self.hass,
self.set_mode_config,
validate_config=False,
variables={'mode': self.get_ha_mode_by_yandex_mode(state['value'])},
blocking=True,
context=data.context
)
class CustomToggleCapability(CustomCapability, ToggleCapability):
def __init__(self, hass: HomeAssistant, config: Config, state: State,
instance: str, capability_config: dict[str, Any]):
super().__init__(hass, config, state, instance, capability_config)
self.turn_on_config = self.capability_config[const.CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON]
self.turn_off_config = self.capability_config[const.CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF]
def supported(self) -> bool:
"""Test if capability is supported."""
return True
def get_value(self) -> bool | None:
"""Return the state value of this capability for this entity."""
if not self.retrievable:
return None
return not super().get_value() in [STATE_OFF, False]
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
await async_call_from_config(
self.hass,
self.turn_on_config if state['value'] else self.turn_off_config,
validate_config=False,
blocking=True,
context=data.context
)
class CustomRangeCapability(CustomCapability, RangeCapability):
def __init__(self, hass: HomeAssistant, config: Config, state: State,
instance: str, capability_config: dict[str, Any]):
self.capability_config = capability_config
self.set_value = self.capability_config.get(const.CONF_ENTITY_CUSTOM_RANGE_SET_VALUE)
self.increase_value = self.capability_config.get(const.CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE)
self.decrease_value = self.capability_config.get(const.CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE)
super().__init__(hass, config, state, instance, capability_config)
self.default_range = (
self.capability_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MIN, self.default_range[0]),
self.capability_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MAX, self.default_range[1]),
self.capability_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_PRECISION, self.default_range[2])
)
def supported(self) -> bool:
"""Test if capability is supported."""
return True
@property
def support_random_access(self) -> bool:
"""Test if capability supports random access."""
for key in [CONF_ENTITY_RANGE_MIN, CONF_ENTITY_RANGE_MAX]:
if key not in self.capability_config.get(CONF_ENTITY_RANGE, {}):
return False
return self.set_value is not None
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
if not self.retrievable:
return None
return self.float_value(super().get_value())
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value']
service = self.set_value
if state.get('relative'):
if self.increase_value or self.decrease_value:
if value >= 0:
service = self.increase_value
else:
service = self.decrease_value
else:
if not self.retrievable:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Failed to set relative value for {self.instance} instance of {self.state.entity_id}. '
f'No state or service found.'
)
value = self.get_absolute_value(state['value'])
await async_call_from_config(
self.hass,
service,
validate_config=False,
variables={'value': value},
blocking=True,
context=data.context
)
@@ -0,0 +1,757 @@
"""Implement the Yandex Smart Home modes capabilities."""
from __future__ import annotations
from abc import ABC, abstractmethod
import logging
import math
from typing import Any
from homeassistant.components import climate, fan, humidifier, media_player, vacuum
from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.util.percentage import ordered_list_item_to_percentage, percentage_to_ordered_list_item
from . import const
from .capability import PREFIX_CAPABILITIES, AbstractCapability, register_capability
from .const import CONF_ENTITY_MODE_MAP, ERR_INVALID_VALUE, STATE_NONE
from .error import SmartHomeError
from .helpers import RequestData
_LOGGER = logging.getLogger(__name__)
CAPABILITIES_MODE = PREFIX_CAPABILITIES + 'mode'
class ModeCapability(AbstractCapability, ABC):
"""Base class of capabilities with mode functionality like thermostat mode
or fan speed.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-docpage/
"""
type = CAPABILITIES_MODE
modes_map_default: dict[str, list[str]] = {}
modes_map_index_fallback: dict[int, str] = {}
def supported(self) -> bool:
"""Test if capability is supported."""
return bool(self.supported_yandex_modes)
def parameters(self) -> dict[str, Any]:
"""Return parameters for a devices request."""
return {
'instance': self.instance,
'modes': [{'value': v} for v in self.supported_yandex_modes],
}
@property
def supported_yandex_modes(self) -> list[str]:
"""Returns list of supported Yandex modes for this entity."""
modes = []
for ha_value in self.supported_ha_modes:
value = self.get_yandex_mode_by_ha_mode(ha_value, hide_warnings=True)
if value is not None and value not in modes:
modes.append(value)
return modes
@property
def supported_ha_modes(self) -> list[str]:
"""Returns list of supported HA modes for this entity."""
return self.state.attributes.get(self.modes_list_attribute, []) or []
@property
def modes_map(self) -> dict[str, list[str]]:
"""Return modes mapping between Yandex and HA."""
return self.modes_map_config or self.modes_map_default
@property
def modes_map_config(self) -> dict[str, list[str]] | None:
"""Return modes mapping from entity configuration."""
if CONF_ENTITY_MODE_MAP in self.entity_config:
return self.entity_config[CONF_ENTITY_MODE_MAP].get(self.instance)
return None
@property
@abstractmethod
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
pass
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return None
def get_yandex_mode_by_ha_mode(self, ha_mode: str, hide_warnings=False) -> str | None:
rv = None
for yandex_mode, names in self.modes_map.items():
lower_names = [str(n).lower() for n in names]
if str(ha_mode).lower() in lower_names:
rv = yandex_mode
break
if rv is None and self.modes_map_config is None and self.modes_map_index_fallback:
try:
rv = self.modes_map_index_fallback[self.supported_ha_modes.index(ha_mode)]
except (IndexError, ValueError, KeyError):
pass
if rv is not None and ha_mode not in self.supported_ha_modes:
err = f'Unsupported HA mode "{ha_mode}" for {self.instance} instance of {self.state.entity_id}.'
if self.modes_list_attribute:
err += f' Maybe it missing in entity attribute {self.modes_list_attribute}?'
raise SmartHomeError(ERR_INVALID_VALUE, err)
if rv is None and not hide_warnings:
if str(ha_mode).lower() not in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE):
if str(ha_mode).lower() in [str(m).lower() for m in self.supported_ha_modes]:
_LOGGER.warning(
f'Unable to get Yandex mode for "{ha_mode}" for {self.instance} instance '
f'of {self.state.entity_id}. It may cause inconsistencies between Yandex and HA. '
f'Check \"modes\" setting for this entity'
)
return rv
def get_ha_mode_by_yandex_mode(self, yandex_mode: str) -> str:
ha_modes = self.modes_map.get(yandex_mode, [])
for ha_mode in ha_modes:
for am in self.supported_ha_modes:
if str(am).lower() == str(ha_mode).lower():
return am
if self.modes_map_config is None:
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 SmartHomeError(
ERR_INVALID_VALUE,
f'Unsupported mode "{yandex_mode}" for {self.instance} instance of {self.state.entity_id}. '
f'Check \"modes\" setting for this entity'
)
def get_value(self) -> str | None:
"""Return the state value of this capability for this entity."""
ha_mode = self.state.state
if self.state_value_attribute:
ha_mode = self.state.attributes.get(self.state_value_attribute)
return self.get_yandex_mode_by_ha_mode(ha_mode, False)
@register_capability
class ThermostatCapability(ModeCapability):
"""Thermostat functionality"""
instance = const.MODE_INSTANCE_THERMOSTAT
modes_map_default = {
const.MODE_INSTANCE_MODE_HEAT: [climate.HVACMode.HEAT],
const.MODE_INSTANCE_MODE_COOL: [climate.HVACMode.COOL],
const.MODE_INSTANCE_MODE_AUTO: [climate.HVACMode.HEAT_COOL, climate.HVACMode.AUTO],
const.MODE_INSTANCE_MODE_DRY: [climate.HVACMode.DRY],
const.MODE_INSTANCE_MODE_FAN_ONLY: [climate.HVACMode.FAN_ONLY],
}
def supported(self) -> bool:
"""Test if capability is supported."""
if self.state.domain == climate.DOMAIN:
return super().supported()
return False
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return climate.ATTR_HVAC_MODES
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
@register_capability
class SwingCapability(ModeCapability):
"""Swing functionality"""
instance = const.MODE_INSTANCE_SWING
modes_map_default = {
const.MODE_INSTANCE_MODE_VERTICAL: [climate.const.SWING_VERTICAL, 'ud'],
const.MODE_INSTANCE_MODE_HORIZONTAL: [climate.const.SWING_HORIZONTAL, 'lr'],
const.MODE_INSTANCE_MODE_STATIONARY: [climate.const.SWING_OFF],
const.MODE_INSTANCE_MODE_AUTO: [climate.const.SWING_BOTH, 'all']
}
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == climate.DOMAIN and features & climate.ClimateEntityFeature.SWING_MODE:
return super().supported()
return False
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return climate.ATTR_SWING_MODES
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return climate.ATTR_SWING_MODE
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
class ProgramCapability(ModeCapability, ABC):
"""Program functionality"""
instance = const.MODE_INSTANCE_PROGRAM
modes_map_index_fallback = {
0: const.MODE_INSTANCE_MODE_ONE,
1: const.MODE_INSTANCE_MODE_TWO,
2: const.MODE_INSTANCE_MODE_THREE,
3: const.MODE_INSTANCE_MODE_FOUR,
4: const.MODE_INSTANCE_MODE_FIVE,
5: const.MODE_INSTANCE_MODE_SIX,
6: const.MODE_INSTANCE_MODE_SEVEN,
7: const.MODE_INSTANCE_MODE_EIGHT,
8: const.MODE_INSTANCE_MODE_NINE,
9: const.MODE_INSTANCE_MODE_TEN,
}
@register_capability
class ProgramCapabilityHumidifier(ProgramCapability):
"""Program functionality"""
modes_map_default = {
const.MODE_INSTANCE_MODE_FAN_ONLY: [
const.XIAOMI_AIRPURIFIER_PRESET_FAN,
],
const.MODE_INSTANCE_MODE_AUTO: [
humidifier.const.MODE_AUTO,
],
const.MODE_INSTANCE_MODE_ECO: [
humidifier.const.MODE_ECO,
const.XIAOMI_AIRPURIFIER_PRESET_IDLE,
],
const.MODE_INSTANCE_MODE_QUIET: [
humidifier.const.MODE_SLEEP,
const.XIAOMI_AIRPURIFIER_PRESET_SILENT,
],
const.MODE_INSTANCE_MODE_LOW: [
const.XIAOMI_AIRPURIFIER_PRESET_LOW,
],
const.MODE_INSTANCE_MODE_MIN: [
humidifier.const.MODE_AWAY
],
const.MODE_INSTANCE_MODE_MEDIUM: [
humidifier.const.MODE_COMFORT,
const.XIAOMI_AIRPURIFIER_PRESET_MEDIUM,
const.XIAOMI_AIRPURIFIER_PRESET_MIDDLE,
const.XIAOMI_HUMIDIFIER_PRESET_MID,
],
const.MODE_INSTANCE_MODE_NORMAL: [
humidifier.const.MODE_NORMAL,
const.XIAOMI_AIRPURIFIER_PRESET_FAVORITE,
],
const.MODE_INSTANCE_MODE_MAX: [
humidifier.const.MODE_HOME,
],
const.MODE_INSTANCE_MODE_HIGH: [
humidifier.const.MODE_BABY,
const.XIAOMI_AIRPURIFIER_PRESET_HIGH,
],
const.MODE_INSTANCE_MODE_TURBO: [
humidifier.const.MODE_BOOST,
const.XIAOMI_AIRPURIFIER_PRESET_STRONG,
],
}
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == humidifier.DOMAIN and features & humidifier.HumidifierEntityFeature.MODES:
return super().supported()
return False
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return humidifier.ATTR_AVAILABLE_MODES
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return humidifier.ATTR_MODE
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
@register_capability
class ProgramCapabilityFan(ProgramCapability):
modes_map_default = {
const.MODE_INSTANCE_MODE_AUTO: [
climate.const.FAN_AUTO,
],
const.MODE_INSTANCE_MODE_ECO: [
const.XIAOMI_AIRPURIFIER_PRESET_IDLE,
],
const.MODE_INSTANCE_MODE_QUIET: [
const.XIAOMI_AIRPURIFIER_PRESET_SILENT,
const.XIAOMI_FAN_PRESET_LEVEL_1,
const.XIAOMI_FAN_PRESET_NATURE,
],
const.MODE_INSTANCE_MODE_LOW: [
const.XIAOMI_AIRPURIFIER_PRESET_LOW,
const.XIAOMI_FAN_PRESET_LEVEL_2,
],
const.MODE_INSTANCE_MODE_MEDIUM: [
const.XIAOMI_HUMIDIFIER_PRESET_MID,
const.XIAOMI_FAN_PRESET_LEVEL_3,
],
const.MODE_INSTANCE_MODE_NORMAL: [
const.XIAOMI_FAN_PRESET_NORMAL,
const.XIAOMI_AIRPURIFIER_PRESET_FAVORITE,
],
const.MODE_INSTANCE_MODE_HIGH: [
const.XIAOMI_FAN_PRESET_LEVEL_4,
],
const.MODE_INSTANCE_MODE_TURBO: [
const.XIAOMI_AIRPURIFIER_PRESET_STRONG,
const.XIAOMI_FAN_PRESET_LEVEL_5,
],
}
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == fan.DOMAIN:
if features & fan.FanEntityFeature.PRESET_MODE:
if features & fan.FanEntityFeature.SET_SPEED and fan.ATTR_PERCENTAGE_STEP in self.state.attributes:
return super().supported()
return False
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return fan.ATTR_PRESET_MODE
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return fan.ATTR_PRESET_MODES
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
@register_capability
class InputSourceCapability(ModeCapability):
"""Input Source functionality"""
instance = const.MODE_INSTANCE_INPUT_SOURCE
modes_map_index_fallback = {
0: const.MODE_INSTANCE_MODE_ONE,
1: const.MODE_INSTANCE_MODE_TWO,
2: const.MODE_INSTANCE_MODE_THREE,
3: const.MODE_INSTANCE_MODE_FOUR,
4: const.MODE_INSTANCE_MODE_FIVE,
5: const.MODE_INSTANCE_MODE_SIX,
6: const.MODE_INSTANCE_MODE_SEVEN,
7: const.MODE_INSTANCE_MODE_EIGHT,
8: const.MODE_INSTANCE_MODE_NINE,
9: const.MODE_INSTANCE_MODE_TEN,
}
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == media_player.DOMAIN:
if const.MEDIA_PLAYER_FEATURE_SELECT_SOURCE in self.entity_config.get(const.CONF_FEATURES, []):
return True
if features & media_player.MediaPlayerEntityFeature.SELECT_SOURCE:
return super().supported()
return False
@property
def supported_ha_modes(self) -> list[str]:
"""Returns list of supported HA modes for this entity."""
modes = self.state.attributes.get(self.modes_list_attribute, []) or []
if modes or self.state.state not in (STATE_OFF, STATE_UNKNOWN):
self._cache.save_attr_value(self.state.entity_id, self.modes_list_attribute, modes)
return modes
return self._cache.get_attr_value(self.state.entity_id, self.modes_list_attribute) or []
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return media_player.ATTR_INPUT_SOURCE_LIST
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return media_player.ATTR_INPUT_SOURCE
def get_yandex_mode_by_ha_mode(self, ha_mode: str, hide_warnings=False) -> str | None:
return super().get_yandex_mode_by_ha_mode(ha_mode, hide_warnings=True)
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
class FanSpeedCapability(ModeCapability, ABC):
"""Fan speed functionality."""
instance = const.MODE_INSTANCE_FAN_SPEED
@register_capability
class FanSpeedCapabilityClimate(FanSpeedCapability):
modes_map_default = {
const.MODE_INSTANCE_MODE_AUTO: [
climate.const.FAN_AUTO,
climate.const.FAN_ON,
const.SMARTTHINQ_FAN_PRESET_NATURE,
],
const.MODE_INSTANCE_MODE_QUIET: [
climate.const.FAN_OFF,
climate.const.FAN_DIFFUSE,
],
const.MODE_INSTANCE_MODE_MIN: [
const.TION_FAN_SPEED_1,
],
const.MODE_INSTANCE_MODE_LOW: [
climate.const.FAN_LOW,
const.FAN_SPEED_MIN,
const.TION_FAN_SPEED_2,
],
const.MODE_INSTANCE_MODE_MEDIUM: [
climate.const.FAN_MEDIUM,
climate.const.FAN_MIDDLE,
const.FAN_SPEED_MID,
const.TION_FAN_SPEED_3,
],
const.MODE_INSTANCE_MODE_HIGH: [
climate.const.FAN_HIGH,
const.FAN_SPEED_MAX,
const.TION_FAN_SPEED_4,
],
const.MODE_INSTANCE_MODE_TURBO: [
climate.const.FAN_FOCUS,
const.FAN_SPEED_HIGHEST,
const.TION_FAN_SPEED_5,
],
const.MODE_INSTANCE_MODE_MAX: [
const.TION_FAN_SPEED_6,
],
}
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == climate.DOMAIN and features & climate.ClimateEntityFeature.FAN_MODE:
return super().supported()
return False
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return climate.ATTR_FAN_MODE
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return climate.ATTR_FAN_MODES
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
@register_capability
class FanSpeedCapabilityFanViaPreset(FanSpeedCapability):
modes_map_default = {
const.MODE_INSTANCE_MODE_AUTO: [
climate.const.FAN_AUTO,
climate.const.FAN_ON,
],
const.MODE_INSTANCE_MODE_ECO: [
const.XIAOMI_AIRPURIFIER_PRESET_IDLE,
],
const.MODE_INSTANCE_MODE_QUIET: [
const.FAN_SPEED_OFF,
const.XIAOMI_AIRPURIFIER_PRESET_SILENT,
const.XIAOMI_FAN_PRESET_LEVEL_1,
],
const.MODE_INSTANCE_MODE_LOW: [
const.FAN_SPEED_LOW,
const.FAN_SPEED_MIN,
const.XIAOMI_FAN_PRESET_LEVEL_2,
],
const.MODE_INSTANCE_MODE_MEDIUM: [
const.FAN_SPEED_MEDIUM,
const.XIAOMI_HUMIDIFIER_PRESET_MID,
const.XIAOMI_FAN_PRESET_LEVEL_3,
],
const.MODE_INSTANCE_MODE_NORMAL: [
const.XIAOMI_AIRPURIFIER_PRESET_FAVORITE,
],
const.MODE_INSTANCE_MODE_HIGH: [
const.FAN_SPEED_HIGH,
const.XIAOMI_FAN_PRESET_LEVEL_4,
],
const.MODE_INSTANCE_MODE_TURBO: [
const.FAN_SPEED_MAX,
const.XIAOMI_AIRPURIFIER_PRESET_STRONG,
const.XIAOMI_FAN_PRESET_LEVEL_5,
],
}
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == fan.DOMAIN:
if features & fan.FanEntityFeature.PRESET_MODE:
if features & fan.FanEntityFeature.SET_SPEED and fan.ATTR_PERCENTAGE_STEP in self.state.attributes:
return False
return super().supported()
return False
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return fan.ATTR_PRESET_MODE
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return fan.ATTR_PRESET_MODES
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
@register_capability
class FanSpeedCapabilityFanViaPercentage(FanSpeedCapability):
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == fan.DOMAIN:
if features & fan.FanEntityFeature.SET_SPEED and fan.ATTR_PERCENTAGE_STEP in self.state.attributes:
return super().supported()
return False
@property
def supported_ha_modes(self) -> list[str]:
if self.modes_map:
return list(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 = [const.MODE_INSTANCE_MODE_LOW, const.MODE_INSTANCE_MODE_HIGH]
if speed_count >= 3:
modes.insert(modes.index(const.MODE_INSTANCE_MODE_HIGH), const.MODE_INSTANCE_MODE_MEDIUM)
if speed_count >= 4:
modes.insert(modes.index(const.MODE_INSTANCE_MODE_MEDIUM), const.MODE_INSTANCE_MODE_NORMAL)
if speed_count >= 5:
modes.insert(0, const.MODE_INSTANCE_MODE_ECO)
if speed_count >= 6:
modes.insert(modes.index(const.MODE_INSTANCE_MODE_LOW), const.MODE_INSTANCE_MODE_QUIET)
if speed_count >= 7:
modes.append(const.MODE_INSTANCE_MODE_TURBO)
return modes
@property
def supported_yandex_modes(self) -> list[str]:
return self.supported_ha_modes
def get_value(self) -> str | None:
value = self.state.attributes.get(fan.ATTR_PERCENTAGE)
if not value:
return None
if self.modes_map:
for yandex_mode, values in self.modes_map.items():
for str_value in values:
if int(value) == self._convert_mapping_speed_value(str_value):
return yandex_mode
return None
return percentage_to_ordered_list_item(self.supported_ha_modes, value)
@property
def modes_list_attribute(self) -> str | None:
return None
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
yandex_mode = state['value']
if self.modes_map:
ha_modes = self.modes_map.get(yandex_mode)
if not ha_modes:
raise SmartHomeError(
ERR_INVALID_VALUE,
f'Unsupported mode "{yandex_mode}" for {self.instance} instance of {self.state.entity_id}. '
f'Check \"modes\" setting for this entity'
)
ha_mode = self._convert_mapping_speed_value(ha_modes[0])
else:
ha_mode = ordered_list_item_to_percentage(self.supported_ha_modes, yandex_mode)
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=True,
context=data.context
)
def _convert_mapping_speed_value(self, value: str) -> int:
try:
return int(value.replace('%', ''))
except ValueError:
raise SmartHomeError(
ERR_INVALID_VALUE,
f'Unsupported speed value {value!r} for {self.instance} '
f'instance of {self.state.entity_id}.'
)
@register_capability
class CleanupModeCapability(ModeCapability):
"""Vacuum cleanup mode functionality."""
instance = const.MODE_INSTANCE_CLEANUP_MODE
modes_map_default = {
const.MODE_INSTANCE_MODE_AUTO: ['auto', 'automatic', '102'],
const.MODE_INSTANCE_MODE_TURBO: ['turbo', 'high', 'performance', '104', 'full speed', 'max+'],
const.MODE_INSTANCE_MODE_MIN: ['min', 'mop'],
const.MODE_INSTANCE_MODE_LOW: ['gentle'],
const.MODE_INSTANCE_MODE_MAX: ['max', 'strong'],
const.MODE_INSTANCE_MODE_EXPRESS: ['express', '105'],
const.MODE_INSTANCE_MODE_MEDIUM: ['medium', 'middle'],
const.MODE_INSTANCE_MODE_NORMAL: ['normal', 'standard', 'basic', '103'],
const.MODE_INSTANCE_MODE_QUIET: ['quiet', 'low', 'min', 'silent', 'eco', '101'],
}
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == vacuum.DOMAIN and features & vacuum.VacuumEntityFeature.FAN_SPEED:
return super().supported()
return False
@property
def modes_list_attribute(self) -> str | None:
"""Return HA attribute contains modes list for this entity."""
return vacuum.ATTR_FAN_SPEED_LIST
@property
def state_value_attribute(self) -> str | None:
"""Return HA attribute for state of this entity."""
return vacuum.ATTR_FAN_SPEED
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
@@ -0,0 +1,459 @@
"""Implement the Yandex Smart Home on_off capability."""
from __future__ import annotations
from abc import ABC, abstractmethod
import logging
from typing import Any
from homeassistant.components import (
automation,
button,
climate,
cover,
fan,
group,
humidifier,
input_boolean,
input_button,
light,
lock,
media_player,
scene,
script,
switch,
vacuum,
water_heater,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
SERVICE_CLOSE_COVER,
SERVICE_LOCK,
SERVICE_OPEN_COVER,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_UNLOCK,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import DOMAIN as HA_DOMAIN
from homeassistant.exceptions import ServiceNotFound
from homeassistant.helpers.service import async_call_from_config
from . import const
from .capability import PREFIX_CAPABILITIES, AbstractCapability, register_capability
from .const import ERR_NOT_SUPPORTED_IN_CURRENT_MODE
from .error import SmartHomeError
from .helpers import RequestData
_LOGGER = logging.getLogger(__name__)
CAPABILITIES_ONOFF = PREFIX_CAPABILITIES + 'on_off'
class OnOffCapability(AbstractCapability, ABC):
"""On_off to offer basic on and off functionality.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/on_off-docpage/
"""
type = CAPABILITIES_ONOFF
instance = const.ON_OFF_INSTANCE_ON
@property
def retrievable(self) -> bool:
if self.entity_config.get(const.CONF_STATE_UNKNOWN):
return False
return True
def parameters(self) -> dict[str, Any] | None:
"""Return parameters for a devices request."""
if not self.retrievable:
return {'split': True}
return None
def get_value(self) -> bool | None:
return self.state.state != STATE_OFF
async def set_state(self, data: RequestData, state: dict[str, Any]):
for key, call in ((const.CONF_TURN_ON, state['value']), (const.CONF_TURN_OFF, not state['value'])):
if key in self.entity_config and call:
return await async_call_from_config(
self.hass,
self.entity_config[key],
blocking=True,
context=data.context
)
await self._set_state(data, state)
@abstractmethod
async def _set_state(self, data: RequestData, state: dict[str, Any]):
pass
class OnlyOnCapability(OnOffCapability, ABC):
@property
def retrievable(self) -> bool:
return False
def parameters(self) -> dict[str, Any] | None:
return None
@register_capability
class OnOffCapabilityBasic(OnOffCapability):
def supported(self) -> bool:
return self.state.domain in (light.DOMAIN, fan.DOMAIN, switch.DOMAIN, humidifier.DOMAIN, input_boolean.DOMAIN)
async def _set_state(self, data: RequestData, state: dict[str, Any]):
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=True,
context=data.context
)
@register_capability
class OnOffCapabilityAutomation(OnOffCapability):
def get_value(self) -> bool:
return self.state.state == STATE_ON
def supported(self) -> bool:
return self.state.domain == automation.DOMAIN
async def _set_state(self, data: RequestData, state: dict[str, Any]):
if state['value']:
service = SERVICE_TURN_ON
else:
service = SERVICE_TURN_OFF
await self.hass.services.async_call(
automation.DOMAIN,
service, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
@register_capability
class OnOffCapabilityGroup(OnOffCapability):
def supported(self) -> bool:
return self.state.domain in group.DOMAIN
async def _set_state(self, data: RequestData, state: dict[str, Any]):
if state['value']:
service = SERVICE_TURN_ON
else:
service = SERVICE_TURN_OFF
await self.hass.services.async_call(
HA_DOMAIN,
service, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
@register_capability
class OnOffCapabilityScript(OnlyOnCapability):
def get_value(self) -> bool | None:
return None
def supported(self) -> bool:
return self.state.domain in (scene.DOMAIN, script.DOMAIN)
async def _set_state(self, data: RequestData, state: dict[str, Any]):
await self.hass.services.async_call(
self.state.domain,
SERVICE_TURN_ON, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=self.state.domain != script.DOMAIN,
context=data.context
)
@register_capability
class OnOffCapabilityButton(OnlyOnCapability):
def get_value(self) -> bool | None:
return None
def supported(self) -> bool:
return self.state.domain == button.DOMAIN
async def _set_state(self, data: RequestData, state: dict[str, Any]):
await self.hass.services.async_call(
self.state.domain,
button.SERVICE_PRESS, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
@register_capability
class OnOffCapabilityInputButton(OnlyOnCapability):
def get_value(self) -> bool | None:
return None
def supported(self) -> bool:
return self.state.domain == input_button.DOMAIN
async def _set_state(self, data: RequestData, state: dict[str, Any]):
await self.hass.services.async_call(
self.state.domain,
input_button.SERVICE_PRESS, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
@register_capability
class OnOffCapabilityLock(OnOffCapability):
def get_value(self) -> bool:
return self.state.state == lock.STATE_UNLOCKED
def supported(self) -> bool:
return self.state.domain == lock.DOMAIN
async def _set_state(self, data: RequestData, state: dict[str, Any]):
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=True,
context=data.context
)
@register_capability
class OnOffCapabilityCover(OnOffCapability):
def get_value(self) -> bool:
return self.state.state == cover.STATE_OPEN
def supported(self) -> bool:
return self.state.domain == cover.DOMAIN
async def _set_state(self, data: RequestData, state: dict[str, Any]):
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=True,
context=data.context
)
@register_capability
class OnOffCapabilityMediaPlayer(OnOffCapability):
def supported(self) -> bool:
if self.state.domain == media_player.DOMAIN:
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if const.CONF_TURN_ON in self.entity_config or const.CONF_TURN_OFF in self.entity_config:
return True
if const.MEDIA_PLAYER_FEATURE_TURN_ON_OFF in self.entity_config.get(const.CONF_FEATURES, []):
return True
return features & media_player.MediaPlayerEntityFeature.TURN_ON or \
features & media_player.MediaPlayerEntityFeature.TURN_OFF
return False
async def _set_state(self, data: RequestData, state: dict[str, Any]):
if state['value']:
service = SERVICE_TURN_ON
else:
service = SERVICE_TURN_OFF
await self.hass.services.async_call(
media_player.DOMAIN,
service, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
@register_capability
class OnOffCapabilityVacuum(OnOffCapability):
def get_value(self) -> bool | None:
return self.state.state in [STATE_ON, vacuum.STATE_CLEANING]
def supported(self) -> bool:
if self.state.domain != vacuum.DOMAIN:
return False
if const.CONF_TURN_ON in self.entity_config:
return True
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if features & vacuum.VacuumEntityFeature.TURN_ON and features & vacuum.VacuumEntityFeature.TURN_OFF:
return True
if features & vacuum.VacuumEntityFeature.START:
if features & vacuum.VacuumEntityFeature.RETURN_HOME or features & vacuum.VacuumEntityFeature.STOP:
return True
return False
async def _set_state(self, data: RequestData, state: dict[str, Any]):
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES)
if state['value']:
if features & vacuum.VacuumEntityFeature.START:
service = vacuum.SERVICE_START
else:
service = SERVICE_TURN_ON
else:
if features & vacuum.VacuumEntityFeature.RETURN_HOME:
service = vacuum.SERVICE_RETURN_TO_BASE
elif features & vacuum.VacuumEntityFeature.STOP:
service = vacuum.SERVICE_STOP
else:
service = SERVICE_TURN_OFF
await self.hass.services.async_call(
vacuum.DOMAIN,
service, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
@register_capability
class OnOffCapabilityClimate(OnOffCapability):
def get_value(self) -> bool | None:
return self.state.state != climate.HVAC_MODE_OFF
def supported(self) -> bool:
return self.state.domain == climate.DOMAIN
async def _set_state(self, data: RequestData, state: dict[str, Any]):
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 (climate.HVACMode.HEAT_COOL,
climate.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=True,
context=data.context
)
@register_capability
class OnOffCapabilityWaterHeater(OnOffCapability):
water_heater_operations = {
STATE_ON: [STATE_ON, 'On', 'ON', water_heater.STATE_ELECTRIC],
STATE_OFF: [STATE_OFF, 'Off', 'OFF'],
}
def get_value(self) -> bool | None:
return self.state.state.lower() != water_heater.STATE_OFF
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
def supported(self) -> bool:
return self.state.domain == water_heater.DOMAIN
async def _set_state(self, data: RequestData, state: dict[str, Any]):
if state['value']:
service = water_heater.SERVICE_TURN_ON
else:
service = water_heater.SERVICE_TURN_OFF
try:
await self.hass.services.async_call(
water_heater.DOMAIN,
service, {
ATTR_ENTITY_ID: self.state.entity_id,
},
blocking=True,
context=data.context
)
return
except (AttributeError, ServiceNotFound):
# turn_on/turn_off is not supported
pass
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 SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Unable to determine operation mode for {target_state_text} state'
)
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=True,
context=data.context
)
@@ -0,0 +1,555 @@
"""Implement the Yandex Smart Home ranges capabilities."""
from __future__ import annotations
from abc import ABC, abstractmethod
import logging
from typing import Any
from homeassistant.components import climate, cover, fan, humidifier, light, media_player, water_heater
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_MODEL,
ATTR_SUPPORTED_FEATURES,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from . import const
from .capability import PREFIX_CAPABILITIES, AbstractCapability, register_capability
from .const import (
ATTR_TARGET_HUMIDITY,
CONF_ENTITY_RANGE,
CONF_ENTITY_RANGE_MAX,
CONF_ENTITY_RANGE_MIN,
CONF_ENTITY_RANGE_PRECISION,
DOMAIN_XIAOMI_AIRPURIFIER,
ERR_DEVICE_OFF,
ERR_INVALID_VALUE,
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
MODEL_PREFIX_XIAOMI_AIRPURIFIER,
SERVICE_FAN_SET_TARGET_HUMIDITY,
STATE_NONE,
)
from .error import SmartHomeError
from .helpers import Config, RequestData
_LOGGER = logging.getLogger(__name__)
CAPABILITIES_RANGE = PREFIX_CAPABILITIES + 'range'
class RangeCapability(AbstractCapability, ABC):
"""Base class of capabilities with range functionality like volume or
brightness.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-docpage/
"""
type = CAPABILITIES_RANGE
default_range = (0, 100, 1)
def __init__(self, hass: HomeAssistant, config: Config, state: State):
super().__init__(hass, config, state)
self.retrievable = self.support_random_access
@property
@abstractmethod
def support_random_access(self) -> bool:
"""Test if capability supports random access."""
pass
@property
def range(self) -> (float, float, float):
"""Return support range (min, max, precision)."""
return (
self.entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MIN, self.default_range[0]),
self.entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MAX, self.default_range[1]),
self.entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_PRECISION, self.default_range[2])
)
def parameters(self) -> dict[str, Any]:
"""Return parameters for a devices request."""
if self.support_random_access:
range_min, range_max, range_precision = self.range
rv = {
'instance': self.instance,
'random_access': True,
'range': {
'min': range_min,
'max': range_max,
'precision': range_precision
}
}
if self.instance in const.RANGE_INSTANCE_TO_UNITS:
rv['unit'] = const.RANGE_INSTANCE_TO_UNITS[self.instance]
return rv
return {
'instance': self.instance,
'random_access': False,
}
def float_value(self, value: Any, strict: bool = True) -> float | None:
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE):
return None
try:
return float(value)
except (ValueError, TypeError):
if strict:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Unsupported value {value!r} for instance {self.instance} of {self.state.entity_id}'
)
def get_absolute_value(self, relative_value: float) -> float:
"""Return absolute value for relative value."""
value = self.get_value()
if value is None:
if self.state.state == STATE_OFF:
raise SmartHomeError(
ERR_DEVICE_OFF,
f'Device {self.state.entity_id} probably turned off'
)
raise SmartHomeError(
ERR_INVALID_VALUE,
f'Unable to get current value or {self.instance} instance of {self.state.entity_id}'
)
return max(min(value + relative_value, self.range[1]), self.range[0])
@register_capability
class CoverLevelCapability(RangeCapability):
"""Set cover level"""
instance = const.RANGE_INSTANCE_OPEN
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return self.state.domain == cover.DOMAIN and features & cover.CoverEntityFeature.SET_POSITION
@property
def support_random_access(self) -> bool:
"""Test if capability supports random access."""
return True
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(cover.ATTR_CURRENT_POSITION))
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
await self.hass.services.async_call(
cover.DOMAIN,
cover.SERVICE_SET_COVER_POSITION, {
ATTR_ENTITY_ID: self.state.entity_id,
cover.ATTR_POSITION: value
},
blocking=True,
context=data.context
)
class TemperatureCapability(RangeCapability, ABC):
"""Set temperature functionality."""
instance = const.RANGE_INSTANCE_TEMPERATURE
default_range = (0, 100, 0.5)
@property
def support_random_access(self) -> bool:
"""Test if capability supports random access."""
return True
@register_capability
class TemperatureCapabilityWaterHeater(TemperatureCapability):
def __init__(self, hass: HomeAssistant, config: Config, state: State):
super().__init__(hass, config, state)
self.default_range = (
self.state.attributes.get(water_heater.ATTR_MIN_TEMP),
self.state.attributes.get(water_heater.ATTR_MAX_TEMP),
0.5
)
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return self.state.domain == water_heater.DOMAIN and \
features & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(water_heater.ATTR_TEMPERATURE))
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
await self.hass.services.async_call(
water_heater.DOMAIN,
water_heater.SERVICE_SET_TEMPERATURE, {
ATTR_ENTITY_ID: self.state.entity_id,
water_heater.ATTR_TEMPERATURE: value
},
blocking=True,
context=data.context
)
@register_capability
class TemperatureCapabilityClimate(TemperatureCapability):
def __init__(self, hass: HomeAssistant, config: Config, state: State):
super().__init__(hass, config, state)
self.default_range = (
self.state.attributes.get(climate.ATTR_MIN_TEMP),
self.state.attributes.get(climate.ATTR_MAX_TEMP),
self.state.attributes.get(climate.ATTR_TARGET_TEMP_STEP, 0.5),
)
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return self.state.domain == climate.DOMAIN and features & climate.ClimateEntityFeature.TARGET_TEMPERATURE
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(climate.ATTR_TEMPERATURE))
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
await self.hass.services.async_call(
climate.DOMAIN,
climate.SERVICE_SET_TEMPERATURE, {
ATTR_ENTITY_ID: self.state.entity_id,
climate.ATTR_TEMPERATURE: value
},
blocking=True,
context=data.context
)
class HumidityCapability(RangeCapability, ABC):
"""Set humidity functionality."""
instance = const.RANGE_INSTANCE_HUMIDITY
@property
def support_random_access(self) -> bool:
"""Test if capability supports random access."""
return True
@register_capability
class HumidityCapabilityHumidifier(HumidityCapability):
instance = const.RANGE_INSTANCE_HUMIDITY
def __init__(self, hass: HomeAssistant, config: Config, state: State):
"""Initialize a trait for a state."""
super().__init__(hass, config, state)
self.default_range = (
self.state.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 0),
self.state.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 100),
1
)
def supported(self) -> bool:
"""Test if capability is supported."""
return self.state.domain == humidifier.DOMAIN
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(humidifier.ATTR_HUMIDITY))
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
await self.hass.services.async_call(
humidifier.DOMAIN,
humidifier.SERVICE_SET_HUMIDITY, {
ATTR_ENTITY_ID: self.state.entity_id,
humidifier.ATTR_HUMIDITY: value
},
blocking=True,
context=data.context
)
@register_capability
class HumidityCapabilityHumidiferXiaomi(HumidityCapability):
"""Set humidity functionality."""
def supported(self) -> bool:
"""Test if 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
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(ATTR_TARGET_HUMIDITY))
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
await self.hass.services.async_call(
DOMAIN_XIAOMI_AIRPURIFIER,
SERVICE_FAN_SET_TARGET_HUMIDITY, {
ATTR_ENTITY_ID: self.state.entity_id,
humidifier.ATTR_HUMIDITY: value
},
blocking=True,
context=data.context
)
@register_capability
class BrightnessCapability(RangeCapability):
"""Set brightness functionality."""
instance = const.RANGE_INSTANCE_BRIGHTNESS
default_range = (1, 100, 1)
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == light.DOMAIN:
if features & light.SUPPORT_BRIGHTNESS:
return True
if light.brightness_supported(self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES)):
return True
return False
@property
def support_random_access(self) -> bool:
"""Test if capability supports random access."""
return True
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS)
if brightness is not None:
return int(100 * (self.float_value(brightness) / 255))
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
if state.get('relative'):
attribute = light.ATTR_BRIGHTNESS_STEP_PCT
else:
attribute = light.ATTR_BRIGHTNESS_PCT
await self.hass.services.async_call(
light.DOMAIN,
light.SERVICE_TURN_ON, {
ATTR_ENTITY_ID: self.state.entity_id,
attribute: state['value']
},
blocking=True,
context=data.context
)
@register_capability
class VolumeCapability(RangeCapability):
"""Set volume functionality."""
instance = const.RANGE_INSTANCE_VOLUME
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == media_player.DOMAIN:
if features & media_player.MediaPlayerEntityFeature.VOLUME_STEP:
return True
if features & media_player.MediaPlayerEntityFeature.VOLUME_SET:
return True
if const.MEDIA_PLAYER_FEATURE_VOLUME_SET in self.entity_config.get(const.CONF_FEATURES, []):
return True
return False
@property
def support_random_access(self) -> bool:
"""Test if capability supports random access."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if const.MEDIA_PLAYER_FEATURE_VOLUME_SET in self.entity_config.get(const.CONF_FEATURES, []):
return True
return not (features & media_player.MediaPlayerEntityFeature.VOLUME_STEP and
not features & media_player.MediaPlayerEntityFeature.VOLUME_SET)
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
level = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL)
if level is not None:
return int(self.float_value(level) * 100)
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
if not self.support_random_access:
if not state.get('relative'):
raise SmartHomeError(ERR_INVALID_VALUE, f'Failed to set absolute volume for {self.state.entity_id}')
if state['value'] > 0:
service = media_player.SERVICE_VOLUME_UP
else:
service = media_player.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 = 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=True, context=data.context
)
return
value = (state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])) / 100
await self.hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_VOLUME_SET, {
ATTR_ENTITY_ID: self.state.entity_id,
media_player.ATTR_MEDIA_VOLUME_LEVEL: value
},
blocking=True,
context=data.context
)
@register_capability
class ChannelCapability(RangeCapability):
"""Set channel functionality."""
instance = const.RANGE_INSTANCE_CHANNEL
default_range = (0, 999, 1)
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == media_player.DOMAIN:
if features & media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK and \
features & media_player.MediaPlayerEntityFeature.NEXT_TRACK:
return True
if const.MEDIA_PLAYER_FEATURE_NEXT_PREVIOUS_TRACK in self.entity_config.get(const.CONF_FEATURES, []):
return True
if features & media_player.MediaPlayerEntityFeature.PLAY_MEDIA or \
const.MEDIA_PLAYER_FEATURE_PLAY_MEDIA in self.entity_config.get(const.CONF_FEATURES, []):
if self.entity_config.get(const.CONF_SUPPORT_SET_CHANNEL) is False:
return False
return True
return False
@property
def support_random_access(self) -> bool:
"""Test if capability supports random access."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
if self.entity_config.get(const.CONF_SUPPORT_SET_CHANNEL) is False:
return False
if device_class == media_player.DEVICE_CLASS_TV:
if features & media_player.MediaPlayerEntityFeature.PLAY_MEDIA or \
const.MEDIA_PLAYER_FEATURE_PLAY_MEDIA in self.entity_config.get(const.CONF_FEATURES, []):
return True
return False
def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
media_content_type = self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_TYPE)
if media_content_type == media_player.const.MEDIA_TYPE_CHANNEL:
return self.float_value(self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_ID), strict=False)
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value']
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if state.get('relative'):
if features & media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK and \
features & media_player.MediaPlayerEntityFeature.NEXT_TRACK:
if state['value'] >= 0:
service = media_player.SERVICE_MEDIA_NEXT_TRACK
else:
service = media_player.SERVICE_MEDIA_PREVIOUS_TRACK
await self.hass.services.async_call(
media_player.DOMAIN,
service, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
return
if self.get_value() is None:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Failed to set relative value for {self.instance} instance of {self.state.entity_id}.'
)
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: media_player.const.MEDIA_TYPE_CHANNEL
},
blocking=False, # some tv's do it too slow
context=data.context
)
except ValueError as e:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Failed to set channel for {self.state.entity_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}'
)
@@ -0,0 +1,205 @@
"""Implement the Yandex Smart Home toggles capabilities."""
from __future__ import annotations
from abc import ABC
import logging
from typing import Any
from homeassistant.components import cover, fan, media_player, vacuum
from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES
from homeassistant.core import HomeAssistant, State
from . import const
from .capability import PREFIX_CAPABILITIES, AbstractCapability, register_capability
from .helpers import Config, RequestData
_LOGGER = logging.getLogger(__name__)
CAPABILITIES_TOGGLE = PREFIX_CAPABILITIES + 'toggle'
class ToggleCapability(AbstractCapability, ABC):
"""Base toggle functionality.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-docpage/
"""
type = CAPABILITIES_TOGGLE
def parameters(self) -> dict[str, Any]:
"""Return parameters for a devices request."""
return {
'instance': self.instance
}
@register_capability
class MuteCapability(ToggleCapability):
"""Mute and unmute functionality."""
instance = const.TOGGLE_INSTANCE_MUTE
def __init__(self, hass: HomeAssistant, config: Config, state: State):
super().__init__(hass, config, state)
self.retrievable = media_player.ATTR_MEDIA_VOLUME_MUTED in self.state.attributes
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == media_player.DOMAIN:
if features & media_player.MediaPlayerEntityFeature.VOLUME_MUTE:
return True
if const.MEDIA_PLAYER_FEATURE_VOLUME_MUTE in self.entity_config.get(const.CONF_FEATURES, []):
return True
return False
def get_value(self) -> bool:
"""Return the state value of this capability for this entity."""
muted = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED)
return bool(muted)
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
await self.hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_VOLUME_MUTE, {
ATTR_ENTITY_ID: self.state.entity_id,
media_player.ATTR_MEDIA_VOLUME_MUTED: state['value']
},
blocking=True,
context=data.context
)
class PauseCapability(ToggleCapability, ABC):
"""Pause and unpause functionality."""
instance = const.TOGGLE_INSTANCE_PAUSE
@register_capability
class PauseCapabilityMediaPlayer(PauseCapability):
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.state.domain == media_player.DOMAIN:
if const.MEDIA_PLAYER_FEATURE_PLAY_PAUSE in self.entity_config.get(const.CONF_FEATURES, []):
return True
if features & media_player.MediaPlayerEntityFeature.PAUSE and \
features & media_player.MediaPlayerEntityFeature.PLAY:
return True
return False
def get_value(self) -> bool:
"""Return the state value of this capability for this entity."""
return bool(self.state.state != media_player.STATE_PLAYING)
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
if state['value']:
service = media_player.SERVICE_MEDIA_PAUSE
else:
service = media_player.SERVICE_MEDIA_PLAY
await self.hass.services.async_call(
media_player.DOMAIN,
service, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
@register_capability
class PauseCapabilityCover(PauseCapability):
retrievable = False
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return self.state.domain == cover.DOMAIN and features & cover.CoverEntityFeature.STOP
def get_value(self) -> bool:
"""Return the state value of this capability for this entity."""
return False
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
await self.hass.services.async_call(
cover.DOMAIN,
cover.SERVICE_STOP_COVER, {
ATTR_ENTITY_ID: self.state.entity_id
},
blocking=True,
context=data.context
)
@register_capability
class PauseCapabilityVacuum(PauseCapability):
instance = const.TOGGLE_INSTANCE_PAUSE
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return self.state.domain == vacuum.DOMAIN and features & vacuum.VacuumEntityFeature.PAUSE
def get_value(self) -> bool:
"""Return the state value of this capability for this entity."""
return self.state.state == vacuum.STATE_PAUSED
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
@register_capability
class OscillationCapability(ToggleCapability):
"""Oscillation functionality."""
instance = const.TOGGLE_INSTANCE_OSCILLATION
def supported(self) -> bool:
"""Test if capability is supported."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return self.state.domain == fan.DOMAIN and features & fan.FanEntityFeature.OSCILLATE
def get_value(self) -> bool:
"""Return the state value of this capability for this entity."""
return bool(self.state.attributes.get(fan.ATTR_OSCILLATING))
async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device 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=True,
context=data.context
)
@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components import camera
from homeassistant.components.camera import StreamType, _get_camera_from_entity_id
from homeassistant.components.stream import Stream
from homeassistant.const import ATTR_SUPPORTED_FEATURES
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import network
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .capability import PREFIX_CAPABILITIES, AbstractCapability, register_capability
from .cloud_stream import CloudStream
from .const import CLOUD_STREAMS, DOMAIN, ERR_NOT_SUPPORTED_IN_CURRENT_MODE, VIDEO_STREAM_INSTANCE_GET_STREAM
from .error import SmartHomeError
from .helpers import Config, RequestData
_LOGGER = logging.getLogger(__name__)
CAPABILITIES_VIDEO_STREAM = PREFIX_CAPABILITIES + 'video_stream'
@register_capability
class VideoStreamCapability(AbstractCapability):
type = CAPABILITIES_VIDEO_STREAM
instance = VIDEO_STREAM_INSTANCE_GET_STREAM
retrievable = False
def __init__(self, hass: HomeAssistant, config: Config, state: State):
super().__init__(hass, config, state)
self._config = config
self.reportable = False
def parameters(self) -> dict[str, Any]:
return {
'protocol': StreamType.HLS
}
def supported(self) -> bool:
if self.state.domain != camera.DOMAIN:
return False
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return features & camera.CameraEntityFeature.STREAM
def get_value(self) -> float | str | bool | None:
return None
async def set_state(self, data: RequestData, state: dict[str, Any]) -> dict[str, Any] | None:
entity_id = self.state.entity_id
stream = await self._async_request_stream(entity_id)
if self._config.use_cloud_stream:
cloud_stream = self.hass.data[DOMAIN][CLOUD_STREAMS].get(entity_id)
if not cloud_stream:
cloud_stream = CloudStream(self.hass, stream, async_get_clientsession(self.hass))
self.hass.data[DOMAIN][CLOUD_STREAMS][entity_id] = cloud_stream
await cloud_stream.start()
stream_url = cloud_stream.stream_url
else:
try:
external_url = network.get_url(self.hass, allow_internal=False)
except network.NoURLAvailableError:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
'Unable to get 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 {
'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 SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'{entity_id} does not support play stream service'
)
stream.add_provider(StreamType.HLS)
stream.start()
stream.endpoint_url(StreamType.HLS)
return stream
@@ -0,0 +1,167 @@
"""Implement the Yandex Smart Home cloud connection manager."""
from __future__ import annotations
from asyncio import TimeoutError
from dataclasses import dataclass
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
from typing import Any
from aiohttp import (
ClientConnectorError,
ClientResponseError,
ClientSession,
ClientWebSocketResponse,
WSMessage,
WSMsgType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HassJob, HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.json import JSONEncoder
from homeassistant.util import dt
from . import const
from .const import CLOUD_BASE_URL, CONFIG, DOMAIN
from .helpers import Config, RequestData
from .smart_home import async_handle_message
_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'
@dataclass
class CloudInstanceData:
id: str
password: str
connection_token: str
@dataclass
class CloudRequest:
request_id: str
action: str
message: dict[str, Any]
@classmethod
def from_dict(cls, data: dict[str, Any]):
if 'message' in data:
if isinstance(data['message'], str):
data['message'] = json.loads(data['message'])
else:
data['message'] = {}
return cls(**data)
class CloudManager:
def __init__(self, hass: HomeAssistant, config: Config, session: ClientSession):
self._hass = hass
self._instance_id = config.cloud_instance_id
self._token = config.cloud_connection_token
self._user_id = config.user_id
self._session = session
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._url = f'{BASE_API_URL}/connect'
async def connect(self, *_):
if not self._ws_active:
return
# noinspection PyBroadException
try:
_LOGGER.debug(f'Connecting to {self._url}')
self._ws = await self._session.ws_connect(self._url, heartbeat=45, compress=15, headers={
'Authorization': f'Bearer {self._token}'
})
_LOGGER.debug('Connection to Yandex Smart Home cloud established')
self._ws_reconnect_delay = DEFAULT_RECONNECTION_DELAY
self._last_connection_at = dt.utcnow()
async for msg in self._ws: # type: WSMessage
if msg.type == WSMsgType.TEXT:
await self._on_message(msg.json())
_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()
async def disconnect(self, *_):
self._ws_active = False
if self._ws:
await self._ws.close()
async def _on_message(self, payload: dict[Any, Any]):
request = CloudRequest.from_dict(payload)
_LOGGER.debug('Request: %s (message: %s)' % (request.action, request.message))
data = RequestData(
config=self._hass.data[DOMAIN][CONFIG],
request_user_id=self._instance_id,
request_id=request.request_id,
user_id=self._user_id
)
result = await async_handle_message(self._hass, data, request.action, request.message)
response = json.dumps(result, cls=JSONEncoder)
_LOGGER.debug(f'Response: {response}')
await self._ws.send_str(response)
def _try_reconnect(self):
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
_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')
async_call_later(self._hass, self._ws_reconnect_delay, HassJob(self.connect))
async def register_cloud_instance(hass: HomeAssistant) -> CloudInstanceData:
session = async_create_clientsession(hass)
response = await session.post(f'{BASE_API_URL}/instance/register')
response.raise_for_status()
return CloudInstanceData(**await response.json())
async def delete_cloud_instance(hass: HomeAssistant, entry: ConfigEntry):
session = async_create_clientsession(hass)
instance_id = entry.data[const.CONF_CLOUD_INSTANCE][const.CONF_CLOUD_INSTANCE_ID]
token = entry.data[const.CONF_CLOUD_INSTANCE][const.CONF_CLOUD_INSTANCE_CONNECTION_TOKEN]
response = await session.delete(f'{BASE_API_URL}/instance/{instance_id}', headers={
'Authorization': f'Bearer {token}'
})
if response.status != HTTPStatus.OK:
_LOGGER.error(f'Failed to delete cloud instance, status code: {response.status}')
@@ -0,0 +1,162 @@
from __future__ import annotations
import asyncio
from asyncio import TimeoutError
from dataclasses import asdict, dataclass
from datetime import timedelta
import json
import logging
from typing import Any, cast
from aiohttp import (
ClientConnectionError,
ClientResponseError,
ClientSession,
ClientWebSocketResponse,
WSMessage,
WSMsgType,
)
from aiohttp.web_request import Request as AIOWebRequest
from homeassistant.components.stream import Stream
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 multidict import MultiDictProxy
import yarl
from .const import CLOUD_STREAM_BASE_URL
_LOGGER = logging.getLogger(__name__)
RECONNECTION_DELAY = 2
WAIT_FOR_CONNECTION_TIMEOUT = 10
@dataclass
class Request:
view: str
sequence: str = None
part_num: str = None
url_query: str = None
@dataclass
class ResponseMeta:
status_code: int
headers: dict[str, Any] | None = None
class WebRequest:
def __init__(self, hass: HomeAssistant, url: yarl.URL):
self.app = {'hass': hass}
self._url = url
@property
def query(self) -> MultiDictProxy[str]:
return MultiDictProxy(self._url.query)
class CloudStream:
def __init__(self, hass: HomeAssistant, stream: Stream, session: ClientSession):
self._hass = hass
self._stream = stream
self._running_stream_id: str | None = None
self._session = session
self._unsub_reconnect: CALLBACK_TYPE | None = None
self._connected = asyncio.Event()
self._ws: ClientWebSocketResponse | None = None
@property
def stream_url(self) -> str | None:
if not self._running_stream_id:
return None
return f'{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/master_playlist.m3u8'
async def start(self):
if self._ws or not self._stream.access_token:
return
self._running_stream_id = self._stream.access_token
self._hass.loop.create_task(self._connect())
await asyncio.wait_for(self._connected.wait(), timeout=WAIT_FOR_CONNECTION_TIMEOUT)
await self._keepalive()
async def _keepalive(self, *_):
if self._stream.access_token != self._running_stream_id:
return await self._disconnect()
async_call_later(self._hass, timedelta(seconds=1), HassJob(self._keepalive))
async def _connect(self, *_):
if not self._running_stream_id:
return
ws_url = f'{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/connect'
# noinspection PyBroadException
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 self._ws: # type: WSMessage
if msg.type == WSMsgType.TEXT:
await self._handle_request(msg.json())
_LOGGER.debug(f'Disconnected: {self._ws.close_code}')
if self._ws.close_code is not None:
self._try_reconnect()
except (ClientConnectionError, ClientResponseError, TimeoutError):
_LOGGER.exception('Failed to connect to Yandex Smart Home cloud')
self._try_reconnect()
except Exception:
_LOGGER.exception('Unexpected exception')
self._try_reconnect()
async def _disconnect(self, *_):
self._running_stream_id = None
self._connected.clear()
if self._unsub_reconnect:
self._unsub_reconnect()
self._unsub_reconnect = None
if self._ws:
await self._ws.close()
self._ws = None
async def _handle_request(self, payload: dict):
_LOGGER.debug(f'Request: {payload}')
request = Request(**payload)
request_url = yarl.URL.build(path=f'{request.view}', query=request.url_query)
web_request = cast(AIOWebRequest, WebRequest(self._hass, request_url))
views = {
'master_playlist': HlsMasterPlaylistView,
'playlist': HlsPlaylistView,
'init': HlsInitView,
'part': HlsPartView,
'segment': HlsSegmentView
}
view = views[request.view]()
r = await view.get(web_request, self._stream.access_token, request.sequence, request.part_num)
meta = ResponseMeta(status_code=r.status, headers=dict(r.headers))
response = bytes(json.dumps(asdict(meta)), 'utf-8') + b'\r\n' + r.body
await self._ws.send_bytes(response, compress=False)
def _try_reconnect(self):
_LOGGER.debug(f'Trying to reconnect in {RECONNECTION_DELAY} seconds')
self._unsub_reconnect = async_call_later(self._hass, RECONNECTION_DELAY, HassJob(self._connect))
@@ -0,0 +1,265 @@
from __future__ import annotations
import logging
from typing import Any
from aiohttp import ClientConnectorError, ClientResponseError
from homeassistant import data_entry_flow
from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import CONF_ENTITIES
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowHandler
from homeassistant.helpers import selector
from homeassistant.helpers.entityfilter import CONF_INCLUDE_ENTITIES
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from . import DOMAIN, FILTER_SCHEMA, YAML_CONFIG, const, get_config_entry_data_from_yaml_config
from .cloud import register_cloud_instance
_LOGGER = logging.getLogger(__name__)
CONNECTION_TYPES = {
const.CONNECTION_TYPE_CLOUD: 'Через облако',
const.CONNECTION_TYPE_DIRECT: 'Напрямую'
}
class BaseFlowHandler(FlowHandler):
def __init__(self):
self._data: dict[str, Any] = {}
self._options: dict[str, Any] = {}
def _populate_data_from_yaml_config(self):
yaml_config = None
if DOMAIN in self.hass.data:
yaml_config = self.hass.data[DOMAIN][YAML_CONFIG]
data, options = get_config_entry_data_from_yaml_config(self._data, self._options, yaml_config)
self._data.update(data)
self._options.update(options)
class ConfigFlowHandler(BaseFlowHandler, ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
super().__init__()
self._yaml_config: ConfigType | None = None
self._data: dict[str, Any] = {
const.CONF_DEVICES_DISCOVERED: False
}
async def async_step_user(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
if self._async_current_entries():
return self.async_abort(reason='single_instance_allowed')
if DOMAIN in self.hass.data:
yaml_config = self.hass.data[DOMAIN][YAML_CONFIG]
if yaml_config and yaml_config.get(const.CONF_FILTER):
return await self.async_step_filter_yaml()
return await self.async_step_include_entities()
async def async_step_filter_yaml(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
if user_input is not None:
return await self.async_step_connection_type()
return self.async_show_form(step_id='filter_yaml')
async def async_step_include_entities(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
errors = {}
if user_input is not None:
if user_input[CONF_ENTITIES]:
self._options[const.CONF_FILTER] = {
CONF_INCLUDE_ENTITIES: user_input[CONF_ENTITIES]
}
return await self.async_step_connection_type()
else:
errors['base'] = 'entities_not_selected'
return self.async_show_form(
step_id='include_entities',
data_schema=vol.Schema({
vol.Required(CONF_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(multiple=True)
)
}),
errors=errors
)
async def async_step_connection_type(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
errors = {}
if user_input is not None:
self._data.update(user_input)
entry_description = user_input[const.CONF_CONNECTION_TYPE]
entry_description_placeholders = {}
if user_input[const.CONF_CONNECTION_TYPE] == const.CONNECTION_TYPE_CLOUD:
try:
instance = await register_cloud_instance(self.hass)
self._data[const.CONF_CLOUD_INSTANCE] = {
const.CONF_CLOUD_INSTANCE_ID: instance.id,
const.CONF_CLOUD_INSTANCE_PASSWORD: instance.password,
const.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')
else:
entry_description_placeholders.update(self._data[const.CONF_CLOUD_INSTANCE])
if not errors:
self._populate_data_from_yaml_config()
return self.async_create_entry(
title=const.CONFIG_ENTRY_TITLE,
description=entry_description,
description_placeholders=entry_description_placeholders,
data=self._data,
options=self._options
)
return self.async_show_form(
step_id='connection_type',
data_schema=vol.Schema({
vol.Required(const.CONF_CONNECTION_TYPE,
default=const.CONNECTION_TYPE_CLOUD): vol.In(CONNECTION_TYPES)
}),
errors=errors
)
@staticmethod
@callback
def async_get_options_flow(entry: ConfigEntry) -> OptionsFlow:
return OptionsFlowHandler(entry)
class OptionsFlowHandler(BaseFlowHandler, OptionsFlow):
def __init__(self, entry: ConfigEntry):
super().__init__()
self._entry = entry
self._options = dict(entry.options)
self._data = dict(entry.data)
async def async_step_init(self, _: ConfigType | None = None) -> data_entry_flow.FlowResult:
options = ['include_entities', 'connection_type']
if self._data[const.CONF_CONNECTION_TYPE] == const.CONNECTION_TYPE_CLOUD:
options += ['cloud_info', 'cloud_settings']
return self.async_show_menu(step_id='menu', menu_options=options)
async def async_step_filter_yaml(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
if user_input is not None:
return await self.async_step_init()
return self.async_show_form(step_id='filter_yaml')
async def async_step_include_entities(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
errors = {}
entities = []
yaml_config = self.hass.data[DOMAIN][YAML_CONFIG]
if yaml_config and yaml_config.get(const.CONF_FILTER):
return await self.async_step_filter_yaml()
if const.CONF_FILTER in self._options:
entities = set(self._options[const.CONF_FILTER].get(CONF_INCLUDE_ENTITIES, []))
# migration from include_exclude filters
entity_filter = FILTER_SCHEMA(self._options[const.CONF_FILTER])
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[const.CONF_FILTER] = {
CONF_INCLUDE_ENTITIES: user_input[CONF_ENTITIES]
}
return await self.async_step_done()
else:
errors['base'] = 'entities_not_selected'
entities = []
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_connection_type(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
errors = {}
if user_input is not None:
self._data.update(user_input)
if user_input[const.CONF_CONNECTION_TYPE] == const.CONNECTION_TYPE_CLOUD and \
const.CONF_CLOUD_INSTANCE not in self._data:
try:
instance = await register_cloud_instance(self.hass)
self._data[const.CONF_CLOUD_INSTANCE] = {
const.CONF_CLOUD_INSTANCE_ID: instance.id,
const.CONF_CLOUD_INSTANCE_PASSWORD: instance.password,
const.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:
self._populate_data_from_yaml_config()
self.hass.config_entries.async_update_entry(self._entry, data=self._data, options=self._options)
return self.async_create_entry(title='', data=self._options)
return self.async_show_form(
step_id='connection_type',
data_schema=vol.Schema({
vol.Required(const.CONF_CONNECTION_TYPE,
default=self._data[const.CONF_CONNECTION_TYPE]): vol.In(CONNECTION_TYPES)
}),
errors=errors
)
async def async_step_cloud_settings(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
if user_input is not None:
self._options.update(user_input)
return await self.async_step_done()
return self.async_show_form(step_id='cloud_settings', data_schema=vol.Schema({
vol.Required(const.CONF_USER_ID, default=self._options.get(const.CONF_USER_ID)): vol.In(
await _async_get_users(self.hass)
)
}))
async def async_step_cloud_info(self, user_input: ConfigType | None = None) -> data_entry_flow.FlowResult:
if user_input is not None:
return await self.async_step_init()
instance = self._data[const.CONF_CLOUD_INSTANCE]
return self.async_show_form(step_id='cloud_info', description_placeholders={
const.CONF_CLOUD_INSTANCE_ID: instance[const.CONF_CLOUD_INSTANCE_ID],
const.CONF_CLOUD_INSTANCE_PASSWORD: instance[const.CONF_CLOUD_INSTANCE_PASSWORD]
})
async def async_step_done(self) -> data_entry_flow.FlowResult:
return self.async_create_entry(title='', data=self._options)
async def _async_get_users(hass: HomeAssistant) -> dict[str, str]:
rv = {}
for user in await hass.auth.async_get_users():
if any(gr.id == GROUP_ID_READ_ONLY for gr in user.groups):
continue
rv[user.id] = user.name
return rv
@@ -0,0 +1,113 @@
from __future__ import annotations
import logging
import voluptuous as vol
from . import const
from .capability_color import ColorConverter
from .prop_float import PRESSURE_UNITS_TO_YANDEX_UNITS
_LOGGER = logging.getLogger(__name__)
def property_type(value: str) -> str:
if value not in const.FLOAT_INSTANCES and value not in const.EVENT_INSTANCES:
raise vol.Invalid(
f'Property type {value!r} is not supported. '
f'See valid types at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/float-instance.html and '
f'https://yandex.ru/dev/dialogs/smart-home/doc/concepts/event-instance.html'
)
if value == const.EVENT_INSTANCE_BUTTON:
_LOGGER.warning('Property type "button" is not supported. See documentation '
'at https://docs.yaha-cloud.ru/v0.6.x/devices/button/')
return value
def mode_instance(value: str) -> str:
if value not in const.MODE_INSTANCES and value not in const.COLOR_SETTING_SCENE:
_LOGGER.error(
f'Mode instance {value!r} is not supported. '
f'See valid modes at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance.html'
)
raise vol.Invalid(f'Mode instance {value!r} is not supported.')
return value
def mode(value: str) -> str:
if value not in const.MODE_INSTANCE_MODES and value not in const.COLOR_SCENES:
_LOGGER.error(
f'Mode {value!r} is not supported. '
f'See valid modes at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance-modes.html and '
f'https://yandex.ru/dev/dialogs/smart-home/doc/concepts/color_setting.html#discovery__discovery-'
f'parameters-color-setting-table__entry__75'
)
raise vol.Invalid(f'Mode {value!r} is not supported.')
return value
def toggle_instance(value: str) -> str:
if value not in const.TOGGLE_INSTANCES:
_LOGGER.error(
f'Toggle instance {value!r} is not supported. '
f'See valid values at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/toggle-instance.html'
)
raise vol.Invalid(f'Toggle instance {value!r} is not supported.')
return value
def range_instance(value: str) -> str:
if value not in const.RANGE_INSTANCES:
_LOGGER.error(
f'Range instance {value!r} is not supported. '
f'See valid values at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/range-instance.html'
)
raise vol.Invalid(f'Range instance {value!r} is not supported.')
return value
def entity_features(value: list[str]):
for feature in value:
if feature not in const.MEDIA_PLAYER_FEATURES:
raise vol.Invalid(f'Feature {feature!r} is not supported')
return value
def device_type(value: str) -> str:
if value not in const.TYPES:
_LOGGER.error(
f'Device type {value!r} 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!r} is not supported.')
return value
def pressure_unit(value):
if value not in PRESSURE_UNITS_TO_YANDEX_UNITS:
raise vol.Invalid(f'Pressure unit {value!r} is not supported')
return value
def color_value(value: list | int) -> int:
if isinstance(value, (int, str)):
return int(value)
if isinstance(value, list) and len(value) == 3:
return ColorConverter.rgb_to_int(*[int(v) for v in value])
raise vol.Invalid(f'Invalid value: {value!r}')
@@ -0,0 +1,684 @@
"""Constants for Yandex Smart Home."""
from homeassistant.components import (
air_quality,
automation,
binary_sensor,
button,
camera,
climate,
cover,
fan,
group,
humidifier,
input_boolean,
input_button,
input_text,
light,
lock,
media_player,
scene,
script,
sensor,
switch,
vacuum,
water_heater,
)
DOMAIN = 'yandex_smart_home'
CONFIG = 'config'
YAML_CONFIG = 'yaml_config'
YAML_CONFIG_HASH = 'yaml_config_hash'
CONFIG_ENTRY_TITLE = 'Yandex Smart Home'
NOTIFIERS = 'notifiers'
CLOUD_MANAGER = 'cloud_manager'
CLOUD_STREAMS = 'cloud_streams'
CONF_SETTINGS = 'settings'
CONF_PRESSURE_UNIT = 'pressure_unit'
CONF_BETA = 'beta'
CONF_CLOUD_STREAM = 'cloud_stream'
CONF_NOTIFIER = 'notifier'
CONF_NOTIFIER_OAUTH_TOKEN = 'oauth_token'
CONF_NOTIFIER_SKILL_ID = 'skill_id'
CONF_NOTIFIER_USER_ID = 'user_id'
CONF_CONNECTION_TYPE = 'connection_type'
CONF_CLOUD_INSTANCE = 'cloud_instance'
CONF_CLOUD_INSTANCE_ID = 'id'
CONF_CLOUD_INSTANCE_PASSWORD = 'password'
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN = 'token'
CONF_USER_ID = 'user_id'
CONF_DEVICES_DISCOVERED = 'devices_discovered'
CONF_COLOR_PROFILE = 'color_profile'
CONF_ENTITY_CONFIG = 'entity_config'
CONF_FILTER = 'filter'
CONF_NAME = 'name'
CONF_ROOM = 'room'
CONF_TYPE = 'type'
CONF_TURN_ON = 'turn_on'
CONF_TURN_OFF = 'turn_off'
CONF_DEVICE_CLASS = 'device_class'
CONF_FEATURES = 'features'
CONF_SUPPORT_SET_CHANNEL = 'support_set_channel'
CONF_STATE_UNKNOWN = 'state_unknown'
CONF_ENTITY_PROPERTY_ENTITY = 'entity'
CONF_ENTITY_PROPERTY_TYPE = 'type'
CONF_ENTITY_PROPERTY_ATTRIBUTE = 'attribute'
CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT = '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_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'
STORE_CACHE_ATTRS = 'attrs'
CONNECTION_TYPE_DIRECT = 'direct'
CONNECTION_TYPE_CLOUD = 'cloud'
CLOUD_BASE_URL = 'https://yaha-cloud.ru'
CLOUD_STREAM_BASE_URL = 'https://stream.yaha-cloud.ru'
EVENT_DEVICE_DISCOVERY = 'yandex_smart_home_device_discovery'
EVENT_CONFIG_CHANGED = 'yandex_smart_home_config_changed'
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/device-types.html
PREFIX_TYPES = 'devices.types.'
TYPE_LIGHT = PREFIX_TYPES + 'light'
TYPE_SOCKET = PREFIX_TYPES + 'socket'
TYPE_SWITCH = PREFIX_TYPES + 'switch'
TYPE_THERMOSTAT = PREFIX_TYPES + 'thermostat'
TYPE_THERMOSTAT_AC = PREFIX_TYPES + 'thermostat.ac'
TYPE_MEDIA_DEVICE = PREFIX_TYPES + 'media_device'
TYPE_MEDIA_DEVICE_TV = PREFIX_TYPES + 'media_device.tv'
TYPE_MEDIA_DEVICE_TV_BOX = PREFIX_TYPES + 'media_device.tv_box'
TYPE_MEDIA_DEVICE_RECIEVER = PREFIX_TYPES + 'media_device.receiver'
TYPE_CAMERA = PREFIX_TYPES + 'camera'
TYPE_COOKING = PREFIX_TYPES + 'cooking'
TYPE_COFFEE_MAKER = PREFIX_TYPES + 'cooking.coffee_maker'
TYPE_KETTLE = PREFIX_TYPES + 'cooking.kettle'
TYPE_MULTICOOKER = PREFIX_TYPES + 'cooking.multicooker'
TYPE_OPENABLE = PREFIX_TYPES + 'openable'
TYPE_OPENABLE_CURTAIN = PREFIX_TYPES + 'openable.curtain'
TYPE_HUMIDIFIER = PREFIX_TYPES + 'humidifier'
TYPE_FAN = PREFIX_TYPES + 'fan'
TYPE_PURIFIER = PREFIX_TYPES + 'purifier'
TYPE_VACUUM_CLEANER = PREFIX_TYPES + 'vacuum_cleaner'
TYPE_WASHING_MACHINE = PREFIX_TYPES + 'washing_machine'
TYPE_DISHWASHER = PREFIX_TYPES + 'dishwasher'
TYPE_IRON = PREFIX_TYPES + 'iron'
TYPE_SENSOR = PREFIX_TYPES + 'sensor'
TYPE_OTHER = PREFIX_TYPES + 'other'
TYPES = (
TYPE_LIGHT,
TYPE_SOCKET,
TYPE_SWITCH,
TYPE_THERMOSTAT,
TYPE_THERMOSTAT_AC,
TYPE_MEDIA_DEVICE,
TYPE_MEDIA_DEVICE_TV,
TYPE_MEDIA_DEVICE_TV_BOX,
TYPE_MEDIA_DEVICE_RECIEVER,
TYPE_CAMERA,
TYPE_COOKING,
TYPE_COFFEE_MAKER,
TYPE_KETTLE,
TYPE_MULTICOOKER,
TYPE_OPENABLE,
TYPE_OPENABLE_CURTAIN,
TYPE_HUMIDIFIER,
TYPE_FAN,
TYPE_PURIFIER,
TYPE_VACUUM_CLEANER,
TYPE_WASHING_MACHINE,
TYPE_DISHWASHER,
TYPE_IRON,
TYPE_SENSOR,
TYPE_OTHER,
)
DOMAIN_TO_YANDEX_TYPES = {
air_quality.DOMAIN: TYPE_SENSOR,
automation.DOMAIN: TYPE_OTHER,
binary_sensor.DOMAIN: TYPE_SENSOR,
button.DOMAIN: TYPE_OTHER,
camera.DOMAIN: TYPE_CAMERA,
climate.DOMAIN: TYPE_THERMOSTAT,
cover.DOMAIN: TYPE_OPENABLE_CURTAIN,
fan.DOMAIN: TYPE_FAN,
group.DOMAIN: TYPE_SWITCH,
humidifier.DOMAIN: TYPE_HUMIDIFIER,
input_boolean.DOMAIN: TYPE_SWITCH,
input_button.DOMAIN: TYPE_OTHER,
input_text.DOMAIN: TYPE_SENSOR,
light.DOMAIN: TYPE_LIGHT,
lock.DOMAIN: TYPE_OPENABLE,
media_player.DOMAIN: TYPE_MEDIA_DEVICE,
scene.DOMAIN: TYPE_OTHER,
script.DOMAIN: TYPE_OTHER,
sensor.DOMAIN: TYPE_SENSOR,
switch.DOMAIN: TYPE_SWITCH,
vacuum.DOMAIN: TYPE_VACUUM_CLEANER,
water_heater.DOMAIN: TYPE_KETTLE,
}
DEVICE_CLASS_TO_YANDEX_TYPES = {
(media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_MEDIA_DEVICE_TV,
(media_player.DOMAIN, media_player.DEVICE_CLASS_RECEIVER): TYPE_MEDIA_DEVICE_RECIEVER,
(switch.DOMAIN, switch.DEVICE_CLASS_OUTLET): TYPE_SOCKET,
}
ON_OFF_INSTANCE_ON = 'on'
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/toggle-instance.html
TOGGLE_INSTANCE_BACKLIGHT = 'backlight'
TOGGLE_INSTANCE_CONTROLS_LOCKED = 'controls_locked'
TOGGLE_INSTANCE_IONIZATION = 'ionization'
TOGGLE_INSTANCE_KEEP_WARM = 'keep_warm'
TOGGLE_INSTANCE_MUTE = 'mute'
TOGGLE_INSTANCE_OSCILLATION = 'oscillation'
TOGGLE_INSTANCE_PAUSE = 'pause'
TOGGLE_INSTANCES = (
TOGGLE_INSTANCE_BACKLIGHT,
TOGGLE_INSTANCE_CONTROLS_LOCKED,
TOGGLE_INSTANCE_IONIZATION,
TOGGLE_INSTANCE_KEEP_WARM,
TOGGLE_INSTANCE_MUTE,
TOGGLE_INSTANCE_OSCILLATION,
TOGGLE_INSTANCE_PAUSE,
)
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/range-instance.html
RANGE_INSTANCE_BRIGHTNESS = 'brightness'
RANGE_INSTANCE_CHANNEL = 'channel'
RANGE_INSTANCE_HUMIDITY = 'humidity'
RANGE_INSTANCE_OPEN = 'open'
RANGE_INSTANCE_TEMPERATURE = 'temperature'
RANGE_INSTANCE_VOLUME = 'volume'
RANGE_INSTANCES = (
RANGE_INSTANCE_BRIGHTNESS,
RANGE_INSTANCE_CHANNEL,
RANGE_INSTANCE_HUMIDITY,
RANGE_INSTANCE_OPEN,
RANGE_INSTANCE_TEMPERATURE,
RANGE_INSTANCE_VOLUME,
)
RANGE_INSTANCE_TO_UNITS = {
RANGE_INSTANCE_BRIGHTNESS: 'unit.percent',
RANGE_INSTANCE_HUMIDITY: 'unit.percent',
RANGE_INSTANCE_OPEN: 'unit.percent',
RANGE_INSTANCE_TEMPERATURE: 'unit.temperature.celsius'
}
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance.html
MODE_INSTANCE_CLEANUP_MODE = 'cleanup_mode'
MODE_INSTANCE_COFFEE_MODE = 'coffee_mode'
MODE_INSTANCE_DISHWASHING = 'dishwashing'
MODE_INSTANCE_FAN_SPEED = 'fan_speed'
MODE_INSTANCE_HEAT = 'heat'
MODE_INSTANCE_INPUT_SOURCE = 'input_source'
MODE_INSTANCE_PROGRAM = 'program'
MODE_INSTANCE_SWING = 'swing'
MODE_INSTANCE_TEA_MODE = 'tea_mode'
MODE_INSTANCE_THERMOSTAT = 'thermostat'
MODE_INSTANCE_WORK_SPEED = 'work_speed'
MODE_INSTANCES = (
MODE_INSTANCE_CLEANUP_MODE,
MODE_INSTANCE_COFFEE_MODE,
MODE_INSTANCE_DISHWASHING,
MODE_INSTANCE_FAN_SPEED,
MODE_INSTANCE_HEAT,
MODE_INSTANCE_INPUT_SOURCE,
MODE_INSTANCE_PROGRAM,
MODE_INSTANCE_SWING,
MODE_INSTANCE_TEA_MODE,
MODE_INSTANCE_THERMOSTAT,
MODE_INSTANCE_WORK_SPEED,
)
VIDEO_STREAM_INSTANCE_GET_STREAM = 'get_stream'
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/color_setting.html#discovery__discovery-parameters-color-setting-table__entry__75
COLOR_SETTING_RGB = 'rgb'
COLOR_SETTING_TEMPERATURE_K = 'temperature_k'
COLOR_SETTING_SCENE = 'scene'
COLOR_SCENE_ALARM = 'alarm'
COLOR_SCENE_ALICE = 'alice'
COLOR_SCENE_CANDLE = 'candle'
COLOR_SCENE_DINNER = 'dinner'
COLOR_SCENE_FANTASY = 'fantasy'
COLOR_SCENE_GARLAND = 'garland'
COLOR_SCENE_JUNGLE = 'jungle'
COLOR_SCENE_MOVIE = 'movie'
COLOR_SCENE_NEON = 'neon'
COLOR_SCENE_NIGHT = 'night'
COLOR_SCENE_OCEAN = 'ocean'
COLOR_SCENE_PARTY = 'party'
COLOR_SCENE_READING = 'reading'
COLOR_SCENE_REST = 'rest'
COLOR_SCENE_ROMANCE = 'romance'
COLOR_SCENE_SIREN = 'siren'
COLOR_SCENE_SUNRISE = 'sunrise'
COLOR_SCENE_SUNSET = 'sunset'
COLOR_SCENES = (
COLOR_SCENE_ALARM,
COLOR_SCENE_ALICE,
COLOR_SCENE_CANDLE,
COLOR_SCENE_DINNER,
COLOR_SCENE_FANTASY,
COLOR_SCENE_GARLAND,
COLOR_SCENE_JUNGLE,
COLOR_SCENE_MOVIE,
COLOR_SCENE_NEON,
COLOR_SCENE_NIGHT,
COLOR_SCENE_OCEAN,
COLOR_SCENE_PARTY,
COLOR_SCENE_READING,
COLOR_SCENE_REST,
COLOR_SCENE_ROMANCE,
COLOR_SCENE_SIREN,
COLOR_SCENE_SUNRISE,
COLOR_SCENE_SUNSET,
)
COLOR_NAME_RED = 'red'
COLOR_NAME_CORAL = 'coral'
COLOR_NAME_ORANGE = 'orange'
COLOR_NAME_YELLOW = 'yellow'
COLOR_NAME_LIME = 'lime'
COLOR_NAME_GREEN = 'green'
COLOR_NAME_EMERALD = 'emerald'
COLOR_NAME_TURQUOISE = 'turquoise'
COLOR_NAME_CYAN = 'cyan'
COLOR_NAME_BLUE = 'blue'
COLOR_NAME_MOONLIGHT = 'moonlight'
COLOR_NAME_LAVENDER = 'lavender'
COLOR_NAME_VIOLET = 'violet'
COLOR_NAME_PURPLE = 'purple'
COLOR_NAME_ORCHID = 'orchid'
COLOR_NAME_MAUVE = 'mauve'
COLOR_NAME_RASPBERRY = 'raspberry'
COLOR_TEMPERATURE_NAME_FIERY_WHITE = 'fiery_white'
COLOR_TEMPERATURE_NAME_SOFT_WHITE = 'soft_white'
COLOR_TEMPERATURE_NAME_WARM_WHITE = 'warm_white'
COLOR_TEMPERATURE_NAME_WHITE = 'white'
COLOR_TEMPERATURE_NAME_DAYLIGHT = 'daylight'
COLOR_TEMPERATURE_NAME_COLD_WHITE = 'cold_white'
COLOR_TEMPERATURE_NAME_MISTY_WHITE = 'misty_white'
COLOR_TEMPERATURE_NAME_HEAVENLY_WHITE = 'heavenly_white'
COLOR_NAMES = (
COLOR_NAME_RED,
COLOR_NAME_CORAL,
COLOR_NAME_ORANGE,
COLOR_NAME_YELLOW,
COLOR_NAME_LIME,
COLOR_NAME_GREEN,
COLOR_NAME_EMERALD,
COLOR_NAME_TURQUOISE,
COLOR_NAME_CYAN,
COLOR_NAME_BLUE,
COLOR_NAME_MOONLIGHT,
COLOR_NAME_LAVENDER,
COLOR_NAME_VIOLET,
COLOR_NAME_PURPLE,
COLOR_NAME_ORCHID,
COLOR_NAME_MAUVE,
COLOR_NAME_RASPBERRY,
COLOR_TEMPERATURE_NAME_FIERY_WHITE,
COLOR_TEMPERATURE_NAME_SOFT_WHITE,
COLOR_TEMPERATURE_NAME_WARM_WHITE,
COLOR_TEMPERATURE_NAME_WHITE,
COLOR_TEMPERATURE_NAME_DAYLIGHT,
COLOR_TEMPERATURE_NAME_COLD_WHITE,
COLOR_TEMPERATURE_NAME_MISTY_WHITE,
COLOR_TEMPERATURE_NAME_HEAVENLY_WHITE
)
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance-modes.html
MODE_INSTANCE_MODE_AUTO = 'auto'
MODE_INSTANCE_MODE_ECO = 'eco'
MODE_INSTANCE_MODE_TURBO = 'turbo'
MODE_INSTANCE_MODE_COOL = 'cool'
MODE_INSTANCE_MODE_DRY = 'dry'
MODE_INSTANCE_MODE_FAN_ONLY = 'fan_only'
MODE_INSTANCE_MODE_HEAT = 'heat'
MODE_INSTANCE_MODE_PREHEAT = 'preheat'
MODE_INSTANCE_MODE_HIGH = 'high'
MODE_INSTANCE_MODE_LOW = 'low'
MODE_INSTANCE_MODE_MEDIUM = 'medium'
MODE_INSTANCE_MODE_MAX = 'max'
MODE_INSTANCE_MODE_MIN = 'min'
MODE_INSTANCE_MODE_FAST = 'fast'
MODE_INSTANCE_MODE_SLOW = 'slow'
MODE_INSTANCE_MODE_EXPRESS = 'express'
MODE_INSTANCE_MODE_NORMAL = 'normal'
MODE_INSTANCE_MODE_QUIET = 'quiet'
MODE_INSTANCE_MODE_HORIZONTAL = 'horizontal'
MODE_INSTANCE_MODE_STATIONARY = 'stationary'
MODE_INSTANCE_MODE_VERTICAL = 'vertical'
MODE_INSTANCE_MODE_ONE = 'one'
MODE_INSTANCE_MODE_TWO = 'two'
MODE_INSTANCE_MODE_THREE = 'three'
MODE_INSTANCE_MODE_FOUR = 'four'
MODE_INSTANCE_MODE_FIVE = 'five'
MODE_INSTANCE_MODE_SIX = 'six'
MODE_INSTANCE_MODE_SEVEN = 'seven'
MODE_INSTANCE_MODE_EIGHT = 'eight'
MODE_INSTANCE_MODE_NINE = 'nine'
MODE_INSTANCE_MODE_TEN = 'ten'
MODE_INSTANCE_MODE_AMERICANO = 'americano'
MODE_INSTANCE_MODE_CAPPUCCINO = 'cappuccino'
MODE_INSTANCE_MODE_DOUBLE = 'double'
MODE_INSTANCE_MODE_ESPRESSO = 'espresso'
MODE_INSTANCE_MODE_DOUBLE_ESPRESSO = 'double_espresso'
MODE_INSTANCE_MODE_LATTE = 'latte'
MODE_INSTANCE_MODE_BLACK_TEA = 'black_tea'
MODE_INSTANCE_MODE_FLOWER_TEA = 'flower_tea'
MODE_INSTANCE_MODE_GREEN_TEA = 'green_tea'
MODE_INSTANCE_MODE_HERBAL_TEA = 'herbal_tea'
MODE_INSTANCE_MODE_OOLONG_TEA = 'oolong_tea'
MODE_INSTANCE_MODE_PUERH_TEA = 'puerh_tea'
MODE_INSTANCE_MODE_RED_TEA = 'red_tea'
MODE_INSTANCE_MODE_WHITE_TEA = 'white_tea'
MODE_INSTANCE_MODE_GLASS = 'glass'
MODE_INSTANCE_MODE_INTENSIVE = 'intensive'
MODE_INSTANCE_MODE_PRE_RINSE = 'pre_rinse'
MODE_INSTANCE_MODE_ASPIC = 'aspic'
MODE_INSTANCE_MODE_BABY_FOOD = 'baby_food'
MODE_INSTANCE_MODE_BAKING = 'baking'
MODE_INSTANCE_MODE_BREAD = 'bread'
MODE_INSTANCE_MODE_BOILING = 'boiling'
MODE_INSTANCE_MODE_CEREALS = 'cereals'
MODE_INSTANCE_MODE_CHEESECAKE = 'cheesecake'
MODE_INSTANCE_MODE_DEEP_FRYER = 'deep_fryer'
MODE_INSTANCE_MODE_DESSERT = 'dessert'
MODE_INSTANCE_MODE_FOWL = 'fowl'
MODE_INSTANCE_MODE_FRYING = 'frying'
MODE_INSTANCE_MODE_MACARONI = 'macaroni'
MODE_INSTANCE_MODE_MILK_PORRIDGE = 'milk_porridge'
MODE_INSTANCE_MODE_MULTICOOKER = 'multicooker'
MODE_INSTANCE_MODE_PASTA = 'pasta'
MODE_INSTANCE_MODE_PILAF = 'pilaf'
MODE_INSTANCE_MODE_PIZZA = 'pizza'
MODE_INSTANCE_MODE_SAUCE = 'sauce'
MODE_INSTANCE_MODE_SLOW_COOK = 'slow_cook'
MODE_INSTANCE_MODE_SOUP = 'soup'
MODE_INSTANCE_MODE_STEAM = 'steam'
MODE_INSTANCE_MODE_STEWING = 'stewing'
MODE_INSTANCE_MODE_VACUUM = 'vacuum'
MODE_INSTANCE_MODE_YOGURT = 'yogurt'
MODE_INSTANCE_MODES = (
MODE_INSTANCE_MODE_AUTO,
MODE_INSTANCE_MODE_ECO,
MODE_INSTANCE_MODE_TURBO,
MODE_INSTANCE_MODE_COOL,
MODE_INSTANCE_MODE_DRY,
MODE_INSTANCE_MODE_FAN_ONLY,
MODE_INSTANCE_MODE_HEAT,
MODE_INSTANCE_MODE_PREHEAT,
MODE_INSTANCE_MODE_HIGH,
MODE_INSTANCE_MODE_LOW,
MODE_INSTANCE_MODE_MEDIUM,
MODE_INSTANCE_MODE_MAX,
MODE_INSTANCE_MODE_MIN,
MODE_INSTANCE_MODE_FAST,
MODE_INSTANCE_MODE_SLOW,
MODE_INSTANCE_MODE_EXPRESS,
MODE_INSTANCE_MODE_NORMAL,
MODE_INSTANCE_MODE_QUIET,
MODE_INSTANCE_MODE_HORIZONTAL,
MODE_INSTANCE_MODE_STATIONARY,
MODE_INSTANCE_MODE_VERTICAL,
MODE_INSTANCE_MODE_ONE,
MODE_INSTANCE_MODE_TWO,
MODE_INSTANCE_MODE_THREE,
MODE_INSTANCE_MODE_FOUR,
MODE_INSTANCE_MODE_FIVE,
MODE_INSTANCE_MODE_SIX,
MODE_INSTANCE_MODE_SEVEN,
MODE_INSTANCE_MODE_EIGHT,
MODE_INSTANCE_MODE_NINE,
MODE_INSTANCE_MODE_TEN,
MODE_INSTANCE_MODE_AMERICANO,
MODE_INSTANCE_MODE_CAPPUCCINO,
MODE_INSTANCE_MODE_DOUBLE,
MODE_INSTANCE_MODE_ESPRESSO,
MODE_INSTANCE_MODE_DOUBLE_ESPRESSO,
MODE_INSTANCE_MODE_LATTE,
MODE_INSTANCE_MODE_BLACK_TEA,
MODE_INSTANCE_MODE_FLOWER_TEA,
MODE_INSTANCE_MODE_GREEN_TEA,
MODE_INSTANCE_MODE_HERBAL_TEA,
MODE_INSTANCE_MODE_OOLONG_TEA,
MODE_INSTANCE_MODE_PUERH_TEA,
MODE_INSTANCE_MODE_RED_TEA,
MODE_INSTANCE_MODE_WHITE_TEA,
MODE_INSTANCE_MODE_GLASS,
MODE_INSTANCE_MODE_INTENSIVE,
MODE_INSTANCE_MODE_PRE_RINSE,
MODE_INSTANCE_MODE_ASPIC,
MODE_INSTANCE_MODE_BABY_FOOD,
MODE_INSTANCE_MODE_BAKING,
MODE_INSTANCE_MODE_BREAD,
MODE_INSTANCE_MODE_BOILING,
MODE_INSTANCE_MODE_CEREALS,
MODE_INSTANCE_MODE_CHEESECAKE,
MODE_INSTANCE_MODE_DEEP_FRYER,
MODE_INSTANCE_MODE_DESSERT,
MODE_INSTANCE_MODE_FOWL,
MODE_INSTANCE_MODE_FRYING,
MODE_INSTANCE_MODE_MACARONI,
MODE_INSTANCE_MODE_MILK_PORRIDGE,
MODE_INSTANCE_MODE_MULTICOOKER,
MODE_INSTANCE_MODE_PASTA,
MODE_INSTANCE_MODE_PILAF,
MODE_INSTANCE_MODE_PIZZA,
MODE_INSTANCE_MODE_SAUCE,
MODE_INSTANCE_MODE_SLOW_COOK,
MODE_INSTANCE_MODE_SOUP,
MODE_INSTANCE_MODE_STEAM,
MODE_INSTANCE_MODE_STEWING,
MODE_INSTANCE_MODE_VACUUM,
MODE_INSTANCE_MODE_YOGURT,
)
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/float-instance.html
FLOAT_INSTANCE_AMPERAGE = 'amperage'
FLOAT_INSTANCE_BATTERY_LEVEL = 'battery_level'
FLOAT_INSTANCE_CO2_LEVEL = 'co2_level'
FLOAT_INSTANCE_HUMIDITY = 'humidity'
FLOAT_INSTANCE_ILLUMINATION = 'illumination'
FLOAT_INSTANCE_PM10_DENSITY = 'pm10_density'
FLOAT_INSTANCE_PM1_DENSITY = 'pm1_density'
FLOAT_INSTANCE_PM2_5_DENSITY = 'pm2.5_density'
FLOAT_INSTANCE_POWER = 'power'
FLOAT_INSTANCE_PRESSURE = 'pressure'
FLOAT_INSTANCE_TEMPERATURE = 'temperature'
FLOAT_INSTANCE_TVOC = 'tvoc'
FLOAT_INSTANCE_VOLTAGE = 'voltage'
FLOAT_INSTANCE_WATER_LEVEL = 'water_level'
FLOAT_INSTANCES = (
FLOAT_INSTANCE_AMPERAGE,
FLOAT_INSTANCE_BATTERY_LEVEL,
FLOAT_INSTANCE_CO2_LEVEL,
FLOAT_INSTANCE_HUMIDITY,
FLOAT_INSTANCE_ILLUMINATION,
FLOAT_INSTANCE_PM10_DENSITY,
FLOAT_INSTANCE_PM1_DENSITY,
FLOAT_INSTANCE_PM2_5_DENSITY,
FLOAT_INSTANCE_POWER,
FLOAT_INSTANCE_PRESSURE,
FLOAT_INSTANCE_TEMPERATURE,
FLOAT_INSTANCE_TVOC,
FLOAT_INSTANCE_VOLTAGE,
FLOAT_INSTANCE_WATER_LEVEL,
)
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/event-instance.html
EVENT_INSTANCE_VIBRATION = 'vibration'
EVENT_INSTANCE_OPEN = 'open'
EVENT_INSTANCE_BUTTON = 'button'
EVENT_INSTANCE_MOTION = 'motion'
EVENT_INSTANCE_SMOKE = 'smoke'
EVENT_INSTANCE_GAS = 'gas'
EVENT_INSTANCE_BATTERY_LEVEL = 'battery_level'
EVENT_INSTANCE_WATER_LEVEL = 'water_level'
EVENT_INSTANCE_WATER_LEAK = 'water_leak'
EVENT_INSTANCES = (
EVENT_INSTANCE_VIBRATION,
EVENT_INSTANCE_OPEN,
EVENT_INSTANCE_BUTTON,
EVENT_INSTANCE_MOTION,
EVENT_INSTANCE_SMOKE,
EVENT_INSTANCE_GAS,
EVENT_INSTANCE_BATTERY_LEVEL,
EVENT_INSTANCE_WATER_LEVEL,
EVENT_INSTANCE_WATER_LEAK,
)
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/event-instance.html#event-instance__vibration
EVENT_VIBRATION_TILT = 'tilt'
EVENT_VIBRATION_FALL = 'fall'
EVENT_VIBRATION_VIBRATION = 'vibration'
EVENT_OPEN_OPENED = 'opened'
EVENT_OPEN_CLOSED = 'closed'
EVENT_BUTTON_CLICK = 'click'
EVENT_BUTTON_DOUBLE_CLICK = 'double_click'
EVENT_BUTTON_LONG_PRESS = 'long_press'
EVENT_MOTION_DETECTED = 'detected'
EVENT_MOTION_NOT_DETECTED = 'not_detected'
EVENT_SMOKE_DETECTED = 'detected'
EVENT_SMOKE_NOT_DETECTED = 'not_detected'
EVENT_SMOKE_HIGH = 'high'
EVENT_GAS_DETECTED = 'detected'
EVENT_GAS_NOT_DETECTED = 'not_detected'
EVENT_GAS_HIGH = 'high'
EVENT_BATTERY_LEVEL_LOW = 'low'
EVENT_BATTERY_LEVEL_NORMAL = 'normal'
EVENT_WATER_LEVEL_LOW = 'low'
EVENT_WATER_LEVEL_NORMAL = 'normal'
EVENT_WATER_LEAK_DRY = 'dry'
EVENT_WATER_LEAK_LEAK = 'leak'
# https://yandex.ru/dev/dialogs/smart-home/doc/concepts/response-codes.html
ERR_DEVICE_UNREACHABLE = 'DEVICE_UNREACHABLE'
ERR_INTERNAL_ERROR = 'INTERNAL_ERROR'
ERR_INVALID_ACTION = 'INVALID_ACTION'
ERR_DEVICE_OFF = 'INVALID_ACTION'
ERR_INVALID_VALUE = 'INVALID_VALUE'
ERR_NOT_SUPPORTED_IN_CURRENT_MODE = 'NOT_SUPPORTED_IN_CURRENT_MODE'
PRESSURE_UNIT_PASCAL = 'pa'
PRESSURE_UNIT_HECTOPASCAL = 'hPa'
PRESSURE_UNIT_KILOPASCAL = 'kPa'
PRESSURE_UNIT_MEGAPASCAL = 'MPa'
PRESSURE_UNIT_MMHG = 'mmHg'
PRESSURE_UNIT_ATM = 'atm'
PRESSURE_UNIT_BAR = 'bar'
PRESSURE_UNIT_MBAR = 'mbar'
# 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'
# 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/syssi/xiaomi_airpurifier#service-fanset_preset_mode
XIAOMI_FAN_PRESET_LEVEL_1 = 'Level 1'
XIAOMI_FAN_PRESET_LEVEL_2 = 'Level 2'
XIAOMI_FAN_PRESET_LEVEL_3 = 'Level 3'
XIAOMI_FAN_PRESET_LEVEL_4 = 'Level 4'
XIAOMI_FAN_PRESET_LEVEL_5 = 'Level 5'
# https://github.com/home-assistant/core/blob/d5a8f1af1d2dc74a12fb6870a4f1cb5318f88bf9/homeassistant/components/xiaomi_miio/fan.py#L744
XIAOMI_FAN_PRESET_NATURE = 'Nature'
XIAOMI_FAN_PRESET_NORMAL = 'Normal'
# https://github.com/ollo69/ha-smartthinq-sensors/blob/2d6212c9e060dc1d4947e1fed195af154442b941/custom_components/smartthinq_sensors/wideq/ac.py#L141
SMARTTHINQ_FAN_PRESET_NATURE = 'NATURE'
# https://github.com/home-assistant/core/blob/6830eec549c372946b19035000c10afecd2f2da3/homeassistant/components/xiaomi_miio/fan.py#L275
XIAOMI_AIRPURIFIER_PRESET_AUTO = 'Auto'
XIAOMI_AIRPURIFIER_PRESET_SILENT = 'Silent'
XIAOMI_AIRPURIFIER_PRESET_LOW = 'Low'
XIAOMI_AIRPURIFIER_PRESET_FAVORITE = 'Favorite'
XIAOMI_AIRPURIFIER_PRESET_IDLE = 'Idle'
XIAOMI_AIRPURIFIER_PRESET_MEDIUM = 'Medium'
XIAOMI_AIRPURIFIER_PRESET_HIGH = 'High'
XIAOMI_AIRPURIFIER_PRESET_STRONG = 'Strong'
XIAOMI_AIRPURIFIER_PRESET_FAN = 'Fan'
XIAOMI_AIRPURIFIER_PRESET_MIDDLE = 'Middle'
# https://github.com/home-assistant/core/blob/d5a8f1af1d2dc74a12fb6870a4f1cb5318f88bf9/homeassistant/components/xiaomi_miio/humidifier.py#L316
XIAOMI_HUMIDIFIER_PRESET_MID = 'Mid'
# https://github.com/airens/tion_home_assistant#climateset_fan_mode
TION_FAN_SPEED_1 = '1'
TION_FAN_SPEED_2 = '2'
TION_FAN_SPEED_3 = '3'
TION_FAN_SPEED_4 = '4'
TION_FAN_SPEED_5 = '5'
TION_FAN_SPEED_6 = '6'
# https://github.com/home-assistant/core/pull/67743
FAN_SPEED_OFF = 'off'
FAN_SPEED_LOW = 'low'
FAN_SPEED_MEDIUM = 'medium'
FAN_SPEED_HIGH = 'high'
# https://github.com/dmitry-k/yandex_smart_home/issues/173
FAN_SPEED_MIN = 'min'
FAN_SPEED_MAX = 'max'
# https://github.com/dmitry-k/yandex_smart_home/issues/347
FAN_SPEED_MID = 'mid'
# SmartIR
FAN_SPEED_HIGHEST = 'highest'
# Fake device class
DEVICE_CLASS_BUTTON = 'button'
MEDIA_PLAYER_FEATURE_VOLUME_MUTE = 'volume_mute'
MEDIA_PLAYER_FEATURE_VOLUME_SET = 'volume_set'
MEDIA_PLAYER_FEATURE_NEXT_PREVIOUS_TRACK = 'next_previous_track'
MEDIA_PLAYER_FEATURE_SELECT_SOURCE = 'select_source'
MEDIA_PLAYER_FEATURE_TURN_ON_OFF = 'turn_on_off'
MEDIA_PLAYER_FEATURE_PLAY_PAUSE = 'play_pause'
MEDIA_PLAYER_FEATURE_PLAY_MEDIA = 'play_media'
MEDIA_PLAYER_FEATURES = (
MEDIA_PLAYER_FEATURE_VOLUME_MUTE,
MEDIA_PLAYER_FEATURE_VOLUME_SET,
MEDIA_PLAYER_FEATURE_NEXT_PREVIOUS_TRACK,
MEDIA_PLAYER_FEATURE_SELECT_SOURCE,
MEDIA_PLAYER_FEATURE_PLAY_PAUSE,
MEDIA_PLAYER_FEATURE_PLAY_MEDIA,
)
@@ -0,0 +1,300 @@
from __future__ import annotations
from typing import Any
from homeassistant.const import ATTR_DEVICE_CLASS, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.area_registry import AreaEntry, AreaRegistry
from homeassistant.helpers.device_registry import DeviceEntry, DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntry
from . import capability as caps, const, prop
from .capability import AbstractCapability
from .capability_custom import CustomModeCapability, CustomRangeCapability, CustomToggleCapability
from .const import (
CONF_ENTITY_PROPERTIES,
CONF_ROOM,
CONF_TYPE,
DEVICE_CLASS_TO_YANDEX_TYPES,
DOMAIN_TO_YANDEX_TYPES,
ERR_DEVICE_UNREACHABLE,
ERR_INTERNAL_ERROR,
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
)
from .error import SmartHomeError
from .helpers import Config, RequestData
from .prop import AbstractProperty
from .prop_custom import CustomEntityProperty
from .prop_event import EventProperty
class YandexEntity:
"""Adaptation of Entity expressed in Yandex's terms."""
def __init__(self, hass: HomeAssistant, config: Config, state: State):
"""Initialize a Yandex Smart Home entity."""
self.hass = hass
self.config = config
self.state = state
self._capabilities: list[AbstractCapability] | None = None
self._properties: list[AbstractProperty] | None = None
@property
def entity_id(self) -> str:
"""Return entity ID."""
return self.state.entity_id
@callback
def capabilities(self) -> list[AbstractCapability]:
"""Return capabilities for entity."""
if self._capabilities is not None:
return self._capabilities
self._capabilities = []
state = self.state
entity_config = self.config.get_entity_config(state.entity_id)
for capability_class, config_key in (
(CustomModeCapability, const.CONF_ENTITY_CUSTOM_MODES),
(CustomToggleCapability, const.CONF_ENTITY_CUSTOM_TOGGLES),
(CustomRangeCapability, const.CONF_ENTITY_CUSTOM_RANGES)):
if config_key in entity_config:
for instance in entity_config[config_key]:
capability = capability_class(
self.hass, self.config, state, instance, entity_config[config_key][instance]
)
if capability.supported():
self._capabilities.append(capability)
for Capability in caps.CAPABILITIES:
capability = Capability(self.hass, self.config, state)
if capability.supported() and capability.instance not in [c.instance for c in self._capabilities]:
self._capabilities.append(capability)
return self._capabilities
@callback
def properties(self) -> list[AbstractProperty]:
"""Return properties for entity."""
if self._properties is not None:
return self._properties
self._properties = []
state = self.state
entity_config = self.config.get_entity_config(state.entity_id)
for property_config in entity_config.get(CONF_ENTITY_PROPERTIES, []):
self._properties.append(
CustomEntityProperty.get(self.hass, self.config, state, property_config)
)
for Property in prop.PROPERTIES:
entity_property = Property(self.hass, self.config, state)
if entity_property.supported():
if entity_property.instance not in [p.instance for p in self._properties]:
self._properties.append(entity_property)
return self._properties
@property
def should_expose(self) -> bool:
"""If device should be exposed."""
if self.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
if not self.yandex_device_type:
return False
return self.config.should_expose(self.entity_id)
@property
def yandex_device_type(self) -> str | None:
"""Yandex type based on domain and device class."""
entity_config = self.config.get_entity_config(self.entity_id)
if CONF_TYPE in entity_config:
return entity_config[CONF_TYPE]
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
domain = self.state.domain
return DEVICE_CLASS_TO_YANDEX_TYPES.get((domain, device_class), DOMAIN_TO_YANDEX_TYPES.get(domain))
async def devices_serialize(self, ent_reg: EntityRegistry, dev_reg: DeviceRegistry,
area_reg: AreaRegistry) -> dict[str, Any] | None:
"""Serialize entity for a devices response.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/get-devices-docpage/
"""
if self.state.state == STATE_UNAVAILABLE:
return None
if not self.capabilities() and not self.properties():
return None
entity_config = self.config.get_entity_config(self.entity_id)
name = (entity_config.get(CONF_NAME) or self.state.name).strip() or self.entity_id
entity_entry, device_entry = await self._get_entity_and_device(ent_reg, dev_reg)
device = {
'id': self.entity_id,
'name': name,
'type': self.yandex_device_type,
'capabilities': [],
'properties': [],
'device_info': {
'model': self.entity_id,
},
}
room = entity_config.get(CONF_ROOM)
if room:
device['room'] = room
else:
area = await self._get_area(entity_entry, device_entry, area_reg)
if area and area.name:
device['room'] = area.name
if device_entry:
if device_entry.manufacturer:
device['device_info']['manufacturer'] = device_entry.manufacturer
if device_entry.model:
device['device_info']['model'] = f'{device_entry.model} | {self.entity_id}'
if device_entry.sw_version:
device['device_info']['sw_version'] = str(device_entry.sw_version)
for item in [c.description() for c in self.capabilities()]:
if item not in device['capabilities']:
device['capabilities'].append(item)
for item in [p.description() for p in self.properties()]:
if item not in device['properties']:
device['properties'].append(item)
return device
@callback
def query_serialize(self) -> dict[str, Any]:
"""Serialize entity for a query response.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-devices-query-docpage/
"""
if self.state.state == STATE_UNAVAILABLE:
return {
'id': self.entity_id, 'error_code': ERR_DEVICE_UNREACHABLE
}
device = {
'id': self.entity_id,
'capabilities': [],
'properties': [],
}
for item in [c for c in self.capabilities() if c.retrievable]:
state = item.get_state()
if state is not None:
device['capabilities'].append(state)
for item in [p for p in self.properties() if p.retrievable]:
state = item.get_state()
if state is not None:
device['properties'].append(state)
return device
async def execute(self,
data: RequestData,
capability_type: str,
instance: str,
state: dict[str, str | int | bool]) -> dict[str, Any] | None:
"""Execute action.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-action-docpage/
"""
target_capabilities = [c for c in self.capabilities() if c.type == capability_type and c.instance == instance]
if not target_capabilities:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Capability not found for instance {instance} ({capability_type}) of {self.state.entity_id}'
)
for capability in target_capabilities:
try:
return await capability.set_state(data, state)
except SmartHomeError:
raise
except Exception as e:
raise SmartHomeError(
ERR_INTERNAL_ERROR,
f'Failed to execute action for instance {instance} ({capability_type}) of {self.state.entity_id}: '
f'{e!r}'
)
async def _get_entity_and_device(self, ent_reg: EntityRegistry, dev_reg: DeviceRegistry) -> \
tuple[RegistryEntry, DeviceEntry] | tuple[None, None]:
"""Fetch the entity and device entries."""
entity_entry = ent_reg.async_get(self.entity_id)
if not entity_entry:
return None, None
device_entry = dev_reg.devices.get(entity_entry.device_id)
return entity_entry, device_entry
@staticmethod
async def _get_area(entity_entry: RegistryEntry | None, device_entry: DeviceEntry | None,
area_reg: AreaRegistry) -> AreaEntry | None:
"""Calculate the area for an entity."""
if entity_entry and entity_entry.area_id:
area_id = entity_entry.area_id
elif device_entry and device_entry.area_id:
area_id = device_entry.area_id
else:
return None
return area_reg.areas.get(area_id)
class YandexEntityCallbackState:
def __init__(self, entity: YandexEntity, event_entity_id: str, initial_report: bool = False):
self.device_id: str = entity.entity_id
self.old_state: YandexEntityCallbackState | None = None
self.capabilities: list[dict] = []
self.properties: list[dict] = []
self.should_report_immediately = False
if entity.state.state == STATE_UNAVAILABLE:
return
for item in [c for c in entity.capabilities() if c.reportable]:
state = item.get_state()
if state is not None:
self.capabilities.append(state)
for item in [c for c in entity.properties() if c.reportable]:
if isinstance(item, CustomEntityProperty):
if item.property_entity_id != event_entity_id:
continue
elif item.state.entity_id != event_entity_id:
continue
if initial_report:
if isinstance(item, EventProperty):
continue
if item.report_immediately:
self.should_report_immediately = True
state = item.get_state()
if state is not None:
self.properties.append(state)
@property
def should_report(self) -> bool:
if not self.capabilities and not self.properties:
return False
if self.old_state:
if self.properties == self.old_state.properties and self.capabilities == self.old_state.capabilities:
return False
return True
@@ -0,0 +1,14 @@
"""Errors for Yandex Smart Home."""
class SmartHomeError(Exception):
"""Yandex Smart Home errors.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/response-codes-docpage/
"""
def __init__(self, code, msg):
"""Log error code."""
super().__init__(msg)
self.code = code
self.message = msg
@@ -0,0 +1,148 @@
"""Helper classes for Yandex Smart Home integration."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from . import const
from .const import DOMAIN, NOTIFIERS, STORE_CACHE_ATTRS
class Config:
"""Hold the configuration for Yandex Smart Home."""
def __init__(self,
hass: HomeAssistant,
entry: ConfigEntry,
entity_config: dict[str, Any] | None = None,
entity_filter: EntityFilter | None = None):
"""Initialize the configuration."""
self._hass = hass
self._data = entry.data
self._options = entry.options
self._entity_filter = entity_filter
self.cache: CacheStore | None = None
self.entity_config = entity_config or {}
async def async_init(self):
self.cache = CacheStore(self._hass)
await self.cache.async_load()
@property
def is_reporting_state(self) -> bool:
"""Return if we're actively reporting states."""
if self.is_cloud_connection:
return True
return bool(self._hass.data[DOMAIN][NOTIFIERS])
@property
def is_cloud_connection(self) -> bool:
return self._data[const.CONF_CONNECTION_TYPE] == const.CONNECTION_TYPE_CLOUD
@property
def is_direct_connection(self) -> bool:
return self._data[const.CONF_CONNECTION_TYPE] == const.CONNECTION_TYPE_DIRECT
@property
def use_cloud_stream(self) -> bool:
return self._options[const.CONF_CLOUD_STREAM]
@property
def cloud_instance_id(self) -> str | None:
return self._data[const.CONF_CLOUD_INSTANCE][const.CONF_CLOUD_INSTANCE_ID]
@property
def cloud_connection_token(self) -> str | None:
return self._data[const.CONF_CLOUD_INSTANCE][const.CONF_CLOUD_INSTANCE_CONNECTION_TOKEN]
@property
def user_id(self) -> str | None:
"""User id for service calls, used only in cloud connection."""
return self._options.get(const.CONF_USER_ID)
@property
def pressure_unit(self) -> str:
return self._options[const.CONF_PRESSURE_UNIT]
@property
def beta(self) -> bool:
return self._options[const.CONF_BETA] # pragma: no cover
@property
def notifier(self) -> list[ConfigType]:
return self._data.get(const.CONF_NOTIFIER, [])
@property
def color_profiles(self) -> dict[str, dict[str, int]]:
return self._options.get(const.CONF_COLOR_PROFILE, {})
@property
def devices_discovered(self) -> bool:
return self._data[const.CONF_DEVICES_DISCOVERED]
def get_entity_config(self, entity_id: str) -> dict[str, Any]:
return self.entity_config.get(entity_id, {})
def should_expose(self, entity_id: str) -> bool:
if self._entity_filter and not self._entity_filter.empty_filter:
return self._entity_filter(entity_id)
return False
class CacheStore:
_STORAGE_VERSION = 1
_STORAGE_KEY = f'{DOMAIN}.cache'
def __init__(self, hass):
self._hass = hass
self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY)
self._data = {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):
"""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)
async def async_load(self):
data = await self._store.async_load()
if data:
self._data = data
class RequestData:
"""Hold data associated with a particular request."""
def __init__(self,
config: Config,
request_user_id: str | None,
request_id: str | None = None,
user_id: str | None = None):
"""Initialize the request data."""
self.config = config
self.context = Context(user_id=user_id)
self.request_user_id = request_user_id
self.request_id = request_id
@@ -0,0 +1,122 @@
"""Support for Yandex Smart Home."""
from __future__ import annotations
import logging
from typing import Any
from aiohttp.web import Request, Response
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from .const import CONFIG, DOMAIN
from .helpers import Config
from .smart_home import RequestData, async_devices, async_handle_message
_LOGGER = logging.getLogger(__name__)
@callback
def async_register_http(hass: HomeAssistant):
"""Register HTTP views for Yandex Smart Home."""
hass.http.register_view(YandexSmartHomeUnauthorizedView())
hass.http.register_view(YandexSmartHomePingView())
hass.http.register_view(YandexSmartHomeView())
class YandexSmartHomeView:
@staticmethod
def _get_config(request: Request) -> Config | None:
return request.app['hass'].data[DOMAIN][CONFIG]
def _is_direct_connection(self, request: Request) -> bool:
"""Check if integration configured to use direct connection."""
config = self._get_config(request)
if not config:
return False
return config.is_direct_connection
class YandexSmartHomeUnauthorizedView(HomeAssistantView, YandexSmartHomeView):
"""Handle Yandex Smart Home unauthorized requests."""
url = f'/api/{DOMAIN}/v1.0'
name = f'api:{DOMAIN}:unauthorized'
requires_auth = False
async def head(self, request: Request) -> Response:
"""Handle Yandex Smart Home HEAD requests."""
_LOGGER.debug('Request: %s (HEAD)' % request.url)
if not self._is_direct_connection(request):
_LOGGER.debug('Integration is not enabled or use cloud connection')
return Response(status=404)
return Response(status=200)
class YandexSmartHomePingView(HomeAssistantView, YandexSmartHomeView):
"""Handle Yandex Smart Home ping requests."""
url = f'/api/{DOMAIN}/v1.0/ping'
name = f'api:{DOMAIN}:unauthorized'
requires_auth = False
# noinspection PyMethodMayBeStatic
async def get(self, request: Request) -> Response:
"""Handle Yandex Smart Home Get requests."""
_LOGGER.debug('Request: %s (GET)' % request.url)
if not self._is_direct_connection(request):
return Response(text='Error: Integration is not enabled or use cloud connection', status=503)
data = RequestData(self._get_config(request), None, 'ping')
devices_sync_response = await async_devices(request.app['hass'], data, {})
device_count = len(devices_sync_response['devices'])
return Response(text=f'OK: {device_count}', status=200)
class YandexSmartHomeView(YandexSmartHomeUnauthorizedView, YandexSmartHomeView):
"""Handle Yandex Smart Home 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, request: Request, message: dict[str, Any] | None = None) -> Response:
if not self._is_direct_connection(request):
_LOGGER.debug('Integration is not enabled or use cloud connection')
return Response(status=404)
data = RequestData(
config=self._get_config(request),
request_user_id=request['hass_user'].id,
request_id=request.headers.get('X-Request-Id'),
user_id=request['hass_user'].id,
)
action = request.path.replace(self.url, '', 1)
result = await async_handle_message(request.app['hass'], data, action, message or {})
response = self.json(result)
_LOGGER.debug(f'Response: {response.text}')
return response
async def post(self, request: Request) -> Response:
"""Handle Yandex Smart Home POST requests."""
_LOGGER.debug('Request: %s (POST data: %s)' % (request.url, await request.text()))
if str(request.url).endswith('/user/unlink'):
return await self._async_handle_request(request)
return await self._async_handle_request(request, await request.json())
async def get(self, request: Request) -> Response:
"""Handle Yandex Smart Home GET requests."""
_LOGGER.debug('Request: %s' % request.url)
return await self._async_handle_request(request)
@@ -0,0 +1,12 @@
{
"domain": "yandex_smart_home",
"name": "Yandex Smart Home",
"config_flow": true,
"documentation": "https://docs.yaha-cloud.ru/v0.6.x/",
"issue_tracker": "https://github.com/dext0r/yandex_smart_home/issues",
"requirements": [],
"iot_class": "cloud_polling",
"dependencies": ["http"],
"codeowners": ["@dext0r"],
"version": "0.6.1"
}
@@ -0,0 +1,345 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from collections import deque
import json
import logging
import time
from typing import Any
from aiohttp import ContentTypeError
from aiohttp.client_exceptions import ClientConnectionError
from homeassistant.const import (
ATTR_ENTITY_ID,
EVENT_HOMEASSISTANT_STARTED,
EVENT_STATE_CHANGED,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.event import async_call_later
from . import const
from .const import (
CONF_NOTIFIER_OAUTH_TOKEN,
CONF_NOTIFIER_SKILL_ID,
CONF_NOTIFIER_USER_ID,
CONFIG,
DOMAIN,
EVENT_CONFIG_CHANGED,
NOTIFIERS,
)
from .entity import YandexEntity, YandexEntityCallbackState
from .helpers import Config
_LOGGER = logging.getLogger(__name__)
DISCOVERY_REQUEST_DELAY = 15
DISCOVERY_REQUEST_DELAY_ON_CONFIG_RELOAD = 5
INITIAL_REPORT_DELAY = 5
REPORT_STATE_WINDOW = 1
class YandexNotifier(ABC):
def __init__(self, hass: HomeAssistant, user_id: str, token: str):
self._hass = hass
self._user_id = user_id
self._token = token
self._property_entities = self._get_property_entities()
self._session = async_create_clientsession(hass)
self._unsub_pending: CALLBACK_TYPE | None = None
self._unsub_send_discovery: CALLBACK_TYPE | None = None
self._pending: deque[YandexEntityCallbackState] = deque()
self._report_states_job = HassJob(self._report_states)
@property
@abstractmethod
def _base_url(self) -> str:
pass
@property
@abstractmethod
def _request_headers(self) -> dict[str, str]:
pass
@property
def _config(self) -> Config:
return self._hass.data[DOMAIN][CONFIG]
@property
def _ready(self) -> bool:
return self._config and self._config.devices_discovered
def _format_log_message(self, message: str) -> str:
return message
@staticmethod
def _log_request(url: str, data: dict[str, Any]):
request_json = json.dumps(data)
_LOGGER.debug(f'Request: {url} (POST data: {request_json})')
def _get_property_entities(self) -> dict[str, list[str]]:
rv = {}
for entity_id, entity_config in self._config.entity_config.items():
for property_config in entity_config.get(const.CONF_ENTITY_PROPERTIES):
property_entity_id = property_config.get(const.CONF_ENTITY_PROPERTY_ENTITY)
if property_entity_id:
rv.setdefault(property_entity_id, [])
if entity_id not in rv[property_entity_id]:
rv[property_entity_id].append(entity_id)
for custom_capabilities_config in [entity_config.get(const.CONF_ENTITY_CUSTOM_MODES),
entity_config.get(const.CONF_ENTITY_CUSTOM_TOGGLES),
entity_config.get(const.CONF_ENTITY_CUSTOM_RANGES)]:
for custom_capability in custom_capabilities_config.values():
state_entity_id = custom_capability.get(const.CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID)
if state_entity_id:
rv.setdefault(state_entity_id, [])
rv[state_entity_id].append(entity_id)
return rv
async def _report_states(self, *_):
devices = {}
attrs = ['properties', 'capabilities']
while len(self._pending):
state = self._pending.popleft()
devices.setdefault(
state.device_id,
dict({attr: [] for attr in attrs}, **{'id': state.device_id})
)
for attr in attrs:
devices[state.device_id][attr][:0] = getattr(state, attr)
await self.async_send_state(list(devices.values()))
if len(self._pending):
self._unsub_pending = async_call_later(self._hass, REPORT_STATE_WINDOW, self._report_states_job)
else:
self._unsub_pending = None
async def async_send_state(self, devices: list):
await self._async_send_callback(f'{self._base_url}/state', {'devices': devices})
async def async_send_discovery(self, _=None):
if not self._ready:
return
_LOGGER.debug(self._format_log_message('Device list update initiated'))
await self._async_send_callback(f'{self._base_url}/discovery', {})
async def async_schedule_discovery(self, delay: int):
self._unsub_send_discovery = async_call_later(self._hass, delay, HassJob(self.async_send_discovery))
# noinspection PyBroadException
async def _async_send_callback(self, url: str, payload: dict[str, Any]):
if self._session.closed:
return
try:
payload['user_id'] = self._user_id
request_data = {'ts': time.time(), 'payload': payload}
self._log_request(url, request_data)
r = await self._session.post(url, headers=self._request_headers, json=request_data, timeout=5)
response_body, error_message = await r.read(), ''
try:
response_data = await r.json()
error_message = response_data['error_message']
except (AttributeError, ValueError, KeyError, ContentTypeError):
if r.status != 202:
error_message = response_body[:100]
if r.status != 202 or error_message:
_LOGGER.warning(
self._format_log_message(f'Failed to send state notification: [{r.status}] {error_message}')
)
except ClientConnectionError as e:
_LOGGER.warning(self._format_log_message(f'Failed to send state notification: {e!r}'))
except asyncio.TimeoutError as e:
_LOGGER.debug(self._format_log_message(f'Failed to send state notification: {e!r}'))
except Exception:
_LOGGER.exception(self._format_log_message('Failed to send state notification'))
async def async_event_handler(self, event: Event):
if not self._ready:
return
event_entity_id = event.data.get(ATTR_ENTITY_ID)
old_state = event.data.get('old_state')
new_state = event.data.get('new_state')
if not old_state or old_state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN, None]:
return
if not new_state or new_state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN, None]:
return
reportable_entity_ids = {event_entity_id}
if event_entity_id in self._property_entities.keys():
reportable_entity_ids.update(self._property_entities.get(event_entity_id, {}))
for entity_id in sorted(reportable_entity_ids):
state = new_state
if entity_id != event_entity_id:
state = self._hass.states.get(entity_id)
if not state:
continue
yandex_entity = YandexEntity(self._hass, self._config, state)
if not yandex_entity.should_expose:
continue
callback_state = YandexEntityCallbackState(yandex_entity, event_entity_id)
if entity_id == event_entity_id and entity_id not in self._property_entities.keys():
callback_state.old_state = YandexEntityCallbackState(
YandexEntity(self._hass, self._config, old_state),
event_entity_id
)
if callback_state.should_report:
entity_text = entity_id
if entity_id != event_entity_id:
entity_text = f'{entity_text} => {event_entity_id}'
_LOGGER.debug(self._format_log_message(
f'Scheduling report state to Yandex for {entity_text}: {new_state.state}'
))
self._pending.append(callback_state)
if self._unsub_pending is None:
delay = 0 if callback_state.should_report_immediately else REPORT_STATE_WINDOW
self._unsub_pending = async_call_later(self._hass, delay, self._report_states_job)
async def async_initial_report(self):
if not self._ready:
return
_LOGGER.debug('Reporting initial states')
for state in self._hass.states.async_all():
yandex_entity = YandexEntity(self._hass, self._config, state)
if not yandex_entity.should_expose:
continue
callback_state = YandexEntityCallbackState(
yandex_entity, event_entity_id=state.entity_id, initial_report=True
)
if callback_state.should_report:
self._pending.append(callback_state)
if self._unsub_pending is None:
self._unsub_pending = async_call_later(self._hass, 0, self._report_states_job)
async def async_unload(self):
if self._unsub_send_discovery:
self._unsub_send_discovery()
class YandexDirectNotifier(YandexNotifier):
def __init__(self, hass: HomeAssistant, user_id: str, token: str, skill_id: str):
self._skill_id = skill_id
super().__init__(hass, user_id, token)
@property
def _base_url(self) -> str:
return f'https://dialogs.yandex.net/api/v1/skills/{self._skill_id}/callback'
@property
def _request_headers(self) -> dict[str, str]:
return {'Authorization': f'OAuth {self._token}'}
def _format_log_message(self, message: str) -> str:
if len(self._hass.data[DOMAIN][NOTIFIERS]) > 1:
return f'[{self._skill_id} | {self._user_id}] {message}'
return message
async def async_validate_config(self):
if await self._hass.auth.async_get_user(self._user_id) is None:
raise ValueError(f'User {self._user_id} does not exist')
class YandexCloudNotifier(YandexNotifier):
@property
def _base_url(self) -> str:
return 'https://yaha-cloud.ru/api/home_assistant/v1/callback'
@property
def _request_headers(self) -> dict[str, str]:
return {'Authorization': f'Bearer {self._token}'}
@callback
def async_setup_notifier(hass: HomeAssistant):
"""Set up notifiers."""
unsub_initial_report: CALLBACK_TYPE | None = None
async def _state_change_listener(event: Event):
await asyncio.gather(*[n.async_event_handler(event) for n in hass.data[DOMAIN][NOTIFIERS]])
hass.bus.async_listen(EVENT_STATE_CHANGED, _state_change_listener)
async def _schedule_discovery(event: Event):
delay = DISCOVERY_REQUEST_DELAY
if event.event_type == EVENT_CONFIG_CHANGED:
delay = DISCOVERY_REQUEST_DELAY_ON_CONFIG_RELOAD
await asyncio.gather(*[n.async_schedule_discovery(delay) for n in hass.data[DOMAIN][NOTIFIERS]])
async def _schedule_initial_report(_: Event):
nonlocal unsub_initial_report
async def _initial_report(_: Event):
nonlocal unsub_initial_report
unsub_initial_report = None
await asyncio.gather(*[n.async_initial_report() for n in hass.data[DOMAIN][NOTIFIERS]])
if unsub_initial_report is None:
unsub_initial_report = async_call_later(hass, INITIAL_REPORT_DELAY, HassJob(_initial_report))
hass.bus.async_listen(EVENT_HOMEASSISTANT_STARTED, _schedule_discovery)
hass.bus.async_listen(EVENT_CONFIG_CHANGED, _schedule_discovery)
hass.bus.async_listen(const.EVENT_DEVICE_DISCOVERY, _schedule_initial_report)
async def async_start_notifier(hass: HomeAssistant):
config = hass.data[DOMAIN][CONFIG]
if config.is_direct_connection:
if not hass.data[DOMAIN][CONFIG].notifier:
_LOGGER.debug('Notifier disabled: no config')
for conf in hass.data[DOMAIN][CONFIG].notifier:
try:
notifier = YandexDirectNotifier(
hass,
conf[CONF_NOTIFIER_USER_ID],
conf[CONF_NOTIFIER_OAUTH_TOKEN],
conf[CONF_NOTIFIER_SKILL_ID],
)
await notifier.async_validate_config()
hass.data[DOMAIN][NOTIFIERS].append(notifier)
except Exception as exc:
raise ConfigEntryNotReady from exc
if config.is_cloud_connection:
hass.data[DOMAIN][NOTIFIERS].append(
YandexCloudNotifier(hass, config.cloud_instance_id, config.cloud_connection_token)
)
async def async_unload_notifier(hass: HomeAssistant):
await asyncio.gather(*[notifier.async_unload() for notifier in hass.data[DOMAIN][NOTIFIERS]])
hass.data[DOMAIN][NOTIFIERS]: list[YandexNotifier] = []
@@ -0,0 +1,78 @@
"""Implement the Yandex Smart Home properties."""
from __future__ import annotations
from abc import ABC, abstractmethod
import logging
from typing import Any, Type
from homeassistant.core import HomeAssistant, State
from .helpers import Config
_LOGGER = logging.getLogger(__name__)
PREFIX_PROPERTIES = 'devices.properties.'
PROPERTIES: list[Type[AbstractProperty]] = []
def register_property(prop):
"""Decorate a function to register a property."""
PROPERTIES.append(prop)
return prop
class AbstractProperty(ABC):
"""Represents a Property."""
type = ''
instance = ''
values = []
retrievable = True
report_immediately = False
def __init__(self, hass: HomeAssistant, config: Config, state: State):
"""Initialize a trait for a state."""
self.hass = hass
self.config = config
self.state = state
self.entity_config = config.get_entity_config(state.entity_id)
self.reportable = config.is_reporting_state
@abstractmethod
def supported(self) -> bool:
pass
def description(self) -> dict[str, Any]:
"""Return description for a devices request."""
response = {
'type': self.type,
'retrievable': self.retrievable,
'reportable': self.reportable,
}
parameters = self.parameters()
if parameters is not None:
response['parameters'] = parameters
return response
def get_state(self) -> dict[str, Any]:
"""Return the state of this property for this entity."""
value = self.get_value()
return {
'type': self.type,
'state': {
'instance': self.instance,
'value': value
}
} if value is not None else None
@abstractmethod
def parameters(self) -> dict[str, Any] | None:
"""Return parameters for a devices request."""
pass
@abstractmethod
def get_value(self) -> str | float | None:
"""Return the state value of this capability for this entity."""
pass
@@ -0,0 +1,130 @@
"""Implement the Yandex Smart Home custom properties."""
from __future__ import annotations
from abc import ABC
import logging
from typing import Any
from homeassistant.components import binary_sensor, sensor
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant, State
from . import const
from .const import (
CONF_ENTITY_PROPERTY_ATTRIBUTE,
CONF_ENTITY_PROPERTY_ENTITY,
CONF_ENTITY_PROPERTY_TYPE,
CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT,
ERR_DEVICE_UNREACHABLE,
EVENT_INSTANCES,
FLOAT_INSTANCES,
)
from .error import SmartHomeError
from .helpers import Config
from .prop import AbstractProperty
from .prop_event import EventProperty
from .prop_float import PRESSURE_UNITS_TO_YANDEX_UNITS, PROPERTY_FLOAT_INSTANCE_TO_UNITS, FloatProperty
_LOGGER = logging.getLogger(__name__)
class CustomEntityProperty(AbstractProperty, ABC):
def __init__(self, hass: HomeAssistant, config: Config, state: State,
property_state: State, property_config: dict[str, Any]):
self.instance = property_config[CONF_ENTITY_PROPERTY_TYPE]
self.property_config = property_config
self.property_state = property_state
super().__init__(hass, config, state)
@classmethod
def get(cls, hass: HomeAssistant, config: Config, state: State,
property_config: dict[str, Any]) -> CustomEventEntityProperty | CustomFloatEntityProperty:
property_state = state
property_entity_id = property_config.get(CONF_ENTITY_PROPERTY_ENTITY)
instance = property_config[CONF_ENTITY_PROPERTY_TYPE]
if property_entity_id:
property_state = hass.states.get(property_entity_id)
if property_state is None:
raise SmartHomeError(
ERR_DEVICE_UNREACHABLE,
f'Entity {property_entity_id} not found for {instance} instance of {state.entity_id}'
)
if property_state.domain == binary_sensor.DOMAIN:
if instance not in EVENT_INSTANCES:
raise SmartHomeError(
ERR_DEVICE_UNREACHABLE,
f'Unsupported entity {property_state.entity_id} for {instance} instance '
f'of {state.entity_id}'
)
return CustomEventEntityProperty(hass, config, state, property_state, property_config)
elif property_state.domain == sensor.DOMAIN:
if instance not in FLOAT_INSTANCES and instance in EVENT_INSTANCES:
# TODO: battery_level and water_level cannot be events for sensor domain
return CustomEventEntityProperty(hass, config, state, property_state, property_config)
return CustomFloatEntityProperty(hass, config, state, property_state, property_config)
def supported(self) -> bool:
return True
def get_value(self) -> str | float | None:
value_attribute = self.property_config.get(CONF_ENTITY_PROPERTY_ATTRIBUTE)
if value_attribute:
if value_attribute not in self.property_state.attributes:
raise SmartHomeError(
ERR_DEVICE_UNREACHABLE,
f'Attribute {value_attribute} not found in entity {self.property_state.entity_id} '
f'for {self.instance} instance of {self.state.entity_id}'
)
value = self.property_state.attributes[value_attribute]
else:
value = self.property_state.state
return value
@property
def property_entity_id(self) -> str | None:
return self.property_config.get(CONF_ENTITY_PROPERTY_ENTITY, self.property_state.entity_id)
class CustomFloatEntityProperty(CustomEntityProperty, FloatProperty):
def __init__(self, hass: HomeAssistant, config: Config, state: State,
property_state: State, property_config: dict[str, str]):
super().__init__(hass, config, state, property_state, property_config)
@property
def unit(self) -> str:
if self.instance == const.FLOAT_INSTANCE_PRESSURE:
return PRESSURE_UNITS_TO_YANDEX_UNITS[self.config.pressure_unit]
return PROPERTY_FLOAT_INSTANCE_TO_UNITS[self.instance]
def get_value(self) -> float | None:
value = super().get_value()
if self.instance in [const.FLOAT_INSTANCE_PRESSURE, const.FLOAT_INSTANCE_TVOC, const.FLOAT_INSTANCE_AMPERAGE]:
value_unit = self.property_config.get(CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT,
self.property_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
return self.convert_value(value, value_unit)
return self.float_value(value)
class CustomEventEntityProperty(CustomEntityProperty, EventProperty):
report_immediately = True
def __init__(self, hass: HomeAssistant, config: Config, state: State,
property_state: State, property_config: dict[str, str]):
super().__init__(hass, config, state, property_state, property_config)
if self.instance in [const.EVENT_INSTANCE_BUTTON, const.EVENT_INSTANCE_VIBRATION]:
self.retrievable = False
def get_value(self) -> str | None:
return self.event_value(super().get_value())
@@ -0,0 +1,311 @@
"""Implement the Yandex Smart Home event properties."""
from __future__ import annotations
from abc import ABC
import logging
from typing import Any
from homeassistant.components import binary_sensor, sensor
from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from . import const
from .const import ERR_NOT_SUPPORTED_IN_CURRENT_MODE, STATE_EMPTY, STATE_NONE, STATE_NONE_UI
from .error import SmartHomeError
from .helpers import Config
from .prop import PREFIX_PROPERTIES, AbstractProperty, register_property
_LOGGER = logging.getLogger(__name__)
PROPERTY_EVENT = PREFIX_PROPERTIES + 'event'
PROPERTY_EVENT_VALUES = {
const.EVENT_INSTANCE_VIBRATION: [
const.EVENT_VIBRATION_VIBRATION,
const.EVENT_VIBRATION_TILT,
const.EVENT_VIBRATION_FALL,
],
const.EVENT_INSTANCE_OPEN: [
const.EVENT_OPEN_OPENED,
const.EVENT_OPEN_CLOSED,
],
const.EVENT_INSTANCE_BUTTON: [
const.EVENT_BUTTON_CLICK,
const.EVENT_BUTTON_DOUBLE_CLICK,
const.EVENT_BUTTON_LONG_PRESS,
],
const.EVENT_INSTANCE_MOTION: [
const.EVENT_MOTION_DETECTED,
const.EVENT_MOTION_NOT_DETECTED,
],
const.EVENT_INSTANCE_SMOKE: [
const.EVENT_SMOKE_DETECTED,
const.EVENT_SMOKE_NOT_DETECTED,
const.EVENT_SMOKE_HIGH,
],
const.EVENT_INSTANCE_GAS: [
const.EVENT_GAS_DETECTED,
const.EVENT_GAS_NOT_DETECTED,
const.EVENT_GAS_HIGH,
],
const.EVENT_INSTANCE_BATTERY_LEVEL: [
const.EVENT_BATTERY_LEVEL_LOW,
const.EVENT_BATTERY_LEVEL_NORMAL,
],
const.EVENT_INSTANCE_WATER_LEVEL: [
const.EVENT_WATER_LEVEL_LOW,
const.EVENT_WATER_LEVEL_NORMAL,
],
const.EVENT_INSTANCE_WATER_LEAK: [
const.EVENT_WATER_LEAK_LEAK,
const.EVENT_WATER_LEAK_DRY,
],
}
class EventProperty(AbstractProperty, ABC):
type = PROPERTY_EVENT
report_immediately = True
def __init__(self, hass: HomeAssistant, config: Config, state: State):
super().__init__(hass, config, state)
self.values = PROPERTY_EVENT_VALUES.get(self.instance)
def parameters(self) -> dict[str, Any]:
return {
'instance': self.instance,
'events': [
{'value': v}
for v in self.values
]
} if self.values else {}
@staticmethod
def bool_value(value) -> bool:
"""Return the bool value according to any type of value."""
return value in [1, STATE_ON, STATE_OPEN, 'high', True]
def event_value(self, value) -> str | None:
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE, STATE_NONE_UI, STATE_EMPTY):
return None
if self.instance == const.EVENT_INSTANCE_OPEN:
return const.EVENT_OPEN_OPENED if self.bool_value(value) else const.EVENT_OPEN_CLOSED
elif self.instance == const.EVENT_INSTANCE_MOTION:
return const.EVENT_MOTION_DETECTED if self.bool_value(value) else const.EVENT_MOTION_NOT_DETECTED
elif self.instance == const.EVENT_INSTANCE_SMOKE:
if value == 'high':
return const.EVENT_SMOKE_HIGH
return const.EVENT_SMOKE_DETECTED if self.bool_value(value) else const.EVENT_SMOKE_NOT_DETECTED
elif self.instance == const.EVENT_INSTANCE_GAS:
if value == 'high':
return const.EVENT_GAS_HIGH
return const.EVENT_GAS_DETECTED if self.bool_value(value) else const.EVENT_GAS_NOT_DETECTED
elif self.instance in const.EVENT_INSTANCE_BATTERY_LEVEL:
return const.EVENT_BATTERY_LEVEL_LOW if self.bool_value(value) else const.EVENT_BATTERY_LEVEL_NORMAL
elif self.instance in const.EVENT_INSTANCE_WATER_LEVEL:
return const.EVENT_WATER_LEVEL_LOW if self.bool_value(value) else const.EVENT_WATER_LEVEL_NORMAL
elif self.instance == const.EVENT_INSTANCE_WATER_LEAK:
return const.EVENT_WATER_LEAK_LEAK if self.bool_value(value) else const.EVENT_WATER_LEAK_DRY
elif self.instance in const.EVENT_INSTANCE_BUTTON:
if value in ['single', 'click']:
return const.EVENT_BUTTON_CLICK
elif value in ['double', 'double_click']:
return const.EVENT_BUTTON_DOUBLE_CLICK
elif value in ['long', 'long_click', 'long_click_press', 'hold']:
return const.EVENT_BUTTON_LONG_PRESS
elif self.instance in const.EVENT_INSTANCE_VIBRATION:
if value in ['vibrate', 'vibration', 'actively', 'move', 'tap_twice', 'shake_air', 'swing']:
return const.EVENT_VIBRATION_VIBRATION
elif self.bool_value(value):
return const.EVENT_VIBRATION_VIBRATION
elif value in ['tilt', 'flip90', 'flip180', 'rotate']:
return const.EVENT_VIBRATION_TILT
elif value in ['free_fall', 'drop']:
return const.EVENT_VIBRATION_FALL
def get_value(self) -> str | None:
if self.state.domain == binary_sensor.DOMAIN:
return self.event_value(self.state.state)
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Failed to get value for instance {self.instance} of {self.state.entity_id}'
)
@register_property
class ContactProperty(EventProperty):
instance = const.EVENT_INSTANCE_OPEN
def supported(self) -> bool:
if self.state.domain == binary_sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) in (
binary_sensor.DEVICE_CLASS_DOOR,
binary_sensor.DEVICE_CLASS_GARAGE_DOOR,
binary_sensor.DEVICE_CLASS_WINDOW,
binary_sensor.DEVICE_CLASS_OPENING
)
return False
@register_property
class MotionProperty(EventProperty):
instance = const.EVENT_INSTANCE_MOTION
def supported(self) -> bool:
if self.state.domain == binary_sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) in (
binary_sensor.DEVICE_CLASS_MOTION,
binary_sensor.DEVICE_CLASS_OCCUPANCY,
binary_sensor.DEVICE_CLASS_PRESENCE
)
return False
@register_property
class GasProperty(EventProperty):
instance = const.EVENT_INSTANCE_GAS
def supported(self) -> bool:
if self.state.domain == binary_sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_GAS
return False
@register_property
class SmokeProperty(EventProperty):
instance = const.EVENT_INSTANCE_SMOKE
def supported(self) -> bool:
if self.state.domain == binary_sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_SMOKE
return False
@register_property
class BatteryLevelLowProperty(EventProperty):
instance = const.EVENT_INSTANCE_BATTERY_LEVEL
def supported(self) -> bool:
if self.state.domain == binary_sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_BATTERY
return False
@register_property
class WaterLevelLowProperty(EventProperty):
instance = const.EVENT_INSTANCE_WATER_LEVEL
def supported(self) -> bool:
if self.state.domain == binary_sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == 'water_level'
return False
@register_property
class WaterLeakProperty(EventProperty):
instance = const.EVENT_INSTANCE_WATER_LEAK
def supported(self) -> bool:
if self.state.domain == binary_sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_MOISTURE
return False
@register_property
class ButtonProperty(EventProperty):
instance = const.EVENT_INSTANCE_BUTTON
retrievable = False
def supported(self) -> bool:
if self.state.attributes.get(ATTR_DEVICE_CLASS) == const.DEVICE_CLASS_BUTTON:
return True
if self.entity_config.get(const.CONF_DEVICE_CLASS) == const.DEVICE_CLASS_BUTTON:
return True
if self.state.domain == binary_sensor.DOMAIN:
return self.state.attributes.get('last_action') in [
'single', 'click', 'double', 'double_click',
'long', 'long_click', 'long_click_press',
'long_click_release', 'hold', 'release',
'triple', 'quadruple', 'many'
]
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get('action') in [
'single', 'click', 'double', 'double_click',
'long', 'long_click', 'long_click_press',
'long_click_release', 'hold', 'release',
'triple', 'quadruple', 'many'
]
return False
def get_value(self) -> str | None:
for value in [self.state.attributes.get('last_action'), self.state.attributes.get('action'), self.state.state]:
event_value = self.event_value(value)
if event_value:
return event_value
@register_property
class VibrationBinarySensorProperty(EventProperty):
instance = const.EVENT_INSTANCE_VIBRATION
retrievable = False
def supported(self) -> bool:
if self.state.domain == binary_sensor.DOMAIN:
if self.state.attributes.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_VIBRATION:
return True
return self.state.attributes.get('last_action') in [
'vibrate', 'tilt', 'free_fall', 'actively',
'move', 'tap_twice', 'shake_air', 'swing',
'flip90', 'flip180', 'rotate', 'drop'
]
return False
def get_value(self) -> str | None:
if self.state.attributes.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_VIBRATION:
return self.event_value(self.state.state)
return self.event_value(self.state.attributes.get('last_action'))
@register_property
class VibrationSensorProperty(EventProperty):
instance = const.EVENT_INSTANCE_VIBRATION
retrievable = False
def supported(self) -> bool:
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get('action') in [
'vibrate', 'tilt', 'free_fall', 'actively',
'move', 'tap_twice', 'shake_air', 'swing',
'flip90', 'flip180', 'rotate', 'drop'
]
return False
def get_value(self) -> str | None:
return self.event_value(self.state.attributes.get('action'))
@@ -0,0 +1,433 @@
"""Implement the Yandex Smart Home float properties."""
from __future__ import annotations
from abc import ABC
import logging
from typing import Any
from homeassistant.components import air_quality, climate, fan, humidifier, light, sensor, switch, water_heater
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
ATTR_VOLTAGE,
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CO2,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_POWER,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ELECTRIC_CURRENT_MILLIAMPERE,
PERCENTAGE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from . import const
from .const import ERR_NOT_SUPPORTED_IN_CURRENT_MODE, STATE_EMPTY, STATE_NONE, STATE_NONE_UI
from .error import SmartHomeError
from .prop import PREFIX_PROPERTIES, AbstractProperty, register_property
_LOGGER = logging.getLogger(__name__)
PROPERTY_FLOAT = PREFIX_PROPERTIES + 'float'
PRESSURE_UNITS_TO_YANDEX_UNITS = {
const.PRESSURE_UNIT_PASCAL: 'unit.pressure.pascal',
const.PRESSURE_UNIT_MMHG: 'unit.pressure.mmhg',
const.PRESSURE_UNIT_ATM: 'unit.pressure.atm',
const.PRESSURE_UNIT_BAR: 'unit.pressure.bar'
}
PRESSURE_TO_PASCAL = {
const.PRESSURE_UNIT_PASCAL: 1,
const.PRESSURE_UNIT_HECTOPASCAL: 100,
const.PRESSURE_UNIT_KILOPASCAL: 1000,
const.PRESSURE_UNIT_MEGAPASCAL: 1000000,
const.PRESSURE_UNIT_MMHG: 133.322,
const.PRESSURE_UNIT_ATM: 101325,
const.PRESSURE_UNIT_BAR: 100000,
const.PRESSURE_UNIT_MBAR: 0.01
}
PRESSURE_FROM_PASCAL = {
const.PRESSURE_UNIT_PASCAL: 1,
const.PRESSURE_UNIT_MMHG: 0.00750061575846,
const.PRESSURE_UNIT_ATM: 0.00000986923266716,
const.PRESSURE_UNIT_BAR: 0.00001,
}
# average molecular weight of tVOC = 110 g/mol
TVOC_CONCENTRATION_TO_MCG_M3 = {
CONCENTRATION_PARTS_PER_BILLION: 4.49629381184,
CONCENTRATION_PARTS_PER_MILLION: 4496.29381184,
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: 35.3146667215,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1
}
PROPERTY_FLOAT_INSTANCE_TO_UNITS = {
const.FLOAT_INSTANCE_HUMIDITY: 'unit.percent',
const.FLOAT_INSTANCE_TEMPERATURE: 'unit.temperature.celsius',
const.FLOAT_INSTANCE_PRESSURE: PRESSURE_UNITS_TO_YANDEX_UNITS[const.PRESSURE_UNIT_MMHG],
const.FLOAT_INSTANCE_WATER_LEVEL: 'unit.percent',
const.FLOAT_INSTANCE_CO2_LEVEL: 'unit.ppm',
const.FLOAT_INSTANCE_POWER: 'unit.watt',
const.FLOAT_INSTANCE_VOLTAGE: 'unit.volt',
const.FLOAT_INSTANCE_BATTERY_LEVEL: 'unit.percent',
const.FLOAT_INSTANCE_AMPERAGE: 'unit.ampere',
const.FLOAT_INSTANCE_ILLUMINATION: 'unit.illumination.lux',
const.FLOAT_INSTANCE_TVOC: 'unit.density.mcg_m3',
const.FLOAT_INSTANCE_PM1_DENSITY: 'unit.density.mcg_m3',
const.FLOAT_INSTANCE_PM2_5_DENSITY: 'unit.density.mcg_m3',
const.FLOAT_INSTANCE_PM10_DENSITY: 'unit.density.mcg_m3'
}
PROPERTY_FLOAT_VALUE_LIMITS = {
'unit.percent': (0, 100),
'unit.pressure.atm': (0, None),
'unit.pressure.pascal': (0, None),
'unit.pressure.bar': (0, None),
'unit.pressure.mmhg': (0, None),
'unit.ppm': (0, None),
'unit.watt': (0, None),
'unit.volt': (0, None),
'unit.ampere': (0, None),
'unit.illumination.lux': (0, None),
'unit.density.mcg_m3': (0, None),
}
class FloatProperty(AbstractProperty, ABC):
type = PROPERTY_FLOAT
def parameters(self) -> dict[str, Any]:
return {
'instance': self.instance,
'unit': self.unit
}
@property
def unit(self) -> str:
return PROPERTY_FLOAT_INSTANCE_TO_UNITS[self.instance]
def float_value(self, value: Any) -> float | None:
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE, STATE_NONE_UI, STATE_EMPTY):
return None
try:
value = float(value)
except (ValueError, TypeError):
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Unsupported value {value!r} for instance {self.instance} of {self.state.entity_id}'
)
if self.unit in PROPERTY_FLOAT_VALUE_LIMITS:
lower_limit, upper_limit = PROPERTY_FLOAT_VALUE_LIMITS.get(self.unit, (None, None))
if (lower_limit is not None and value < lower_limit) or \
(upper_limit is not None and value > upper_limit):
return 0
return value
def convert_value(self, value: Any, from_unit: str | None) -> float | None:
float_value = self.float_value(value)
if float_value is None:
return None
if self.instance == const.FLOAT_INSTANCE_PRESSURE:
if from_unit not in PRESSURE_TO_PASCAL:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Unsupported pressure unit "{from_unit}" '
f'for {self.instance} instance of {self.state.entity_id}'
)
return round(
float_value * PRESSURE_TO_PASCAL[from_unit] *
PRESSURE_FROM_PASCAL[self.config.pressure_unit], 2
)
elif self.instance == const.FLOAT_INSTANCE_TVOC:
return round(float_value * TVOC_CONCENTRATION_TO_MCG_M3.get(from_unit, 1), 2)
elif self.instance == const.FLOAT_INSTANCE_AMPERAGE and from_unit == ELECTRIC_CURRENT_MILLIAMPERE:
return float_value / 1000
return float_value
@register_property
class TemperatureProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_TEMPERATURE
def supported(self) -> bool:
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
elif self.state.domain == air_quality.DOMAIN:
return self.state.attributes.get(climate.ATTR_TEMPERATURE) is not None
elif self.state.domain in (climate.DOMAIN, fan.DOMAIN, humidifier.DOMAIN, water_heater.DOMAIN):
return self.state.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) is not None
return False
def get_value(self) -> float | None:
if self.state.domain == sensor.DOMAIN:
return self.float_value(self.state.state)
elif self.state.domain == air_quality.DOMAIN:
return self.float_value(self.state.attributes.get(climate.ATTR_TEMPERATURE))
elif self.state.domain in (climate.DOMAIN, fan.DOMAIN, humidifier.DOMAIN, water_heater.DOMAIN):
return self.float_value(self.state.attributes.get(climate.ATTR_CURRENT_TEMPERATURE))
@register_property
class HumidityProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_HUMIDITY
def supported(self) -> bool:
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY
elif self.state.domain == air_quality.DOMAIN:
return self.state.attributes.get(climate.ATTR_HUMIDITY) is not None
elif self.state.domain in (climate.DOMAIN, fan.DOMAIN, humidifier.DOMAIN):
return self.state.attributes.get(climate.ATTR_CURRENT_HUMIDITY) is not None
return False
def get_value(self) -> float | None:
if self.state.domain == sensor.DOMAIN:
return self.float_value(self.state.state)
elif self.state.domain == air_quality.DOMAIN:
return self.float_value(self.state.attributes.get(climate.ATTR_HUMIDITY))
elif self.state.domain in (climate.DOMAIN, fan.DOMAIN, humidifier.DOMAIN):
return self.float_value(self.state.attributes.get(climate.ATTR_CURRENT_HUMIDITY))
@register_property
class PressureProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_PRESSURE
def supported(self) -> bool:
if self.state.domain == sensor.DOMAIN:
if self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE:
if self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) in PRESSURE_TO_PASCAL:
return True
return False
def parameters(self) -> dict[str, Any]:
return {
'instance': self.instance,
'unit': self.unit,
}
@property
def unit(self) -> str:
return PRESSURE_UNITS_TO_YANDEX_UNITS[self.config.pressure_unit]
def get_value(self) -> float | None:
return self.convert_value(self.state.state, self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
@register_property
class IlluminanceProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_ILLUMINATION
def supported(self) -> bool:
if self.state.domain in (sensor.DOMAIN, light.DOMAIN, fan.DOMAIN):
if self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ILLUMINANCE:
return True
return const.ATTR_ILLUMINANCE in self.state.attributes
return False
def get_value(self) -> float | None:
if self.state.domain == sensor.DOMAIN:
return self.float_value(self.state.state)
return self.float_value(self.state.attributes.get(const.ATTR_ILLUMINANCE))
@register_property
class WaterLevelProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_WATER_LEVEL
def supported(self) -> bool:
if self.state.domain in (fan.DOMAIN, humidifier.DOMAIN):
return const.ATTR_WATER_LEVEL in self.state.attributes
return False
def get_value(self) -> float | None:
return self.float_value(self.state.attributes.get(const.ATTR_WATER_LEVEL))
@register_property
class CO2Property(FloatProperty):
instance = const.FLOAT_INSTANCE_CO2_LEVEL
def supported(self) -> bool:
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CO2
elif self.state.domain in (air_quality.DOMAIN, fan.DOMAIN):
return air_quality.ATTR_CO2 in self.state.attributes
return False
def get_value(self) -> float | None:
if self.state.domain == sensor.DOMAIN:
return self.float_value(self.state.state)
return self.float_value(self.state.attributes.get(air_quality.ATTR_CO2))
@register_property
class PM1Property(FloatProperty):
instance = const.FLOAT_INSTANCE_PM1_DENSITY
def supported(self) -> bool:
if self.state.domain == air_quality.DOMAIN:
return air_quality.ATTR_PM_0_1 in self.state.attributes
return False
def get_value(self) -> float | None:
return self.float_value(self.state.attributes.get(air_quality.ATTR_PM_0_1))
@register_property
class PM25Property(FloatProperty):
instance = const.FLOAT_INSTANCE_PM2_5_DENSITY
def supported(self) -> bool:
if self.state.domain == air_quality.DOMAIN:
return air_quality.ATTR_PM_2_5 in self.state.attributes
return False
def get_value(self) -> float | None:
return self.float_value(self.state.attributes.get(air_quality.ATTR_PM_2_5))
@register_property
class PM10Property(FloatProperty):
instance = const.FLOAT_INSTANCE_PM10_DENSITY
def supported(self) -> bool:
if self.state.domain == air_quality.DOMAIN:
return air_quality.ATTR_PM_10 in self.state.attributes
return False
def get_value(self) -> float | None:
return self.float_value(self.state.attributes.get(air_quality.ATTR_PM_10))
@register_property
class TVOCProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_TVOC
def supported(self) -> bool:
if self.state.domain == air_quality.DOMAIN:
return const.ATTR_TVOC in self.state.attributes
return False
def get_value(self) -> float | None:
return self.convert_value(
self.state.attributes.get(const.ATTR_TVOC),
self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # may be missing
)
@register_property
class VoltageProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_VOLTAGE
def supported(self) -> bool:
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_VOLTAGE
elif self.state.domain in (switch.DOMAIN, light.DOMAIN):
return ATTR_VOLTAGE in self.state.attributes
return False
def get_value(self) -> float | None:
if self.state.domain == sensor.DOMAIN:
return self.float_value(self.state.state)
return self.float_value(self.state.attributes.get(ATTR_VOLTAGE))
@register_property
class CurrentProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_AMPERAGE
def supported(self) -> bool:
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT
elif self.state.domain in (switch.DOMAIN, light.DOMAIN):
return const.ATTR_CURRENT in self.state.attributes
return False
def get_value(self) -> float | None:
if self.state.domain == sensor.DOMAIN:
value = self.state.state
else:
value = self.state.attributes.get(const.ATTR_CURRENT)
return self.convert_value(value, self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
@register_property
class PowerProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_POWER
def supported(self) -> bool:
if self.state.domain == sensor.DOMAIN:
return self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER
elif self.state.domain == switch.DOMAIN:
for attribute in [const.ATTR_POWER, const.ATTR_LOAD_POWER, const.ATTR_CURRENT_CONSUMPTION]:
if attribute in self.state.attributes:
return True
return False
def get_value(self) -> float | None:
value = None
if self.state.domain == sensor.DOMAIN:
value = self.state.state
elif self.state.domain == switch.DOMAIN:
for attribute in [const.ATTR_POWER, const.ATTR_LOAD_POWER, const.ATTR_CURRENT_CONSUMPTION]:
if attribute in self.state.attributes:
value = self.state.attributes[attribute]
return self.float_value(value)
@register_property
class BatteryLevelProperty(FloatProperty):
instance = const.FLOAT_INSTANCE_BATTERY_LEVEL
def supported(self) -> bool:
if self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY and \
self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE:
return True
return ATTR_BATTERY_LEVEL in self.state.attributes
def get_value(self) -> float | None:
value = None
if self.state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY:
value = self.state.state
elif ATTR_BATTERY_LEVEL in self.state.attributes:
value = self.state.attributes.get(ATTR_BATTERY_LEVEL)
if value in [const.STATE_LOW, const.STATE_CHARGING]:
return 0
return self.float_value(value)
@@ -0,0 +1,2 @@
reload:
description: Reload yandex_smart_home yaml configuration.
@@ -0,0 +1,194 @@
"""Support for Yandex Smart Home API."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry, device_registry, entity_registry
from homeassistant.util.decorator import Registry
from .const import ERR_DEVICE_UNREACHABLE, ERR_INTERNAL_ERROR, EVENT_DEVICE_DISCOVERY
from .entity import YandexEntity
from .error import SmartHomeError
from .helpers import RequestData
HANDLERS = Registry()
_LOGGER = logging.getLogger(__name__)
async def async_handle_message(hass: HomeAssistant,
data: RequestData,
action: str,
message: dict[str, Any]) -> dict[str, Any]:
"""Handle incoming API messages."""
handler = HANDLERS.get(action)
if handler is None:
_LOGGER.error(f'Handler not found for {action!r}')
return {
'request_id': data.request_id,
'payload': {'error_code': ERR_INTERNAL_ERROR}
}
# noinspection PyBroadException
try:
result = await handler(hass, data, message)
except SmartHomeError as err:
_LOGGER.error('Handler error: %s %s', err.code, err.message)
return {
'request_id': data.request_id,
'payload': {'error_code': err.code}
}
except Exception:
_LOGGER.exception('Handler unexpected error')
return {
'request_id': data.request_id,
'payload': {'error_code': ERR_INTERNAL_ERROR}
}
if result is None:
return {'request_id': data.request_id}
return {
'request_id': data.request_id,
'payload': result
}
# noinspection PyUnusedLocal
@HANDLERS.register('/user/devices')
async def async_devices(hass: HomeAssistant, data: RequestData, message: dict[str, Any]):
"""Handle /user/devices request.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/get-devices-docpage/
"""
devices = []
ent_reg = entity_registry.async_get(hass)
dev_reg = device_registry.async_get(hass)
area_reg = area_registry.async_get(hass)
hass.bus.async_fire(EVENT_DEVICE_DISCOVERY, context=data.context)
for state in hass.states.async_all():
entity = YandexEntity(hass, data.config, state)
if not entity.should_expose:
continue
serialized = await entity.devices_serialize(ent_reg, dev_reg, area_reg)
if serialized is None:
_LOGGER.debug(f'Unsupported entity: {entity.state!r}')
continue
devices.append(serialized)
return {
'user_id': data.request_user_id,
'devices': devices,
}
@HANDLERS.register('/user/devices/query')
async def async_devices_query(hass, data, message):
"""Handle /user/devices/query request.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-devices-query-docpage/
"""
devices = []
for device in message.get('devices', []):
entity_id = device['id']
state = hass.states.get(entity_id)
if state is None:
devices.append({
'id': entity_id,
'error_code': ERR_DEVICE_UNREACHABLE
})
continue
entity = YandexEntity(hass, data.config, state)
if not entity.should_expose:
_LOGGER.warning(
f'State requested for unexposed entity {entity.entity_id}. Please either expose the entity via '
f'filters in component configuration or delete the device from Yandex.'
)
devices.append(entity.query_serialize())
return {'devices': devices}
@HANDLERS.register('/user/devices/action')
async def async_devices_execute(hass: HomeAssistant, data: RequestData, message: dict[str, Any]) -> dict[str, Any]:
"""Handle /user/devices/action request.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-action-docpage/
"""
devices = []
for device in message['payload']['devices']:
entity_id = device['id']
state = hass.states.get(entity_id)
if state is None or state.state == STATE_UNAVAILABLE:
devices.append({
'id': entity_id,
'error_code': ERR_DEVICE_UNREACHABLE,
})
continue
entity = YandexEntity(hass, data.config, state)
capabilities_result = []
for capability in device['capabilities']:
capability_type = capability['type']
instance = capability['state']['instance']
try:
value = await entity.execute(data, capability_type, instance, capability['state'])
except SmartHomeError as e:
_LOGGER.error(f'{e.code}: {e.message}')
capabilities_result.append({
'type': capability_type,
'state': {
'instance': instance,
'action_result': {
'status': 'ERROR',
'error_code': e.code
}
}
})
continue
result = {
'type': capability_type,
'state': {
'instance': instance,
'action_result': {
'status': 'DONE',
}
}
}
if value:
result['state']['value'] = value
capabilities_result.append(result)
devices.append({
'id': entity_id,
'capabilities': capabilities_result
})
return {'devices': devices}
# noinspection PyUnusedLocal
@HANDLERS.register('/user/unlink')
async def async_devices_disconnect(hass: HomeAssistant, data: RequestData, message: dict[str, Any]):
"""Handle /user/unlink request.
https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/unlink-docpage/
"""
return None
@@ -0,0 +1,79 @@
{
"config": {
"error": {
"cannot_connect": "Не удалось подключиться",
"entities_not_selected": "Необходимо выбрать хотя бы один объект"
},
"abort": {
"single_instance_allowed": "Невозможно добавить несколько интеграций Yandex Smart Home, но вы можете создать [копию компонента](https://docs.yaha-cloud.ru/v0.6.x/advanced/clone/)."
},
"create_entry": {
"direct": "**Прямое подключение настроено!**\n\nДля его использования необходимо создать навык Умного дома Яндекса и [выполнить дополнительную настройку](https://docs.yaha-cloud.ru/v0.6.x/advanced/direct-connection/)",
"cloud": "**Облачное подключение настроено!**\n\nРеквизиты для привязки Home Assistant к УДЯ:\n* ID: `{id}`\n* Пароль: `{password}`\n\nРеквизиты продублированы в настройках интеграции, сохранять их не требуется.\n\nТеперь вы можете добавить Home Assistant в Умный дом Яндекса, для этого:\n * Откройте [квазар](https://yandex.ru/quasar/iot) или приложение [Дом с Алисой](https://mobile.yandex.ru/apps/smarthome)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите производителя \"Yaha Cloud\"\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nВ [настройках интеграции](https://docs.yaha-cloud.ru/v0.6.x/config/getting-started/#gui) вы можете выбрать пользователя, который будет отображаться в Журнале событий."
},
"step": {
"filter_yaml": {
"title": "Объекты для передачи в УДЯ",
"description": "Объекты для передачи в УДЯ выбраны через YAML конфигурацию. Для настройки через интерфейс вы можете удалить (или закоментировать) раздел `filter`.\n\nНажмите \"Подтвердить\" для продолжения настройки интеграции"
},
"include_entities": {
"title": "Объекты для передачи в УДЯ",
"description": "Выберите объекты, которые будут переданы в УДЯ.\n\nПосле исключения объекта из этого списка необходимо **вручную удалить** устройство из УДЯ, в противном случае оно будет работать некорректно. Удаление всех устройств возможно через [отвязку навыка](https://docs.yaha-cloud.ru/v0.6.x/quasar/#unlink)",
"data": {
"entities": "Объекты"
}
},
"connection_type": {
"title": "Выберите тип подключения к УДЯ",
"description": "* **Через облако**: лёгкое и быстрое подключение через навык [Yaha Cloud](https://dialogs.yandex.ru/store/skills/cef326b2-home-assistant), рекомендуется для всех пользователей\n* **Напрямую**: требуется доступ к Home Assistant по HTTPS через интернет, сложная [многоступенчатая настройка](https://docs.yaha-cloud.ru/v0.6.x/advanced/direct-connection/), только для продвинутых пользователей",
"data": {
"connection_type": "Тип подключения"
}
}
}
},
"options": {
"error": {
"entities_not_selected": "Необходимо выбрать хотя бы один объект"
},
"step": {
"menu": {
"menu_options": {
"include_entities": "Объекты для передачи в УДЯ",
"connection_type": "Тип подключения",
"cloud_info": "ID и пароль (облачное подключение)",
"cloud_settings": "Настройки"
}
},
"filter_yaml": {
"title": "Объекты для передачи в УДЯ",
"description": "Объекты для передачи в УДЯ выбраны через YAML конфигурацию, для настройки через интерфейс удалите (или закоментируйте) раздел `filter`"
},
"include_entities": {
"title": "Объекты для передачи в УДЯ",
"description": "Выберите объекты, которые будут переданы в УДЯ.\n\nПосле исключения объекта из этого списка необходимо **вручную удалить** устройство из УДЯ, в противном случае оно будет работать некорректно. Удаление всех устройств возможно через [отвязку навыка](https://docs.yaha-cloud.ru/v0.6.x/quasar/#unlink)",
"data": {
"entities": "Объекты"
}
},
"connection_type": {
"title": "Выберите тип подключения к УДЯ",
"description": "* **Через облако**: лёгкое и быстрое подключение через навык [Yaha Cloud](https://dialogs.yandex.ru/store/skills/cef326b2-home-assistant), рекомендуется для всех пользователей\n* **Напрямую**: требуется доступ к Home Assistant по HTTPS через интернет, сложная [многоступенчатая настройка](https://docs.yaha-cloud.ru/v0.6.x/advanced/direct-connection/), только для продвинутых пользователей\n\n**Внимание!** После изменении типа подключения потребуется [отвязать старый навык](https://docs.yaha-cloud.ru/v0.6.x/quasar/#unlink) с удалением всех устройств!",
"data": {
"connection_type": "Тип подключения"
}
},
"cloud_settings": {
"title": "Дополнительные настройки",
"description": "Выберите пользователя, от имени которого будут управляться устройства (отображается в Журнале событий)",
"data": {
"user_id": "Пользователь"
}
},
"cloud_info": {
"title": "Облачное подключение",
"description": "Реквизиты для привязки Home Assistant к УДЯ:\n* ID: `{id}`\n* Пароль: `{password}`\n\nТеперь вы можете добавить Home Assistant в Умный дом Яндекса, для этого:\n * Откройте [квазар](https://yandex.ru/quasar/iot) или приложение [Дом с Алисой](https://mobile.yandex.ru/apps/smarthome)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите производителя \"Yaha Cloud\"\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя реквизиты выше"
}
}
}
}