first commit
This commit is contained in:
@@ -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* Выполните привязку используя реквизиты выше"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user