├── .gitignore ├── custom_components └── yandex_smart_home │ ├── core │ ├── __init__.py │ ├── error.py │ ├── type_mapper.py │ ├── http.py │ ├── smart_home.py │ └── helpers.py │ ├── functions │ ├── __init__.py │ ├── prop.py │ └── capability.py │ ├── manifest.json │ ├── sensor.py │ ├── const.py │ ├── translations │ ├── en.json │ └── ru.json │ ├── __init__.py │ └── config_flow.py ├── images ├── header.png ├── step_developer_page.png └── step_developer_type.png ├── hacs.json ├── README.md ├── README.old.md └── info.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea 3 | .dev 4 | .env 5 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core component workings""" 2 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/functions/__init__.py: -------------------------------------------------------------------------------- 1 | """Functions supported by Yandex Smart Home API""" 2 | -------------------------------------------------------------------------------- /images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alryaz/hass-component-yandex-smart-home/HEAD/images/header.png -------------------------------------------------------------------------------- /images/step_developer_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alryaz/hass-component-yandex-smart-home/HEAD/images/step_developer_page.png -------------------------------------------------------------------------------- /images/step_developer_type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alryaz/hass-component-yandex-smart-home/HEAD/images/step_developer_type.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Yandex Smart Home", 3 | "iot_class": "Cloud Push", 4 | "content_in_root": false, 5 | "zip_release": false, 6 | "domains": ["yandex_smart_home"], 7 | "country": ["RU", "BY"], 8 | "render_readme": false, 9 | "homeassistant": "0.96.0" 10 | } 11 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "yandex_smart_home", 3 | "name": "Yandex Smart Home", 4 | "documentation": "https://github.com/alryaz/hass-component-yandex-smart-home", 5 | "issue_tracker": "https://github.com/alryaz/hass-component-yandex-smart-home/issues", 6 | "requirements": [ 7 | "ipaddress" 8 | ], 9 | "dependencies": [ 10 | "http" 11 | ], 12 | "codeowners": [ 13 | "@dmitry-k", 14 | "@alryaz" 15 | ], 16 | "config_flow": true 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://yandex.ru/alice/smart-home) 2 | # _Яндекс Умный Дом_ для HomeAssistant 3 | 4 | ## ⚠️ Данный форк более не поддерживается ⚠️ 5 | 6 | Актуальная версия компонента доступна по ссылке: https://github.com/dmitry-k/yandex_smart_home 7 | 8 | > _Оригинальный разработчик:_ @dmitry-k 9 | >[![Репозиторий GitHub](https://img.shields.io/badge/GitHub-dmitry--k%2Fyandex_smart_home-blue)](https://github.com/dmitry-k/yandex_smart_home) 10 | >[![Пожертвование Yandex](https://img.shields.io/badge/%D0%9F%D0%BE%D0%B6%D0%B5%D1%80%D1%82%D0%B2%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5-Yandex-red.svg)](https://money.yandex.ru/to/41001142896898) 11 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/core/error.py: -------------------------------------------------------------------------------- 1 | """Errors for Yandex Smart Home.""" 2 | from typing import Optional 3 | import inspect 4 | 5 | 6 | class SmartHomeException(Exception): 7 | pass 8 | 9 | 10 | class NotImplementedException(SmartHomeException): 11 | def __init__(self, cls): 12 | super(NotImplementedException, self).__init__( 13 | 'Class %s does not implement %s' 14 | % (cls.__name__, inspect.stack()[1].function) 15 | ) 16 | 17 | 18 | class DefaultNotImplemented(NotImplementedException): 19 | pass 20 | 21 | 22 | class OverrideNotImplemented(NotImplementedException): 23 | pass 24 | 25 | 26 | class SmartHomeError(SmartHomeException): 27 | """Yandex Smart Home errors. 28 | 29 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/response-codes-docpage/ 30 | """ 31 | 32 | def __init__(self, code, msg): 33 | """Log error code.""" 34 | super().__init__(msg) 35 | self.code = code 36 | self.message = msg 37 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, Any, Union, TYPE_CHECKING 2 | 3 | from homeassistant.config_entries import ConfigEntry 4 | from homeassistant.const import STATE_OK 5 | from homeassistant.helpers.entity import Entity 6 | from homeassistant.helpers.typing import HomeAssistantType 7 | 8 | from .const import ( 9 | DOMAIN, 10 | ATTR_LAST_SYNC_TIME, 11 | ATTR_LAST_ACTION_TIME, 12 | ATTR_LAST_ACTION_TARGETS, 13 | ATTR_SYNCED_DEVICES_COUNT, ATTR_YANDEX_TYPE 14 | ) 15 | 16 | if TYPE_CHECKING: 17 | from datetime import datetime 18 | 19 | 20 | # noinspection PyUnusedLocal 21 | async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities): 22 | """Add Yandex statistics sensor.""" 23 | async_add_entities( 24 | [YandexStatisticsSensor()], 25 | False 26 | ) 27 | 28 | return True 29 | 30 | 31 | class YandexStatisticsSensor(Entity): 32 | def __init__(self) -> None: 33 | """Initialize the device mixin.""" 34 | self._last_action_targets = None 35 | self._last_action_time = None 36 | self._last_sync_time = None 37 | self._synced_devices_count = None 38 | 39 | self._identifier = (DOMAIN, "status") 40 | 41 | async def async_added_to_hass(self) -> None: 42 | """Run when entity about to be added to hass.""" 43 | if self.hass.data.get(DOMAIN): 44 | self.hass.data[DOMAIN].sensor_status = self 45 | 46 | async def async_will_remove_from_hass(self) -> None: 47 | """Run when entity will be removed from hass.""" 48 | if self.hass.data.get(DOMAIN): 49 | self.hass.data[DOMAIN].sensor_status = None 50 | 51 | def record_action(self, datetime_at: 'datetime', targets) -> None: 52 | """Shorthand method for action recording.""" 53 | self._last_action_time = str(datetime_at) 54 | self._last_action_targets = list(targets.keys()) 55 | self.schedule_update_ha_state() 56 | 57 | def record_sync(self, datetime_at: 'datetime', devices) -> None: 58 | self._last_sync_time = str(datetime_at) 59 | self._synced_devices_count = len(devices) 60 | self.schedule_update_ha_state() 61 | 62 | @property 63 | def name(self) -> Optional[str]: 64 | return "Yandex Smart Home Status" 65 | 66 | @property 67 | def icon(self) -> Optional[str]: 68 | return 'mdi:cloud' 69 | 70 | @property 71 | def state(self) -> Union[None, str, int, float]: 72 | return STATE_OK 73 | 74 | @property 75 | def device_state_attributes(self) -> Optional[Dict[str, Any]]: 76 | return { 77 | ATTR_LAST_SYNC_TIME: self._last_sync_time, 78 | ATTR_LAST_ACTION_TIME: self._last_action_time, 79 | ATTR_LAST_ACTION_TARGETS: self._last_action_targets, 80 | ATTR_SYNCED_DEVICES_COUNT: self._synced_devices_count, 81 | ATTR_YANDEX_TYPE: False, 82 | } 83 | 84 | @property 85 | def should_poll(self) -> bool: 86 | return False 87 | 88 | @property 89 | def unique_id(self) -> Optional[str]: 90 | return "%s_%s" % self._identifier 91 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Yandex Smart Home.""" 2 | DOMAIN = 'yandex_smart_home' 3 | 4 | DATA_CONFIG = DOMAIN + "_config" 5 | 6 | CONF_ENTITY_CONFIG = 'entity_config' 7 | CONF_FILTER = 'filter' 8 | CONF_ROOM = 'room' 9 | CONF_TYPE = 'type' 10 | CONF_ENTITY_PROPERTY_TYPE = 'type' 11 | CONF_ATTRIBUTE = 'attribute' 12 | CONF_ENTITY_PROPERTIES = 'properties' 13 | CONF_ENTITY_MODES = 'modes' 14 | CONF_ENTITY_RANGES = "ranges" 15 | CONF_CHANNEL_SET_VIA_MEDIA_CONTENT_ID = 'channel_set_via_media_content_id' 16 | CONF_RELATIVE_VOLUME_ONLY = 'relative_volume_only' 17 | CONF_INPUT_SOURCES = 'sources' # <-- do not change this until a major release 18 | CONF_CONTROLS_SWITCH = 'controls_switch' 19 | CONF_SCRIPT_CHANNEL_UP = 'channel_up' 20 | CONF_SCRIPT_CHANNEL_DOWN = 'channel_down' 21 | CONF_ENTITY_TOGGLES = 'toggles' 22 | CONF_PROGRAMS = "programs" 23 | CONF_DIAGNOSTICS_MODE = "diagnostics_mode" 24 | CONF_MAPPING = 'mapping' 25 | CONF_SET_SCRIPT = 'set_script' 26 | CONF_MULTIPLIER = 'multiplier' 27 | CONF_PRECISION = 'precision' 28 | 29 | # Attributes for Yandex statistics sensor 30 | ATTR_LAST_ACTION_TIME = "last_command_time" 31 | ATTR_LAST_ACTION_TARGETS = "last_command_targets" 32 | ATTR_LAST_SYNC_TIME = "last_sync_time" 33 | ATTR_SYNCED_DEVICES_COUNT = "synced_devices_count" 34 | 35 | # Additional attributes accessed within code 36 | ATTR_MODEL = "model" 37 | ATTR_TARGET_HUMIDITY = "target_humidity" 38 | ATTR_CURRENT_POWER_W = "current_power_w" 39 | ATTR_WATER_LEVEL = "water_level" 40 | ATTR_YANDEX_TYPE = "yandex_type" 41 | 42 | # Attributes for services 43 | ATTR_VALUE = "value" 44 | 45 | # Yandex device types 46 | # https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-types-docpage/ 47 | PREFIX_TYPES = 'devices.types.' 48 | TYPE_LIGHT = PREFIX_TYPES + 'light' 49 | TYPE_SOCKET = PREFIX_TYPES + 'socket' 50 | TYPE_SWITCH = PREFIX_TYPES + 'switch' 51 | TYPE_THERMOSTAT = PREFIX_TYPES + 'thermostat' 52 | TYPE_THERMOSTAT_AC = PREFIX_TYPES + 'thermostat.ac' 53 | TYPE_MEDIA_DEVICE = PREFIX_TYPES + 'media_device' 54 | TYPE_MEDIA_DEVICE_TV = PREFIX_TYPES + 'media_device.tv' 55 | TYPE_MEDIA_DEVICE_TV_BOX = PREFIX_TYPES + 'media_device.tv_box' 56 | TYPE_MEDIA_DEVICE_RECEIVER = PREFIX_TYPES + 'media_device.receiver' 57 | TYPE_COOKING = PREFIX_TYPES + 'cooking' 58 | TYPE_COOKING_COFFEE_MAKER = PREFIX_TYPES + 'cooking.coffee_maker' 59 | TYPE_COOKING_KETTLE = PREFIX_TYPES + 'cooking.kettle' 60 | TYPE_OPENABLE = PREFIX_TYPES + 'openable' 61 | TYPE_OPENABLE_CURTAIN = PREFIX_TYPES + 'openable.curtain' 62 | TYPE_HUMIDIFIER = PREFIX_TYPES + 'humidifier' 63 | TYPE_PURIFIER = PREFIX_TYPES + 'purifier' 64 | TYPE_VACUUM_CLEANER = PREFIX_TYPES + 'vacuum_cleaner' 65 | TYPE_WASHING_MACHINE = PREFIX_TYPES + 'washing_machine' 66 | TYPE_OTHER = PREFIX_TYPES + 'other' 67 | 68 | # All yandex device types from above 69 | YANDEX_DEVICE_TYPES = ( 70 | TYPE_LIGHT, TYPE_SOCKET, TYPE_SWITCH, TYPE_THERMOSTAT, TYPE_THERMOSTAT_AC, 71 | TYPE_MEDIA_DEVICE, TYPE_MEDIA_DEVICE_TV, TYPE_MEDIA_DEVICE_TV_BOX, 72 | TYPE_MEDIA_DEVICE_RECEIVER, TYPE_COOKING, TYPE_COOKING_KETTLE, 73 | TYPE_COOKING_COFFEE_MAKER, TYPE_OPENABLE, TYPE_OPENABLE_CURTAIN, 74 | TYPE_HUMIDIFIER, TYPE_PURIFIER, TYPE_VACUUM_CLEANER, TYPE_WASHING_MACHINE, 75 | TYPE_OTHER 76 | ) 77 | 78 | # Custom units for cross-compatibility 79 | UNIT_VOLT = "V" 80 | UNIT_KILOVOLT = "kV" 81 | UNIT_MILLIVOLT = "mV" 82 | UNIT_MEGAVOLT = "MV" 83 | UNIT_AMPERE = "A" 84 | 85 | # Custom device classes for cross-compatibility 86 | DEVICE_CLASS_ANDROIDTV = "androidtv" 87 | DEVICE_CLASS_FIRETV = "firetv" 88 | 89 | # Error codes 90 | # https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/response-codes-docpage/ 91 | ERR_DEVICE_UNREACHABLE = "DEVICE_UNREACHABLE" 92 | ERR_DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND" 93 | ERR_INTERNAL_ERROR = 'INTERNAL_ERROR' 94 | ERR_INVALID_ACTION = 'INVALID_ACTION' 95 | ERR_INVALID_VALUE = 'INVALID_VALUE' 96 | ERR_NOT_SUPPORTED_IN_CURRENT_MODE = 'NOT_SUPPORTED_IN_CURRENT_MODE' 97 | 98 | # Event types 99 | EVENT_ACTION_RECEIVED = 'yandex_smart_home_action' 100 | EVENT_QUERY_RECEIVED = 'yandex_smart_home_query' 101 | EVENT_DEVICES_RECEIVED = 'yandex_smart_home_devices' 102 | 103 | MODES_NUMERIC = ( 104 | 'one', 'two', 'three', 'four', 'five', 105 | 'six', 'seven', 'eight', 'nine', 'ten' 106 | ) 107 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/core/type_mapper.py: -------------------------------------------------------------------------------- 1 | """Type mapper to infer yandex entity types from HomeAssistant's domains""" 2 | 3 | from homeassistant.components import ( 4 | automation, 5 | binary_sensor, 6 | camera, 7 | climate, 8 | cover, 9 | fan, 10 | group, 11 | input_boolean, 12 | light, 13 | lock, 14 | media_player, 15 | scene, 16 | script, 17 | switch, 18 | vacuum, 19 | ) 20 | from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES 21 | from homeassistant.core import State 22 | from homeassistant.helpers.typing import HomeAssistantType 23 | 24 | from ..const import ( 25 | TYPE_OTHER, 26 | TYPE_THERMOSTAT, 27 | TYPE_THERMOSTAT_AC, 28 | TYPE_OPENABLE, 29 | TYPE_OPENABLE_CURTAIN, 30 | TYPE_SWITCH, 31 | TYPE_SOCKET, 32 | TYPE_LIGHT, 33 | TYPE_MEDIA_DEVICE, 34 | TYPE_MEDIA_DEVICE_TV_BOX, 35 | TYPE_VACUUM_CLEANER, 36 | TYPE_MEDIA_DEVICE_TV, 37 | TYPE_HUMIDIFIER, 38 | ATTR_MODEL, 39 | ATTR_TARGET_HUMIDITY, 40 | DEVICE_CLASS_ANDROIDTV, 41 | DEVICE_CLASS_FIRETV, 42 | ATTR_YANDEX_TYPE, 43 | ) 44 | 45 | MAPPING_DEFAULT = "default" 46 | DOMAIN_TO_YANDEX_TYPES = { 47 | automation.DOMAIN: TYPE_OTHER, 48 | binary_sensor.DOMAIN: TYPE_OTHER, 49 | camera.DOMAIN: TYPE_OTHER, 50 | climate.DOMAIN: { 51 | MAPPING_DEFAULT: TYPE_THERMOSTAT, 52 | TYPE_THERMOSTAT_AC: lambda h, s, c: s.attributes.get(ATTR_SUPPORTED_FEATURES) & climate.SUPPORT_SWING_MODE 53 | }, 54 | cover.DOMAIN: { 55 | MAPPING_DEFAULT: TYPE_OPENABLE, 56 | TYPE_OPENABLE_CURTAIN: [ 57 | cover.DEVICE_CLASS_SHADE, 58 | cover.DEVICE_CLASS_SHUTTER, 59 | cover.DEVICE_CLASS_CURTAIN, 60 | cover.DEVICE_CLASS_BLIND, 61 | cover.DEVICE_CLASS_AWNING, 62 | ] 63 | }, 64 | fan.DOMAIN: { 65 | MAPPING_DEFAULT: TYPE_THERMOSTAT, 66 | TYPE_HUMIDIFIER: lambda h, s, c: ( 67 | s.attributes.get(ATTR_MODEL, '').startswith("zhimi.humidifier.") or # Xiaomi Humidifiers 68 | s.attributes.get(ATTR_TARGET_HUMIDITY) is not None # WeMo Humidifiers 69 | ) 70 | }, 71 | group.DOMAIN: TYPE_SWITCH, 72 | input_boolean.DOMAIN: TYPE_SWITCH, 73 | light.DOMAIN: TYPE_LIGHT, 74 | lock.DOMAIN: TYPE_OPENABLE, 75 | media_player.DOMAIN: { 76 | MAPPING_DEFAULT: TYPE_MEDIA_DEVICE, 77 | TYPE_MEDIA_DEVICE_TV: [ 78 | media_player.DEVICE_CLASS_TV, 79 | ], 80 | TYPE_MEDIA_DEVICE_TV_BOX: [ 81 | DEVICE_CLASS_ANDROIDTV, 82 | DEVICE_CLASS_FIRETV 83 | ], 84 | }, 85 | scene.DOMAIN: TYPE_OTHER, 86 | script.DOMAIN: TYPE_OTHER, 87 | switch.DOMAIN: { 88 | MAPPING_DEFAULT: TYPE_SWITCH, 89 | TYPE_SOCKET: [switch.DEVICE_CLASS_OUTLET], 90 | }, 91 | vacuum.DOMAIN: TYPE_VACUUM_CLEANER, 92 | } 93 | 94 | 95 | def get_supported_types(): 96 | supported_types = {} 97 | for _, yandex_type in DOMAIN_TO_YANDEX_TYPES.items(): 98 | if isinstance(yandex_type, dict): 99 | for key, val in yandex_type.items(): 100 | if key == MAPPING_DEFAULT: 101 | supported_types[val] = True 102 | else: 103 | supported_types[key] = True 104 | else: 105 | supported_types[yandex_type] = True 106 | 107 | return supported_types.keys() 108 | 109 | 110 | def determine_state_type(hass: HomeAssistantType, state: State, entity_config): 111 | """Yandex type based on domain and device class.""" 112 | if ATTR_YANDEX_TYPE in state.attributes: 113 | return state.attributes[ATTR_YANDEX_TYPE] 114 | 115 | default_type = TYPE_OTHER 116 | yandex_type = DOMAIN_TO_YANDEX_TYPES.get(state.domain) 117 | if isinstance(yandex_type, dict): 118 | for subtype, mapping_function in yandex_type.items(): 119 | if subtype == MAPPING_DEFAULT: 120 | default_type = mapping_function 121 | 122 | elif callable(mapping_function): 123 | if mapping_function(hass, state, entity_config): 124 | return subtype 125 | 126 | else: 127 | device_class = state.attributes.get(ATTR_DEVICE_CLASS) 128 | if device_class in mapping_function: 129 | return subtype 130 | 131 | elif isinstance(yandex_type, str): 132 | return yandex_type 133 | 134 | return default_type 135 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/core/http.py: -------------------------------------------------------------------------------- 1 | """Support for Yandex Smart Home.""" 2 | import ipaddress 3 | import logging 4 | from json import JSONDecodeError 5 | from types import SimpleNamespace 6 | from typing import TYPE_CHECKING, Tuple, Union, Optional 7 | from uuid import uuid4 8 | 9 | from aiohttp.web import Request, Response 10 | from aiohttp.web_exceptions import HTTPUnauthorized, HTTPBadRequest, HTTPNotFound 11 | from homeassistant.components.http import HomeAssistantView 12 | 13 | from ..const import DOMAIN 14 | from ..core.smart_home import async_handle_message 15 | 16 | if TYPE_CHECKING: 17 | from homeassistant.auth.models import User 18 | from ..core.helpers import Config 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | _LOGGER_REQUEST = logging.getLogger(__name__ + '.request') 22 | _LOGGER_RESPONSE = logging.getLogger(__name__ + '.response') 23 | 24 | 25 | class YandexSmartHomeUnauthorizedView(HomeAssistantView): 26 | """Handle Yandex Smart Home unauthorized requests.""" 27 | 28 | url = '/api/yandex_smart_home/v1.0' 29 | name = 'api:yandex_smart_home:unauthorized' 30 | requires_auth = False 31 | 32 | @classmethod 33 | def config(cls, request: Request) -> 'Config': 34 | return request.app['hass'].data.get(DOMAIN) 35 | 36 | async def head(self, request: Request) -> Response: 37 | """Handle Yandex Smart Home HEAD requests.""" 38 | if not self.config(request): 39 | return Response(status=404) 40 | 41 | _LOGGER_REQUEST.debug("Request: %s (HEAD)" % request.url) 42 | return Response(status=200) 43 | 44 | 45 | class YandexSmartHomeView(YandexSmartHomeUnauthorizedView): 46 | """Handle Yandex Smart Home requests.""" 47 | 48 | url = '/api/yandex_smart_home/v1.0' 49 | extra_urls = [ 50 | url + '/user/unlink', 51 | url + '/user/devices', 52 | url + '/user/devices/query', 53 | url + '/user/devices/action', 54 | ] 55 | name = 'api:yandex_smart_home' 56 | requires_auth = False # this is handled manually within `_process_auth` method 57 | 58 | def _process_auth(self, request: Request) -> Tuple['Config', Union[SimpleNamespace, 'User'], Optional[str]]: 59 | config = self.config(request) 60 | if not config: 61 | raise HTTPNotFound() 62 | 63 | hass_user = request.get('hass_user') 64 | request_id = request.headers.get('X-Request-Id') 65 | remote_accepted = False 66 | if config.diagnostics_mode: 67 | remote_address = ipaddress.ip_address(request.remote) 68 | for network in config.diagnostics_mode: 69 | if remote_address in network: 70 | remote_accepted = True 71 | break 72 | 73 | if remote_accepted: 74 | # Facilitate the use of diagnostics mode by adding dummy data to the request 75 | if not hass_user: 76 | hass_user = SimpleNamespace(id=999999) 77 | if not request_id: 78 | request_id = str(uuid4()).upper() 79 | elif not hass_user: 80 | raise HTTPUnauthorized() 81 | elif not request_id: 82 | raise HTTPBadRequest() 83 | 84 | return config, hass_user, request_id 85 | 86 | async def post(self, request: Request) -> Response: 87 | """Handle Yandex Smart Home POST requests.""" 88 | config, hass_user, request_id = self._process_auth(request) 89 | 90 | try: 91 | message = await request.json() 92 | _LOGGER_REQUEST.debug("Request: %s (JSON data: %s)" % (request.url, message)) 93 | except JSONDecodeError: 94 | message = {} 95 | _LOGGER_REQUEST.debug("Request: %s (POST data: %s)" % (request.url, await request.text())) 96 | 97 | result = await async_handle_message( 98 | request.app['hass'], 99 | config, 100 | hass_user.id, 101 | request_id, 102 | request.path.replace(self.url, '', 1), 103 | message) 104 | 105 | _LOGGER_RESPONSE.debug("Response: %s", result) 106 | return self.json(result) 107 | 108 | async def get(self, request: Request) -> Response: 109 | """Handle Yandex Smart Home GET requests.""" 110 | config, hass_user, request_id = self._process_auth(request) 111 | 112 | _LOGGER_REQUEST.debug("Request: %s" % request.url) 113 | result = await async_handle_message( 114 | request.app['hass'], 115 | config, 116 | hass_user.id, 117 | request_id, 118 | request.path.replace(self.url, '', 1), 119 | {}) 120 | 121 | _LOGGER_RESPONSE.debug("Response: %s" % result) 122 | return self.json(result) 123 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Only a single configuration of Yandex Smart Home is allowed." 5 | }, 6 | "error": { 7 | "conflicting_entity_includes": "Included entities set intersects with excluded entities set", 8 | "customization_unavailable_with_empty_includes": "Unable to initiate custom configuration without specifying entities", 9 | "empty_entity_id": "One of provided entity IDs is empty", 10 | "filter_without_entities": "Configured filter will not yield any entities. Add entities to include, or restart config flow to change domains.", 11 | "invalid_domain": "Invalid entity domain provided", 12 | "invalid_entity_attribute": "Invalid entity attribute provided", 13 | "invalid_entity_id": "Invalid entity ID provided" 14 | }, 15 | "step": { 16 | "custom": { 17 | "data": { 18 | "backlight": "Backlight entity ID (must be toggleable)", 19 | "channel_set_via_media_content_id": "Set channel via `media_content_id` attribute", 20 | "properties": "Configure properties", 21 | "toggles": "Configure toggles", 22 | "type": "Expose entity as other type" 23 | }, 24 | "description": "Configure additional parameters for \"{friendly_name}\" (`{entity_id}`).", 25 | "title": "Customize: {entity_id}" 26 | }, 27 | "custom_properties": { 28 | "data": { 29 | "amperage": "Current (Amperes)", 30 | "battery_level": "Battery level (%)", 31 | "co2_level": "CO\u2082 (ppm)", 32 | "humidity": "Humidity (%)", 33 | "power": "Power consumption (Watts)", 34 | "temperature": "Temperature (\u2103)", 35 | "voltage": "Voltage (Volts)", 36 | "water_level": "Water level (%)" 37 | }, 38 | "description": "Specify one of the following for required properties:\n- _empty_ \u2014 Determine automatically\n- `attribute` \u2014 Current entity attribute\n- `domain.id` \u2014 Other entity's state\n- `domain.id.attribute` \u2014 Attribute of other entity\n\nPage {page} of {pages}", 39 | "title": "Properties configuration" 40 | }, 41 | "custom_toggles": { 42 | "data": { 43 | "backlight": "Backlight", 44 | "controls_locked": "Controls lock", 45 | "ionization": "Ionization", 46 | "keep_warm": "Keep warm", 47 | "mute": "Mute volume", 48 | "oscillation": "Oscillation", 49 | "pause": "Pause" 50 | }, 51 | "description": "Specify one of the following for required toggles:\n- _empty_ \u2014 Determine automatically\n- `domain.id` — Other entity's state\n\nPage {page} of {pages}", 52 | "title": "Toggles configuration" 53 | }, 54 | "selective": { 55 | "data": { 56 | "entity_config": "Customize included entities", 57 | "exclude_entities": "Exclude entities (fan.three, cover.four, ...)", 58 | "include_entities": "Include entities (switch.one, light.two, ...)" 59 | }, 60 | "description": "Exposes/hides certain entities, overriding previous domain configuration. You can leave these fields empty by default.\n\nBy enabling included entities customization you will be able to enable additional features. You can read up on the list of current features on the [integration's GitHub page](https://github.com/alryaz/hass-component-yandex-smart-home/blob/master/README.md).", 61 | "title": "Selective entity exposure" 62 | }, 63 | "supported": { 64 | "data": { 65 | "climate": "Thermostats (`climate`)", 66 | "cover": "Covers (`cover`)", 67 | "fan": "Fans (`fan`)", 68 | "group": "Groups (`group`)", 69 | "input_boolean": "Boolean Inputs (`input_boolean`)", 70 | "light": "Lights (`light`)", 71 | "lock": "Locks (`lock`)", 72 | "media_player": "Media Players (`media_player`)", 73 | "switch": "Switches (`switch`)", 74 | "vacuum": "Vacuums (`vacuum`)" 75 | }, 76 | "description": "Select entity domains which you would like to expose to Yandex Smart Home service. This acts as a wildcard for all entities. You will be able to add per-entity filters later during setup.", 77 | "title": "Configure entities for Yandex exposure" 78 | }, 79 | "unsupported": { 80 | "data": { 81 | "automation": "Automations (`automation`)", 82 | "binary_sensor": "Binary Sensors (`binary_sensor`)", 83 | "camera": "Cameras (`camera`)", 84 | "scene": "Scenes (`scene`)", 85 | "script": "Scripts (`script`)" 86 | }, 87 | "description": "The following domains are not briefly supported by Yandex, and therefore will remain untested until official support is added. Enable them at your own risk!", 88 | "title": "Unsupported entity domains" 89 | }, 90 | "user": { 91 | "data": { 92 | "advanced_configuration": "Advanced configuration (domains, entities, etc.)" 93 | }, 94 | "description": "The following domains are exposed to Yandex by default: {domains}\n\nShould you be willing make any changes, tick the box below before continuing.", 95 | "title": "Yandex Smart Home Setup" 96 | } 97 | }, 98 | "title": "Yandex Smart Home" 99 | } 100 | } -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/translations/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Разрешено создавать только единственную конфигурацию Yandex Smart Home." 5 | }, 6 | "error": { 7 | "conflicting_entity_includes": "Вы указали как миниум один одинаковый идентификатор для обоих опций!", 8 | "customization_unavailable_with_empty_includes": "Невозможно запустить детальную настройку без прямого указания сущностей", 9 | "empty_entity_id": "Один из идентификаторов пустой! (двойная запятая?)", 10 | "filter_without_entities": "При текущей конфигурации, ни одна сущность не будет передана в Яндекс. Пожалуйста, укажите список сущностей, или начните заново и выберите домены.", 11 | "invalid_domain": "Домен \"{invalid_domain}\" не является подходящим!", 12 | "invalid_entity_attribute": "Неверно задан атрибут для объекта!", 13 | "invalid_entity_id": "Идентификатор объекта \"{invalid_entity_id}\" не является действительным!" 14 | }, 15 | "step": { 16 | "custom": { 17 | "data": { 18 | "backlight": "Сущность подсветки (должна быть переключаемой)", 19 | "channel_set_via_media_content_id": "Переключать каналы передавая атрибут `media_content_id`", 20 | "properties": "Настроить свойства", 21 | "toggles": "Настроить переключатели", 22 | "type": "Передавать иной тип устройства" 23 | }, 24 | "description": "Настройте параметры для \"{friendly_name}\" (`{entity_id}`).", 25 | "title": "Дополнительная настройка объекты" 26 | }, 27 | "custom_properties": { 28 | "data": { 29 | "amperage": "Потребляемый ток (Амперы)", 30 | "battery_level": "Уровень заряда источника питания (%)", 31 | "co2_level": "Уровень углекислого газа (мил. доли)", 32 | "humidity": "Влажность (%)", 33 | "power": "Потребляемая мощность (Ватты)", 34 | "temperature": "Температура (℃)", 35 | "voltage": "Напряжение (Вольты)", 36 | "water_level": "Уровень воды (%)" 37 | }, 38 | "description": "Укажите для желаемых свойств одно из следующего:\n- _пусто_ — Определять автоматически\n- `атрибут` — Атрибут текущего объекта\n- `домен.имя` — ID другого объекта\n(будет использовано состояние)\n- `домен.имя.атрибут` — Атрибут другого объекта\n\nСтраница {page} из {pages}", 39 | "title": "Настройка свойств" 40 | }, 41 | "custom_toggles": { 42 | "data": { 43 | "backlight": "Подсветка", 44 | "controls_locked": "Блокировка управления", 45 | "ionization": "Ионизация", 46 | "keep_warm": "Поддерживать тепло", 47 | "mute": "Переключение звука", 48 | "oscillation": "Осциляция", 49 | "pause": "Пауза" 50 | }, 51 | "description": "Укажите для желаемых переключателей одно из следующего:\n- _пусто_ — Определять автоматически\n- `домен.имя` — ID другого объекта\n(будет использовано состояние)\n\nСтраница {page} из {pages}", 52 | "title": "Настройка переключателей" 53 | }, 54 | "selective": { 55 | "data": { 56 | "entity_config": "Дополнительно настроить включённые объекты", 57 | "exclude_entities": "Исключать объекты (fan.three, cover.four, ...)", 58 | "include_entities": "Включать объекты (switch.one, light.two, ...)" 59 | }, 60 | "description": "Раскрывает или скрывает конкретные объекты вопреки предыдущим настройкам домена. Введите идентификаторы сущностей через запятую.", 61 | "title": "Выборочная передача сущностей" 62 | }, 63 | "supported": { 64 | "data": { 65 | "climate": "Термостаты (`climate`)", 66 | "cover": "Шторы (`cover`)", 67 | "fan": "Вентиляторы (`fan`)", 68 | "group": "Группы устройств (`group`)", 69 | "input_boolean": "Бинарный ввод (`input_boolean`)", 70 | "light": "Освещение (`light`)", 71 | "lock": "Замки (`lock`)", 72 | "media_player": "Медиаплееры (`media_player`)", 73 | "switch": "Выключатели (`switch`)", 74 | "vacuum": "Пылесосы (`vacuum`)" 75 | }, 76 | "description": "Выберите требуемые домены, которые стоит начать передавать в Яндекс. Данная опция открывает все элементы под доменом, однако отдельно скрыть определённые из них также возможно (на последнем шаге).\n\nСтраница {page} из {pages}", 77 | "title": "Конфигурация доменов сущностей" 78 | }, 79 | "unsupported": { 80 | "data": { 81 | "automation": "Автоматизации (`automation`)", 82 | "binary_sensor": "Бинарные сенсоры (`binary_sensor`)", 83 | "camera": "Камеры (`camera`)", 84 | "scene": "Сцены (`scene`)", 85 | "script": "Скрипты (`script`)" 86 | }, 87 | "description": "Следующие домены не имеют официальной поддержки. По умолчанию ни один из них не будет отправлен в Яндекс.\n\nСтраница {page} из {pages}", 88 | "title": "Не поддерживаемые домены" 89 | }, 90 | "user": { 91 | "data": { 92 | "advanced_configuration": "Расширенная настройка (домены, объекты, действия, свойства)" 93 | }, 94 | "description": "По умолчанию следуюшие домены после завершения могут быть переданы в Яндекс: {domains}\n\nЕсли Вам требуется дополнительная конфигурация, отметьте галочку ниже.", 95 | "title": "Конфигурация умного дома Yandex" 96 | } 97 | }, 98 | "title": "Yandex Умный Дом" 99 | } 100 | } -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/core/smart_home.py: -------------------------------------------------------------------------------- 1 | """Support for Yandex Smart Home API.""" 2 | import logging 3 | from datetime import datetime 4 | 5 | from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES 6 | from homeassistant.helpers.typing import HomeAssistantType 7 | from homeassistant.util.decorator import Registry 8 | 9 | from ..const import ( 10 | ERR_INTERNAL_ERROR, ERR_DEVICE_UNREACHABLE, 11 | ERR_DEVICE_NOT_FOUND, ATTR_YANDEX_TYPE 12 | ) 13 | from ..core.error import SmartHomeError 14 | from ..core.helpers import RequestData, YandexEntity 15 | 16 | HANDLERS = Registry() 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | async def async_handle_message(hass: HomeAssistantType, config, user_id, request_id, action, 21 | message): 22 | """Handle incoming API messages.""" 23 | data = RequestData(config, user_id, request_id) 24 | 25 | response = await _process(hass, data, action, message) 26 | 27 | if response and 'payload' in response and 'error_code' in response['payload']: 28 | _LOGGER.error('Error handling message %s: %s', 29 | message, response['payload']) 30 | 31 | return response 32 | 33 | 34 | async def _process(hass: HomeAssistantType, data: RequestData, action, message): 35 | """Process a message.""" 36 | handler = HANDLERS.get(action) 37 | 38 | if handler is None: 39 | return { 40 | 'request_id': data.request_id, 41 | 'payload': {'error_code': ERR_INTERNAL_ERROR} 42 | } 43 | 44 | # noinspection PyBroadException 45 | try: 46 | result = await handler(hass, data, message) 47 | 48 | except SmartHomeError as err: 49 | return { 50 | 'request_id': data.request_id, 51 | 'payload': {'error_code': err.code} 52 | } 53 | 54 | except Exception: # pylint: disable=broad-except 55 | _LOGGER.exception('Unexpected error') 56 | return { 57 | 'request_id': data.request_id, 58 | 'payload': {'error_code': ERR_INTERNAL_ERROR} 59 | } 60 | 61 | if result is None: 62 | if data.request_id is None: 63 | return None 64 | 65 | return {'request_id': data.request_id} 66 | 67 | return {'request_id': data.request_id, 'payload': result} 68 | 69 | 70 | # noinspection PyUnusedLocal 71 | @HANDLERS.register('/user/devices') 72 | async def async_devices_sync(hass: HomeAssistantType, data: RequestData, message): 73 | """Handle /user/devices request. 74 | 75 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/get-devices-docpage/ 76 | 77 | :param hass: HomeAssistant object 78 | :param data: Request data 79 | :param message: Message contents 80 | :return: Optional response 81 | """ 82 | devices = [] 83 | for state in hass.states.async_all(): 84 | if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: 85 | continue 86 | 87 | if state.attributes.get(ATTR_YANDEX_TYPE) is False: 88 | continue 89 | 90 | if not data.config.should_expose(state.entity_id): 91 | continue 92 | 93 | entity = YandexEntity(hass, data.config, state) 94 | serialized = await entity.devices_serialize() 95 | 96 | if serialized is None: 97 | _LOGGER.debug("No mapping for %s domain", entity.state) 98 | continue 99 | 100 | devices.append(serialized) 101 | 102 | response = { 103 | 'user_id': data.context.user_id, 104 | 'devices': devices, 105 | } 106 | 107 | return response 108 | 109 | 110 | # noinspection PyUnusedLocal 111 | @HANDLERS.register('/user/devices/query') 112 | async def async_devices_query(hass: HomeAssistantType, data: RequestData, message): 113 | """Handle /user/devices/query request. 114 | 115 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-devices-query-docpage/ 116 | 117 | :param hass: HomeAssistant object 118 | :param data: Request data 119 | :param message: Message contents 120 | :return: Optional response 121 | """ 122 | devices = [] 123 | for device in message.get('devices', []): 124 | entity_id = device['id'] 125 | 126 | if not data.config.should_expose(entity_id): 127 | devices.append({ 128 | 'id': entity_id, 129 | 'error_code': ERR_DEVICE_NOT_FOUND, 130 | }) 131 | continue 132 | 133 | state = hass.states.get(entity_id) 134 | 135 | if not state or state.attributes.get(ATTR_YANDEX_TYPE) is False: 136 | # If we can't find a state, the device is unreachable 137 | devices.append({ 138 | 'id': entity_id, 139 | 'error_code': ERR_DEVICE_UNREACHABLE 140 | }) 141 | continue 142 | 143 | entity = YandexEntity(hass, data.config, state) 144 | devices.append(entity.query_serialize()) 145 | 146 | yandex_sensor = data.config.sensor_status 147 | if yandex_sensor: 148 | yandex_sensor.record_sync(datetime.now(), devices) 149 | 150 | return {'devices': devices} 151 | 152 | 153 | # noinspection PyUnusedLocal 154 | @HANDLERS.register('/user/devices/action') 155 | async def handle_devices_execute(hass: HomeAssistantType, data: RequestData, message): 156 | """Handle /user/devices/action request. 157 | 158 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-action-docpage/ 159 | 160 | :param hass: HomeAssistant object 161 | :param data: Request data 162 | :param message: Message contents 163 | :return: Optional response 164 | """ 165 | entities = {} 166 | devices = {} 167 | results = {} 168 | action_errors = {} 169 | 170 | for device in message['payload']['devices']: 171 | entity_id = device['id'] 172 | devices[entity_id] = device 173 | 174 | if entity_id not in entities: 175 | if not data.config.should_expose(entity_id): 176 | results[entity_id] = { 177 | 'id': entity_id, 178 | 'error_code': ERR_DEVICE_NOT_FOUND, 179 | } 180 | continue 181 | 182 | state = hass.states.get(entity_id) 183 | 184 | if not state: 185 | results[entity_id] = { 186 | 'id': entity_id, 187 | 'error_code': ERR_DEVICE_UNREACHABLE, 188 | } 189 | continue 190 | 191 | entities[entity_id] = YandexEntity(hass, data.config, state) 192 | 193 | for capability in device['capabilities']: 194 | try: 195 | await entities[entity_id].execute(data, 196 | capability.get('type', ''), 197 | capability.get('state', {})) 198 | except SmartHomeError as err: 199 | _LOGGER.error("%s: %s" % (err.code, err.message)) 200 | if entity_id not in action_errors: 201 | action_errors[entity_id] = {} 202 | action_errors[entity_id][capability['type']] = err.code 203 | 204 | final_results = list(results.values()) 205 | 206 | for entity in entities.values(): 207 | if entity.entity_id in results: 208 | continue 209 | 210 | entity.async_update() 211 | 212 | capabilities = [] 213 | for capability in devices[entity.entity_id]['capabilities']: 214 | if capability['state'] is None or 'instance' not in capability[ 215 | 'state']: 216 | continue 217 | if entity.entity_id in action_errors and capability['type'] in \ 218 | action_errors[entity.entity_id]: 219 | capabilities.append({ 220 | 'type': capability['type'], 221 | 'state': { 222 | 'instance': capability['state']['instance'], 223 | 'action_result': { 224 | 'status': 'ERROR', 225 | 'error_code': action_errors[entity.entity_id][ 226 | capability['type']], 227 | } 228 | } 229 | }) 230 | else: 231 | capabilities.append({ 232 | 'type': capability['type'], 233 | 'state': { 234 | 'instance': capability['state']['instance'], 235 | 'action_result': { 236 | 'status': 'DONE', 237 | } 238 | } 239 | }) 240 | 241 | final_results.append({ 242 | 'id': entity.entity_id, 243 | 'capabilities': capabilities, 244 | }) 245 | 246 | yandex_sensor = data.config.sensor_status 247 | if yandex_sensor: 248 | yandex_sensor.record_action(datetime.now(), entities) 249 | 250 | return {'devices': final_results} 251 | 252 | 253 | # noinspection PyUnusedLocal 254 | @HANDLERS.register('/user/unlink') 255 | async def async_devices_disconnect(hass: HomeAssistantType, data: 'RequestData', message): 256 | """Handle /user/unlink request. 257 | 258 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/unlink-docpage/ 259 | 260 | :param hass: HomeAssistant object 261 | :param data: Request data 262 | :param message: Message contents 263 | :return: Optional response 264 | """ 265 | return None 266 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/functions/prop.py: -------------------------------------------------------------------------------- 1 | """Implement the Yandex Smart Home properties.""" 2 | import logging 3 | from typing import Dict, Any, List, Type 4 | 5 | from homeassistant.components import ( 6 | climate, 7 | sensor, 8 | air_quality, 9 | ) 10 | from homeassistant.const import ( 11 | ATTR_DEVICE_CLASS, 12 | ATTR_UNIT_OF_MEASUREMENT, 13 | DEVICE_CLASS_HUMIDITY, 14 | DEVICE_CLASS_TEMPERATURE, 15 | STATE_UNAVAILABLE, 16 | STATE_UNKNOWN, 17 | POWER_WATT, DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE, CONF_ENTITY_ID, 18 | ) 19 | 20 | from ..const import ( 21 | CONF_ENTITY_PROPERTIES, 22 | CONF_ATTRIBUTE, 23 | ATTR_CURRENT_POWER_W, 24 | ATTR_WATER_LEVEL, 25 | UNIT_VOLT, 26 | UNIT_KILOVOLT, 27 | UNIT_MEGAVOLT, 28 | UNIT_MILLIVOLT, 29 | UNIT_AMPERE, 30 | ) 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | PREFIX_PROPERTIES = 'devices.properties.' 35 | PROPERTY_FLOAT = PREFIX_PROPERTIES + 'float' 36 | 37 | PROPERTIES: List[Type['_Property']] = [] 38 | 39 | 40 | def register_property(prop): 41 | """Decorate a function to register a property.""" 42 | PROPERTIES.append(prop) 43 | return prop 44 | 45 | 46 | class _Property: 47 | """Represents a Property.""" 48 | unit = '' 49 | type = '' 50 | instance = '' 51 | supported_sensor_units = [] 52 | default_value = None 53 | 54 | def __init__(self, hass, state, entity_config): 55 | """Initialize a trait for a state.""" 56 | self.hass = hass 57 | self.state = state 58 | self.entity_config = entity_config 59 | 60 | @classmethod 61 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 62 | return False 63 | 64 | @classmethod 65 | def has_override(cls, _: str, entity_config: Dict, __: Dict) -> bool: 66 | entity_properties = entity_config.get(CONF_ENTITY_PROPERTIES) 67 | return bool(entity_properties) and bool(entity_properties.get(cls.instance)) 68 | 69 | def description(self): 70 | """Return description for a devices request.""" 71 | response = { 72 | 'type': self.type, 73 | 'retrievable': True, 74 | } 75 | parameters = self.parameters() 76 | if parameters is not None: 77 | response['parameters'] = parameters 78 | 79 | return response 80 | 81 | def get_state(self): 82 | """Return the state of this property for this entity.""" 83 | return { 84 | 'type': self.type, 85 | 'state': { 86 | 'instance': self.instance, 87 | 'value': self.get_value() 88 | } 89 | } 90 | 91 | def parameters(self): 92 | return { 93 | 'instance': self.instance, 94 | 'unit': self.unit 95 | } 96 | 97 | def get_value(self): 98 | """Return the state value of this capability for this entity.""" 99 | state = self.state 100 | if self.has_override(state.domain, self.entity_config, state.attributes): 101 | return self.get_value_override() 102 | if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) and self.default_value is not None: 103 | return self.default_value 104 | return self.get_value_default() 105 | 106 | def get_value_default(self) -> Any: 107 | raise NotImplementedError("Properties must implement this!") 108 | 109 | def get_value_override(self) -> Any: 110 | raise NotImplementedError("Properties must implement this!") 111 | 112 | 113 | class _FloatProperty(_Property): 114 | """Represents base class for float properties.""" 115 | type = PROPERTY_FLOAT 116 | default_value = 0.0 117 | 118 | def get_value_default(self) -> float: 119 | return float(self.state.state) 120 | 121 | def get_value_override(self) -> float: 122 | property_config = self.entity_config[CONF_ENTITY_PROPERTIES][self.instance] 123 | 124 | if CONF_ENTITY_ID in property_config: 125 | property_entity_id = property_config.get(CONF_ENTITY_ID) 126 | state = self.hass.states.get(property_entity_id) 127 | else: 128 | state = self.state 129 | 130 | if not state or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): 131 | return 0.0 132 | 133 | if CONF_ATTRIBUTE in property_config: 134 | attribute = property_config.get(CONF_ATTRIBUTE) 135 | return float(state.get(attribute, 0.0)) 136 | 137 | return float(state.state) 138 | 139 | 140 | @register_property 141 | class TemperatureProperty(_FloatProperty): 142 | """Temperature property""" 143 | instance = 'temperature' 144 | unit = 'unit.temperature.celsius' 145 | 146 | @classmethod 147 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 148 | if domain == sensor.DOMAIN: 149 | return attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE 150 | elif domain == climate.DOMAIN: 151 | return attributes.get(climate.ATTR_CURRENT_TEMPERATURE) is not None 152 | 153 | return False 154 | 155 | def get_value_default(self) -> float: 156 | value = 0.0 157 | if self.state.domain == sensor.DOMAIN: 158 | value = self.state.state 159 | elif self.state.domain == climate.DOMAIN: 160 | value = self.state.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) 161 | return float(value) 162 | 163 | 164 | @register_property 165 | class HumidityProperty(_FloatProperty): 166 | """Humidity property.""" 167 | instance = "humidity" 168 | unit = "unit.percent" 169 | 170 | @classmethod 171 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 172 | if domain == sensor.DOMAIN: 173 | return attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY 174 | elif domain == climate.DOMAIN: 175 | return attributes.get(climate.ATTR_CURRENT_HUMIDITY) is not None 176 | 177 | return False 178 | 179 | def get_value_default(self): 180 | value = 0 181 | if self.state.domain == sensor.DOMAIN: 182 | value = self.state.state 183 | elif self.state.domain == climate.DOMAIN: 184 | value = self.state.attributes.get(climate.ATTR_CURRENT_HUMIDITY) 185 | return float(value) 186 | 187 | 188 | @register_property 189 | class WaterLevelProperty(_FloatProperty): 190 | """Water level property.""" 191 | instance = "water_level" 192 | unit = "unit.percent" 193 | 194 | @classmethod 195 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 196 | return attributes.get(ATTR_WATER_LEVEL) is not None 197 | 198 | def get_value_default(self): 199 | return float(self.state.attributes.get(ATTR_WATER_LEVEL, 0.0)) 200 | 201 | 202 | @register_property 203 | class CO2LevelProperty(_FloatProperty): 204 | """Water level property.""" 205 | instance = "co2_level" 206 | unit = "unit.ppm" 207 | 208 | @classmethod 209 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 210 | return domain == air_quality.DOMAIN and \ 211 | attributes.get(air_quality.ATTR_CO2) is not None 212 | 213 | def get_value_default(self): 214 | return float(self.state.attributes.get(air_quality.ATTR_CO2, 0.0)) 215 | 216 | 217 | @register_property 218 | class PowerProperty(_FloatProperty): 219 | """Current power property.""" 220 | instance = "power" 221 | unit = "unit.watt" 222 | 223 | supported_sensor_units = [ 224 | POWER_WATT, 225 | f'k{POWER_WATT}', 226 | ] 227 | 228 | @classmethod 229 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 230 | if domain == sensor.DOMAIN: 231 | return attributes.get(ATTR_UNIT_OF_MEASUREMENT) in cls.supported_sensor_units 232 | 233 | return attributes.get(ATTR_CURRENT_POWER_W) is not None 234 | 235 | def get_value_default(self): 236 | if self.state.domain == sensor.DOMAIN: 237 | unit_of_measurement = self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) 238 | 239 | if unit_of_measurement is None or unit_of_measurement == POWER_WATT: 240 | return float(self.state.state) 241 | elif unit_of_measurement == 'k' + POWER_WATT: 242 | return float(self.state.state) / 1000.0 243 | 244 | return float(self.state.attributes(ATTR_CURRENT_POWER_W, 0.0)) 245 | 246 | 247 | @register_property 248 | class VoltageProperty(_FloatProperty): 249 | """Voltage property.""" 250 | instance = "voltage" 251 | unit = "unit.volt" 252 | 253 | supported_sensor_units = [UNIT_VOLT, UNIT_KILOVOLT, UNIT_MEGAVOLT, UNIT_MILLIVOLT] 254 | 255 | @classmethod 256 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 257 | return domain == sensor.DOMAIN and \ 258 | attributes.get(ATTR_UNIT_OF_MEASUREMENT) in cls.supported_sensor_units 259 | 260 | def get_value_default(self) -> float: 261 | unit_of_measurement = self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) 262 | if unit_of_measurement == UNIT_MEGAVOLT: 263 | return float(self.state.state) / 1000000.0 264 | elif unit_of_measurement == UNIT_KILOVOLT: 265 | return float(self.state.state) / 1000.0 266 | elif unit_of_measurement == UNIT_MILLIVOLT: 267 | return float(self.state.state) * 1000.0 268 | return float(self.state.state) 269 | 270 | 271 | @register_property 272 | class AmperageProperty(_FloatProperty): 273 | """Voltage property.""" 274 | instance = "amperage" 275 | unit = "unit.ampere" 276 | 277 | @classmethod 278 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 279 | return domain == sensor.DOMAIN and \ 280 | attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_AMPERE 281 | 282 | def get_value_default(self) -> float: 283 | return float(self.state.state) 284 | 285 | 286 | @register_property 287 | class BatteryLevelProperty(_FloatProperty): 288 | """Battery level property.""" 289 | instance = "battery_level" 290 | unit = "unit.percent" 291 | 292 | @classmethod 293 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 294 | return attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY and \ 295 | attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE 296 | 297 | def get_value_default(self) -> float: 298 | return float(self.state.state) 299 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/core/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper classes for Yandex Smart Home integration.""" 2 | import ipaddress 3 | import logging 4 | from asyncio import gather 5 | from collections.abc import Mapping 6 | from typing import TYPE_CHECKING, Type, List, Optional, Union 7 | 8 | from homeassistant.const import ( 9 | CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES 10 | ) 11 | from homeassistant.core import Context, callback, State 12 | from homeassistant.helpers.typing import HomeAssistantType 13 | 14 | from ..const import ( 15 | ERR_NOT_SUPPORTED_IN_CURRENT_MODE, ERR_DEVICE_UNREACHABLE, 16 | ERR_INVALID_VALUE, CONF_ROOM, CONF_TYPE 17 | ) 18 | from ..core.error import SmartHomeError 19 | from ..core.type_mapper import determine_state_type 20 | from ..functions import prop, capability 21 | 22 | if TYPE_CHECKING: 23 | from homeassistant.helpers.device_registry import DeviceEntry 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | CapabilityType = 'capability._Capability' 28 | PropertyType = 'prop._Property' 29 | AnyInstanceType = Union[Type[PropertyType], Type[CapabilityType]] 30 | 31 | 32 | def deep_update(target, source): 33 | """Update a nested dictionary with another nested dictionary.""" 34 | for key, value in source.items(): 35 | if isinstance(value, Mapping): 36 | target[key] = deep_update(target.get(key, {}), value) 37 | else: 38 | target[key] = value 39 | return target 40 | 41 | 42 | def get_child_instances(source: List[AnyInstanceType], _type: Optional[str] = None) -> List[str]: 43 | """ 44 | Get instance list for subclasses of given class. 45 | :param _type: 46 | :param source: 47 | :return: 48 | """ 49 | if _type is None: 50 | return [item.instance for item in source] 51 | 52 | return [ 53 | item.instance 54 | for item in source 55 | if item.type == _type 56 | ] 57 | 58 | 59 | class Config: 60 | """Hold the configuration for Yandex Smart Home.""" 61 | 62 | def __init__(self, should_expose, entity_config=None, 63 | diagnostics_mode: Union[bool, ipaddress.IPv4Network, ipaddress.IPv6Network] = False): 64 | """Initialize the configuration.""" 65 | self.should_expose = should_expose 66 | self.entity_config = entity_config or {} 67 | self.sensor_status = None 68 | self.diagnostics_mode = diagnostics_mode 69 | 70 | 71 | class RequestData: 72 | """Hold data associated with a particular request.""" 73 | 74 | def __init__(self, config, user_id, request_id): 75 | """Initialize the request data.""" 76 | self.config = config 77 | self.request_id = request_id 78 | self.context = Context(user_id=user_id) 79 | 80 | 81 | class YandexEntity: 82 | """Adaptation of Entity expressed in Yandex's terms.""" 83 | 84 | def __init__(self, hass: HomeAssistantType, config: Config, state: State): 85 | """Initialize a Yandex Smart Home entity.""" 86 | self.hass = hass 87 | self.config = config 88 | self.state = state 89 | self._capabilities: Optional[List[CapabilityType]] = None 90 | self._properties: Optional[List[PropertyType]] = None 91 | 92 | @property 93 | def entity_id(self): 94 | """Return entity ID.""" 95 | return self.state.entity_id 96 | 97 | @callback 98 | def _generate_support_list(self, from_range: List[AnyInstanceType]): 99 | state = self.state 100 | domain = state.domain 101 | features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) 102 | entity_config = self.config.entity_config.get(state.entity_id, {}) 103 | attributes = state.attributes 104 | 105 | return [ 106 | from_class(self.hass, state, entity_config) 107 | for from_class in from_range 108 | if from_class.supported(domain, features, entity_config, attributes) 109 | or from_class.has_override(domain, entity_config, attributes) 110 | ] 111 | 112 | @callback 113 | def capabilities(self): 114 | """Return capabilities for entity.""" 115 | if self._capabilities is not None: 116 | return self._capabilities 117 | 118 | self._capabilities = self._generate_support_list(capability.CAPABILITIES) 119 | 120 | return self._capabilities 121 | 122 | @callback 123 | def properties(self): 124 | """Return properties for entity.""" 125 | if self._properties is not None: 126 | return self._properties 127 | 128 | self._properties = self._generate_support_list(prop.PROPERTIES) 129 | 130 | return self._properties 131 | 132 | async def devices_serialize(self): 133 | """Serialize entity for a devices response. 134 | 135 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/get-devices-docpage/ 136 | """ 137 | state = self.state 138 | 139 | # When a state is unavailable, the attributes that describe 140 | # capabilities will be stripped. For example, a light entity will miss 141 | # the min/max mireds. Therefore they will be excluded from a sync. 142 | if state.state == STATE_UNAVAILABLE: 143 | return None 144 | 145 | entity_config = self.config.entity_config.get(state.entity_id, {}) 146 | name = (entity_config.get(CONF_NAME) or state.name).strip() 147 | 148 | # If an empty string 149 | if not name: 150 | return None 151 | 152 | capabilities = self.capabilities() 153 | properties = self.properties() 154 | 155 | # Found no supported capabilities for this entity 156 | if not capabilities and not properties: 157 | return None 158 | 159 | device_type = entity_config.get(CONF_TYPE) 160 | if device_type: 161 | _LOGGER.debug('Entity [%s] is forcefully exposed as `%s`' % (state.entity_id, device_type)) 162 | else: 163 | device_type = determine_state_type(self.hass, state, entity_config) 164 | 165 | device = { 166 | 'id': state.entity_id, 167 | 'name': name, 168 | 'type': device_type, 169 | 'capabilities': [], 170 | 'properties': [], 171 | } 172 | 173 | for cpb in capabilities: 174 | description = cpb.description() 175 | if description not in device['capabilities']: 176 | device['capabilities'].append(description) 177 | 178 | for ppt in properties: 179 | description = ppt.description() 180 | if description not in device['properties']: 181 | device['properties'].append(description) 182 | 183 | room = entity_config.get(CONF_ROOM) 184 | if room: 185 | device['room'] = room 186 | 187 | device_info_attributes = ['manufacturer', 'model', 'sw_version', 'hw_version'] 188 | device_info = {} 189 | for attr in device_info_attributes: 190 | value = state.attributes.get(attr) 191 | if value: 192 | device_info[attr] = value 193 | 194 | if device_info: 195 | device['device_info'] = device_info 196 | 197 | dev_reg, ent_reg = await gather( 198 | self.hass.helpers.device_registry.async_get_registry(), 199 | self.hass.helpers.entity_registry.async_get_registry(), 200 | ) 201 | 202 | entity_entry = ent_reg.async_get(state.entity_id) 203 | if not (entity_entry and entity_entry.device_id): 204 | return device 205 | 206 | device_entry = dev_reg.devices.get(entity_entry.device_id) # type: DeviceEntry 207 | 208 | if not device_entry: 209 | return device 210 | 211 | for attr in device_info_attributes: 212 | # Device info overrides entity attributes 213 | # This may change in the future 214 | value = getattr(device_entry, attr) if hasattr(device_entry, attr) else None 215 | if value: 216 | device_info[attr] = value 217 | 218 | if device_info: 219 | device['device_info'] = device_info 220 | 221 | if 'room' not in device and device_entry.area_id: 222 | area_reg = await self.hass.helpers.area_registry.async_get_registry() 223 | 224 | area_entry = area_reg.areas.get(device_entry.area_id) 225 | if area_entry and area_entry.name: 226 | device['room'] = area_entry.name 227 | 228 | return device 229 | 230 | @callback 231 | def query_serialize(self): 232 | """Serialize entity for a query response. 233 | 234 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-devices-query-docpage/ 235 | """ 236 | state = self.state 237 | 238 | if state.state == STATE_UNAVAILABLE: 239 | return {'error_code': ERR_DEVICE_UNREACHABLE} 240 | 241 | capabilities = [] 242 | 243 | for cpb in self.capabilities(): 244 | if cpb.retrievable: 245 | capabilities.append(cpb.get_state()) 246 | 247 | properties = [] 248 | for ppt in self.properties(): 249 | properties.append(ppt.get_state()) 250 | 251 | return { 252 | 'id': state.entity_id, 253 | 'capabilities': capabilities, 254 | 'properties': properties, 255 | } 256 | 257 | async def execute(self, data: RequestData, capability_type, state): 258 | """Execute action. 259 | 260 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/reference/post-action-docpage/ 261 | """ 262 | executed = False 263 | if state is None or 'instance' not in state: 264 | raise SmartHomeError( 265 | ERR_INVALID_VALUE, 266 | "Invalid request: no 'instance' field in state %s / %s" 267 | % (capability_type, self.state.entity_id) 268 | ) 269 | 270 | instance = state['instance'] 271 | for cpb in self.capabilities(): 272 | if capability_type == cpb.type and instance == cpb.instance: 273 | await cpb.set_state(data, state) 274 | executed = True 275 | break 276 | 277 | if not executed: 278 | raise SmartHomeError( 279 | ERR_NOT_SUPPORTED_IN_CURRENT_MODE, 280 | "Unable to execute %s / %s for %s" 281 | % (capability_type, instance, self.state.entity_id) 282 | ) 283 | 284 | @callback 285 | def async_update(self): 286 | """Update the entity with latest info from Home Assistant.""" 287 | self.state = self.hass.states.get(self.entity_id) 288 | 289 | if self._capabilities: 290 | for trt in self._capabilities: 291 | trt.state = self.state 292 | 293 | if self._properties: 294 | for prp in self._properties: 295 | prp.state = self.state 296 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for Actions on Yandex Smart Home.""" 2 | __all__ = [ 3 | 4 | ] 5 | 6 | import logging 7 | from ipaddress import IPv4Network, IPv6Network, collapse_addresses 8 | from typing import Any, Dict, TYPE_CHECKING, Union, Sequence, List 9 | 10 | import voluptuous as vol 11 | from homeassistant import config_entries 12 | from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN 13 | from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT 14 | from homeassistant.const import CONF_NAME, CONF_ENTITY_ID, CONF_MAXIMUM, CONF_MINIMUM 15 | from homeassistant.core import callback 16 | from homeassistant.helpers import config_validation as cv 17 | from homeassistant.helpers import entityfilter as ef 18 | from homeassistant.helpers.typing import HomeAssistantType, ConfigType 19 | from homeassistant.loader import bind_hass 20 | 21 | from .const import ( 22 | DOMAIN, CONF_ENTITY_CONFIG, CONF_FILTER, CONF_ROOM, CONF_TYPE, 23 | CONF_ENTITY_PROPERTIES, CONF_ATTRIBUTE, CONF_ENTITY_PROPERTY_TYPE, 24 | CONF_CHANNEL_SET_VIA_MEDIA_CONTENT_ID, CONF_RELATIVE_VOLUME_ONLY, 25 | CONF_INPUT_SOURCES, CONF_ENTITY_TOGGLES, 26 | CONF_SCRIPT_CHANNEL_UP, CONF_SCRIPT_CHANNEL_DOWN, 27 | ATTR_LAST_ACTION_TARGETS, ATTR_LAST_ACTION_TIME, 28 | ATTR_LAST_SYNC_TIME, DATA_CONFIG, 29 | CONF_DIAGNOSTICS_MODE, CONF_ENTITY_MODES, CONF_MAPPING, CONF_SET_SCRIPT, CONF_PROGRAMS, CONF_MULTIPLIER, 30 | CONF_ENTITY_RANGES, CONF_PRECISION, MODES_NUMERIC 31 | ) 32 | from .core.helpers import Config, get_child_instances 33 | from .core.http import YandexSmartHomeUnauthorizedView, YandexSmartHomeView 34 | from .core.type_mapper import DOMAIN_TO_YANDEX_TYPES 35 | from .functions.capability import CAPABILITIES, CAPABILITIES_TOGGLE, CAPABILITIES_MODE, CAPABILITIES_RANGE 36 | from .functions.prop import PROPERTIES 37 | 38 | if TYPE_CHECKING: 39 | # noinspection PyProtectedMember 40 | from .functions.capability import _ModeCapability 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | PROPERTY_INSTANCE_SCHEMA = vol.In(get_child_instances(PROPERTIES)) 45 | TOGGLE_INSTANCE_SCHEMA = vol.In(get_child_instances(CAPABILITIES, CAPABILITIES_TOGGLE)) 46 | MODE_INSTANCE_SCHEMA = vol.In(get_child_instances(CAPABILITIES, CAPABILITIES_MODE)) 47 | RANGE_INSTANCE_SCHEMA = vol.In(get_child_instances(CAPABILITIES, CAPABILITIES_RANGE)) 48 | 49 | ENTITY_PROPERTY_SCHEMA = vol.Any( 50 | vol.All(cv.entity_id, lambda x: {CONF_ENTITY_ID: x}), 51 | vol.Schema( 52 | { 53 | vol.Optional(CONF_ENTITY_ID): cv.entity_id, 54 | vol.Optional(CONF_ATTRIBUTE): cv.string, 55 | } 56 | ) 57 | ) 58 | 59 | 60 | def check_mode_override_mappings(value: Dict[str, Any]): 61 | for instance, config in value.items(): 62 | if CONF_MAPPING not in config: 63 | continue 64 | 65 | capability: '_ModeCapability' 66 | for capability in CAPABILITIES: 67 | _LOGGER.debug('Check on %s with %s' % (capability.instance, instance)) 68 | if capability.instance == instance: 69 | invalid_keys = config[CONF_MAPPING].keys() - set(capability.internal_modes) 70 | if invalid_keys: 71 | raise vol.Invalid('Invalid Yandex modes for overrides: %s' % ', '.join(invalid_keys), 72 | path=[instance, CONF_MAPPING]) 73 | break 74 | 75 | return value 76 | 77 | 78 | NUMERIC_MODE_VALIDATOR = vol.In(MODES_NUMERIC) 79 | NUMERIC_MODE_SCHEMA = vol.Any( 80 | cv.boolean, 81 | {NUMERIC_MODE_VALIDATOR: cv.string}, 82 | vol.All([NUMERIC_MODE_VALIDATOR], vol.Length(min=2, max=10)) 83 | ) 84 | PROPERTY_OVERRIDES_SCHEMA = vol.All({PROPERTY_INSTANCE_SCHEMA: ENTITY_PROPERTY_SCHEMA}) 85 | TOGGLE_OVERRIDES_SCHEMA = vol.All({TOGGLE_INSTANCE_SCHEMA: cv.entity_id}) 86 | MODE_OVERRIDES_SCHEMA = vol.All( 87 | { 88 | MODE_INSTANCE_SCHEMA: vol.Schema({ 89 | vol.Required(CONF_ENTITY_ID): cv.entity_id, 90 | vol.Required(CONF_SET_SCRIPT): cv.SCRIPT_SCHEMA, 91 | vol.Optional(CONF_MAPPING): {cv.string: vol.All(cv.ensure_list, [cv.string])}, 92 | }) 93 | }, 94 | check_mode_override_mappings 95 | ) 96 | 97 | 98 | def check_range_overrides(value: Dict[str, Any]): 99 | for instance, config in value.items(): 100 | if config[CONF_MAXIMUM] <= config[CONF_MINIMUM]: 101 | raise vol.Invalid('Difference between min and max must be greater than 0', 102 | path=[instance, CONF_MAXIMUM]) 103 | 104 | if config[CONF_MULTIPLIER] == 0: 105 | raise vol.Invalid('Multiplier must be greater than 0', 106 | path=[instance, CONF_MULTIPLIER]) 107 | 108 | return value 109 | 110 | 111 | RANGE_OVERRIDES_SCHEMA = vol.All( 112 | { 113 | RANGE_INSTANCE_SCHEMA: vol.Schema({ 114 | vol.Required(CONF_ENTITY_ID): cv.entity_id, 115 | vol.Required(CONF_SET_SCRIPT): cv.SCRIPT_SCHEMA, 116 | vol.Optional(CONF_MINIMUM, default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), 117 | vol.Optional(CONF_MAXIMUM, default=100): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), 118 | vol.Optional(CONF_PRECISION, default=1): vol.All(vol.Coerce(int), vol.Range(min=1, max=100)), 119 | vol.Optional(CONF_MULTIPLIER, default=1): cv.small_float, 120 | }) 121 | }, 122 | check_range_overrides 123 | ) 124 | 125 | ENTITY_SCHEMA = vol.Schema( 126 | { 127 | # Entity options 128 | vol.Optional(CONF_NAME): cv.string, 129 | vol.Optional(CONF_ROOM): cv.string, 130 | vol.Optional(CONF_TYPE): cv.string, 131 | 132 | # Additional options 133 | vol.Optional(CONF_CHANNEL_SET_VIA_MEDIA_CONTENT_ID): cv.boolean, 134 | vol.Optional(CONF_RELATIVE_VOLUME_ONLY): cv.boolean, 135 | vol.Optional(CONF_SCRIPT_CHANNEL_UP): cv.SCRIPT_SCHEMA, 136 | vol.Optional(CONF_SCRIPT_CHANNEL_DOWN): cv.SCRIPT_SCHEMA, 137 | 138 | # Numeric mode capabilities 139 | # (True - enable automatic, False - disable, dictionary - custom modes) 140 | vol.Optional(CONF_INPUT_SOURCES): NUMERIC_MODE_SCHEMA, 141 | vol.Optional(CONF_PROGRAMS): NUMERIC_MODE_SCHEMA, 142 | 143 | # Overrides 144 | vol.Optional(CONF_ENTITY_PROPERTIES, default={}): PROPERTY_OVERRIDES_SCHEMA, 145 | vol.Optional(CONF_ENTITY_TOGGLES, default={}): TOGGLE_OVERRIDES_SCHEMA, 146 | vol.Optional(CONF_ENTITY_MODES, default={}): MODE_OVERRIDES_SCHEMA, 147 | vol.Optional(CONF_ENTITY_RANGES, default={}): RANGE_OVERRIDES_SCHEMA, 148 | } 149 | ) 150 | 151 | 152 | def validate_networks(value: Union[bool, Sequence[str]]) -> Union[bool, List[Union[IPv6Network, IPv4Network]]]: 153 | if value is True: 154 | return [IPv4Network('0.0.0.0/0'), IPv6Network('::/0')] 155 | 156 | if not value: 157 | return [] 158 | 159 | converted_networks_ipv4 = [] 160 | converted_networks_ipv6 = [] 161 | for i, network in enumerate(value): 162 | try: 163 | if ':' in value: 164 | converted_networks_ipv6.append(IPv6Network(network)) 165 | else: 166 | converted_networks_ipv4.append(IPv4Network(network)) 167 | except ValueError: 168 | raise vol.Invalid("invalid network provided", path=[i]) 169 | 170 | if not converted_networks_ipv6: 171 | return list(collapse_addresses(converted_networks_ipv4)) 172 | elif not converted_networks_ipv4: 173 | return list(collapse_addresses(converted_networks_ipv6)) 174 | return [*collapse_addresses(converted_networks_ipv4), *collapse_addresses(converted_networks_ipv6)] 175 | 176 | 177 | YANDEX_SMART_HOME_SCHEMA = vol.Schema( 178 | { 179 | vol.Optional(CONF_FILTER, default={}): ef.FILTER_SCHEMA, 180 | vol.Optional(CONF_ENTITY_CONFIG, default={}): {cv.entity_id: ENTITY_SCHEMA}, 181 | vol.Optional(CONF_DIAGNOSTICS_MODE, default=False): 182 | vol.All(vol.Any(cv.boolean, vol.All(cv.ensure_list, [cv.string])), validate_networks), 183 | } 184 | ) 185 | 186 | CONFIG_SCHEMA = vol.Schema( 187 | { 188 | DOMAIN: YANDEX_SMART_HOME_SCHEMA, 189 | }, 190 | extra=vol.ALLOW_EXTRA 191 | ) 192 | 193 | 194 | @callback 195 | @bind_hass 196 | def _register_views(hass: HomeAssistantType): 197 | # Register Yandex HTTP handlers 198 | _LOGGER.debug("Adding HomeAssistant Yandex views") 199 | hass.http.register_view(YandexSmartHomeUnauthorizedView) 200 | hass.http.register_view(YandexSmartHomeView) 201 | 202 | 203 | async def async_setup(hass: HomeAssistantType, config: ConfigType): 204 | """Activate Yandex Smart Home component.""" 205 | 206 | _register_views(hass) 207 | 208 | if DOMAIN not in config: 209 | return True 210 | 211 | # Save YAML config to data to use it later 212 | hass.data[DATA_CONFIG] = config[DOMAIN] 213 | 214 | # Find existing entry 215 | existing_entries = hass.config_entries.async_entries(DOMAIN) 216 | if existing_entries: 217 | if existing_entries[0].source == config_entries.SOURCE_IMPORT: 218 | _LOGGER.debug('Skipping existing import binding') 219 | else: 220 | _LOGGER.warning('YAML config is overridden by another config entry!') 221 | return True 222 | 223 | # Forward configuration setup to config flow 224 | hass.async_create_task( 225 | hass.config_entries.flow.async_init( 226 | DOMAIN, 227 | context={"source": SOURCE_IMPORT}, 228 | data={} 229 | ) 230 | ) 231 | 232 | return True 233 | 234 | 235 | async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry): 236 | yandex_cfg = config_entry.data 237 | 238 | if config_entry.source == config_entries.SOURCE_IMPORT: 239 | yandex_cfg = hass.data.get(DATA_CONFIG) 240 | if not yandex_cfg: 241 | _LOGGER.info('Removing entry %s after removal from YAML configuration.' % config_entry.entry_id) 242 | hass.async_create_task( 243 | hass.config_entries.async_remove(config_entry.entry_id) 244 | ) 245 | return False 246 | 247 | else: 248 | yandex_cfg = YANDEX_SMART_HOME_SCHEMA(dict(yandex_cfg)) 249 | 250 | # Check for diagnostics mode 251 | diagnostics_mode = yandex_cfg.get(CONF_DIAGNOSTICS_MODE) 252 | 253 | if diagnostics_mode: 254 | from homeassistant.components.persistent_notification import async_create as create_notification 255 | from custom_components.yandex_smart_home.core.http import YandexSmartHomeView 256 | 257 | warning_text = "Diagnostics mode is enabled. Your Yandex Smart home setup may become vulnerable to external " \ 258 | "unauthorized requests. Please, use with caution. Unauthorized requests from the following" \ 259 | " networks are currently allowed: %s" % (', '.join(map(str, diagnostics_mode))) 260 | 261 | contents_links = "Links (will open in a new tab):" 262 | 263 | for url in [YandexSmartHomeView.url] + YandexSmartHomeView.extra_urls: 264 | target_url = url 265 | contents_links += '\n- %s' % (target_url, url) 266 | 267 | contents_links += ( 268 | '\n\nJSON Formatter extension for chromium-based browsers: ' 269 | '' 270 | 'Chrome Web Store, GitHub' 271 | ) 272 | 273 | create_notification( 274 | hass, 275 | warning_text + "\n\n" + contents_links, 276 | "Yandex Smart Home Diagnostics Mode", 277 | "yandex_smart_home_diagnostics_mode" 278 | ) 279 | 280 | _LOGGER.warning(warning_text) 281 | 282 | # Create configuration object (and thus enable HTTP request serving) 283 | hass.data[DOMAIN] = Config( 284 | should_expose=yandex_cfg[CONF_FILTER], 285 | entity_config=yandex_cfg[CONF_ENTITY_CONFIG], 286 | diagnostics_mode=diagnostics_mode 287 | ) 288 | 289 | # Create Yandex request statistics sensor 290 | hass.async_create_task( 291 | hass.config_entries.async_forward_entry_setup( 292 | config_entry, 293 | SENSOR_DOMAIN 294 | ) 295 | ) 296 | 297 | return True 298 | 299 | 300 | async def async_unload_entry(hass, config_entry): 301 | """Unload a config entry.""" 302 | 303 | # Remove Yandex request statistics sensor 304 | await hass.config_entries.async_forward_entry_unload(config_entry, SENSOR_DOMAIN) 305 | 306 | # Remove configuration object (and thus disable HTTP request serving) 307 | hass.data.pop(DOMAIN) 308 | 309 | return True 310 | -------------------------------------------------------------------------------- /README.old.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 2 | 3 | [![Donate](https://img.shields.io/badge/-Donate-purple.svg)](https://money.yandex.ru/to/41001142896898) 4 | 5 | ## Yandex Smart Home custom component for Home Assistant 6 | 7 | 8 | ### Installation 9 | 1. Update home assistant to 0.96.0 at least 10 | 1. Configure SSL certificate if it was not done already (***do not*** use self-signed certificate) 11 | 1. Create dialog via https://dialogs.yandex.ru/developer/ (see [this section](#create-dialog)) 12 | 1. Install this component from HACS. That way you get updates automatically. But you also can just copy and add files into custom_components directory manually instead. 13 | 1. Restart Home Assistant 14 | 1. Follow the example `configuration.yaml` entry below to add integration. 15 | 1. It is also possible to enable this integration via `Settings` => `Integrations` menu within _HomeAssistant_. Search for _Yandex Smart Home_ and follow the activation wizard. Be aware that there are limitations to this method (such as current lack of per-entity configuration). 16 | 1. Add devices via your Yandex app on Android/iOS (or in _Testing_ mode). 17 | 18 | 19 | ### Example configuration 20 | ```yaml 21 | # Example configuration.yaml entry 22 | yandex_smart_home: 23 | filter: 24 | include_domains: 25 | - switch 26 | - light 27 | include_entities: 28 | - media_player.tv 29 | - media_player.tv_lg 30 | exclude_entities: 31 | - light.highlight 32 | entity_config: 33 | switch.kitchen: 34 | name: CUSTOM_NAME_FOR_YANDEX_SMART_HOME 35 | light.living_room: 36 | room: LIVING_ROOM 37 | toggles: 38 | backlight: light.wall_ornament 39 | media_player.tv_lg: 40 | channel_set_via_media_content_id: true 41 | sources: 42 | one: "HDMI 1" 43 | two: "HDMI 2" 44 | three: "Composite" 45 | four: "Netflix App" 46 | toggles: 47 | controls_locked: switch.custom_webostv_controls_lock 48 | backlight: switch.raspberry_pi_ambilight 49 | properties: 50 | power: 51 | entity: sensor.global_power_monitor 52 | attribute: television_socket 53 | fan.xiaomi_miio_device: 54 | name: "Xiaomi Humidifier" 55 | room: LIVING_ROOM 56 | type: devices.types.humidifier 57 | properties: 58 | temperature: sensor.temperature_123d45678910 59 | humidity: 60 | attribute: humidity 61 | water_level: 62 | attribute: depth 63 | ``` 64 | 65 | 66 | ### Variable description 67 | ``` 68 | yandex_smart_home: 69 | (map) (Optional) Configuration options for the Yandex Smart Home integration. 70 | 71 | filter: 72 | (map) (Optional) description: Filters for entities to include/exclude from Yandex Smart Home. 73 | include_entities: 74 | (list) (Optional) description: Entity IDs to include. 75 | include_domains: 76 | (list) (Optional) Domains to include. 77 | exclude_entities: 78 | (list) (Optional) Entity IDs to exclude. 79 | exclude_domains: 80 | (list) (Optional) Domains to exclude. 81 | 82 | entity_config: 83 | (map) (Optional) Entity specific configuration for Yandex Smart Home. 84 | ENTITY_ID: 85 | (map) (Optional) Entity to configure. 86 | name: 87 | (string) (Optional) Name of entity to show in Yandex Smart Home. 88 | room: 89 | (string) (Optional) Associating this device to a room in Yandex Smart Home 90 | type: 91 | (string) (Optional) Allows to force set device type. For exmaple set devices.types.purifier to display device as purifier (instead default devices.types.humidifier for such devices) 92 | channel_set_via_media_content_id: 93 | (boolean) (Optional) (media_player only) Enables ability to set channel by number for some TVs 94 | (TVs that support channel change via passing number as media_content_id) 95 | relative_volume_only: 96 | (boolean) (Optional) (media_player only) Force disable ability to get/set volume by number 97 | sources: 98 | (dict, boolean) (Optional) (media_player only) Define selectable inputs (or map one-to-one in case of 'true'). 99 | one / two / three / ... / ten: 100 | (string) (Optional) Source name <=> Input source mapping. 101 | backlight: 102 | (string) (Optional) Entity ID to use as backlight control (must be toggleable). 103 | channel_up: 104 | (map) (Optional) Script to switch to next channel (avoids using next track). 105 | channel_down: 106 | (map) (Optional) Script to switch to previous channel (avoids using previous track). 107 | toggles: 108 | (dict) (Optional) Assign togglable entities for certain features or override auto-detected ones. 109 | backlight / controls_locked ...: 110 | (entity ID) Entity ID to be used with the toggle 111 | properties: 112 | (dict) (Optional) Assign entities or attributes for certain properties or override auto-detected ones. 113 | humidity / temperature / water_level / co2_level / power / voltage: 114 | (dict / entity ID) Configuration data for property (only entity ID can be specified instead of dictionary, if using other entities). 115 | entity: 116 | (string) (Optional) Custom entity, any sensor can be added 117 | attribute: 118 | (string) (Optional) Attribute of an object to receive data 119 | 120 | diagnostics_mode: 121 | (boolean) (Optional) Enable diagnostics mode (Unauthorized requests will be allowed; never use this option in production environment!) 122 | ``` 123 | 124 | 125 | ### Overriding exposed entity domain — `type` option 126 | When exposing device under a domain different from default confirm compatibility by consulting the 127 | [Yandex API documentation](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-types-docpage/) by 128 | comparing sets of capabilities expected from default and target domains. Very common custom exposure would 129 | be rendering a `switch` entity (example above) as a `socket`. 130 | 131 | Alternative way to override device type is to use `yandex_type` entity attribute. Enable expert configuration mode 132 | in HomeAssistant and open `Customize` menu. From there you will be able to add a custom attribute. The preference, 133 | however, will be given to the option provided in the addon configuration; therefore, if you have custom type 134 | specified using both methods above, the former will be provided to Yandex. 135 | 136 | ### Room/Area support — `room` option 137 | Entities that have not got rooms explicitly set and that have been placed in Home Assistant areas will return 138 | room hints to Yandex Smart Home with the devices in those areas. You can always override these manually 139 | by specified a `room` option in corresponding `entity_config` entries. 140 | 141 | ### Create dialog 142 | Go to https://dialogs.yandex.ru/developer/ and create smart home skill. 143 | 144 | Field | Value 145 | ------------ | ------------- 146 | Endpoint URL | https://[YOUR HOME ASSISTANT URL:PORT]/api/yandex_smart_home 147 | 148 | For account linking use button at the bottom of skill settings page, fill it 149 | using values like below: 150 | 151 | Field | Value 152 | ------------ | ------------- 153 | Client identifier | https://social.yandex.net/ 154 | API authorization endpoint | https://[YOUR HOME ASSISTANT URL:PORT]/auth/authorize 155 | Token Endpoint | https://[YOUR HOME ASSISTANT URL:PORT]/auth/token 156 | Refreshing an Access Token | https://[YOUR HOME ASSISTANT URL:PORT]/auth/token 157 | 158 | ### Diagnostics mode 159 | Diagnostics mode can only be enabled using YAML configuration by adding `diagnostics_mode: true` to `yandex_smart_home` domain configuration. 160 | This mode allows unauthorized requests to be processed. This is technically an alternative to requesting devices via Yandex' own testing 161 | interface, however it also allows to craft custom requests offline. **DO NOT ENABLE THIS MODE IN PRODUCTION ENVIRONMENT!** 162 | 163 | Upon enabling, a persistent notification will appear with a list of supported URLs. All of the provided URLs will open in a new tab. 164 | Using a JSON-formatting extension is recommended. 165 | 166 | ### Supported HomeAssistant domains 167 | _This is a work-in-progress summary, there are more features to mention_ 168 | - [x] Automations: `automation` 169 | - [ ] Matching domain exposure 170 | - [x] Capabilities: 171 | - [x] Turn on/off: `on_off` 172 | 173 | - [ ] Binary sensors: `binary_sensor` 174 | - [ ] Matching domain exposure _(no suitable mapping exists yet)_ 175 | 176 | - [x] Cameras: `camera` 177 | - [ ] Matching domain exposure 178 | - [x] Capabilities: 179 | - [x] Turn on/off: `on_off` 180 | 181 | - [x] Climate management: `climate` 182 | - [x] Matching domain exposure: 183 | - [x] Default exposure: `thermostat` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-type-thermostat-docpage/)) 184 | - [x] AC with support for swing: `thermostat.ac` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-type-thermostat-ac-docpage/)) 185 | - [x] Capabilities: 186 | - [x] Turn on/off: `on_off` 187 | - [x] Fan speed setting: `mode.fan_speed` 188 | - [x] Swing mode setting: `mode.swing` 189 | - [x] Preset modes: `mode.program` 190 | - [x] Temperature: `range.temperature` 191 | - [x] Humidity: `range.humidity` 192 | - [x] Properties: 193 | - [x] Current temperature: `float.temperature` 194 | - [x] Current humidity: `float.humidity` 195 | 196 | - [x] Covers: `cover` 197 | - [x] Matching domain exposure: 198 | - [x] Default exposure: `openable` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-type-openable-docpage/)) 199 | - [x] Curtains, blinds, windows, awnings: `openable.curtain` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-type-openable-docpage/)) 200 | - [x] Capabilities: 201 | - [x] Toggle open/close: `on_off` 202 | - [x] Set semi-open state: `range.open` 203 | - [ ] Set tilt position: _(no suitable mapping exists yet)_ 204 | 205 | - [x] Fans: `fan` 206 | - [x] Matching domain exposure: 207 | - [x] Default exposure: `devices.types.thermostat.ac` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-type-thermostat-ac-docpage/)) 208 | - [x] Capabilities 209 | - [x] Turn on/off: `on_off` 210 | - [x] Fan speed setting: `mode.fan_speed` 211 | - [ ] Direction setting: _(no suitable mapping exists yet)_ 212 | 213 | - [x] Lights: `light` 214 | - [x] Matching domain exposure: 215 | - [x] Default exposure: `devices.types.light` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-type-light-docpage/)) 216 | - [x] Capabilities 217 | - [x] Turn on/off: `on_off` 218 | - [x] Color setting via RGB: `color_setting.rgb` 219 | - [ ] Color setting via HSV: `color_setting.hsv` _(unknown whether support is required)_ 220 | - [x] Brightness capability: `range.brightness` 221 | - [x] Backlight toggle: `toggle.backlight` 222 | - [x] Effect switching: `mode.program` 223 | 224 | - [x] Switches: `switch` 225 | - [x] Matching domain exposure: 226 | - [x] Default exposure: `devices.types.switch` 227 | - [x] Switches with `socket` as device class: `devices.types.socket` 228 | - [x] Capabilities: 229 | - [x] Turn on/off: `on_off` 230 | - [x] Properties: 231 | - [x] Current power: `float.power` 232 | 233 | - [x] Water heaters: `water_heater` 234 | - [x] Matching domain exposure: 235 | - [x] Default exposure: `devices.types.cooking.kettle` 236 | - [x] Capabilities: 237 | - [x] Turn on/off: `on_off` 238 | 239 | - [x] Media players: `media_player` 240 | - [x] Matching domain exposure: 241 | - [x] Default exposure: `devices.types.media_device` 242 | - [x] Televisions: `devices.types.media_device.tv` 243 | - [x] Android TV boxes: `devices.types.media_device.tv_box` 244 | - [x] Capabilities: 245 | - [x] Input sources: `mode.input_source` 246 | - [x] Set/increase/decrease channel: `range.channel` 247 | - [x] Set/increase/decrease volume: `range.volume` 248 | - [x] Mute/unmute: `toggle.mute` 249 | - [x] Pause/unpause: `toggle.pause` 250 | 251 | - [ ] Vacuum cleaners: 252 | - [x] Matching domain exposure: 253 | - [x] Default exposure: `devices.types.vacuum_cleaner` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-type-vacuum-cleaner-docpage/)) 254 | - [ ] Capabilities _(work in progress)_ 255 | 256 | 257 | ### Capabilities 258 | - [x] On / Off: `on_off` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/on_off-docpage/)) 259 | - [ ] Color setting: `color_setting` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/color_setting-docpage/)) 260 | - [x] Temperature object: `temperature_k` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/color_setting-docpage/#discovery__discovery-parameters-color-setting-table__entry__17)) 261 | - [x] RGB palette: `color_model` -> `rgb` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/color_setting-docpage/#discovery__discovery-parameters-color-setting-table__entry__17)) 262 | - [ ] HSV palette: `color_model` -> `hsv` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/color_setting-docpage/#discovery__discovery-parameters-color-setting-table__entry__17)) 263 | - [ ] Operation Mode: `mode` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-docpage/)) 264 | - [x] Retrievable flag ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-docpage/#discovery__discovery-parameters-mode-table)) 265 | - [ ] Cleanup mode function: `cleanup_mode` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/#mode-instance__cleanup_mode)) 266 | - [ ] Coffee mode function: `coffee_mode` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/#mode-instance__coffee_mode)) 267 | - [x] Fan speed function: `fan_speed` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/#mode-instance__fan_speed)) 268 | - [x] Input source function: `input_source` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/#mode-instance__input_source)) 269 | - [x] Program function: `program` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/#mode-instance__program)) 270 | - [x] Swing function: `swing` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/#mode-instance__swing)) 271 | - [x] Thermostat function: `thermostat` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/#mode-instance__thermostat)) 272 | - [ ] Work speed function: `work_speed` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/#mode-instance__work_speed)) 273 | - [x] Range of values: `range` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-docpage/)) 274 | - [x] Brightness function: `brightness` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-instance-docpage/#range-instance__brightness)) 275 | - [x] Channel function: `channel` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-instance-docpage/#range-instance__channel)) 276 | - [x] Humidity function: `humidity` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-instance-docpage/#range-instance__humidity)) 277 | - [x] Temperature function: `temperature` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-instance-docpage/#range-instance__temperature)) 278 | - [x] Volume function: `volume` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-instance-docpage/#range-instance__volume))" 279 | - [x] Toggle: `toggle` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-docpage/)) 280 | - [x] Backlight function: `backlight` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-instance-docpage/#toggle-instance__backlight)) 281 | - [x] Controls locked function: `controls_locked` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-instance-docpage/#toggle-instance__controls_locked)) 282 | - [x] Ionization function: `ionization` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-instance-docpage/#toggle-instance__ionization)) 283 | - [x] Keep warm function: `keep_warm` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-instance-docpage/#toggle-instance__keep_warm)) 284 | - [x] Mute function: `mute` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-instance-docpage/#toggle-instance__mute)) 285 | - [x] Oscillation function: `oscillation` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-instance-docpage/#toggle-instance__oscillation)) 286 | - [x] Pause function: `pause` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-instance-docpage/#toggle-instance__pause))" 287 | 288 | 289 | ### Properties 290 | - [x] Float: `float` 291 | - [x] Amperage: `amperage` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/float-instance-docpage/#float-instance__amperage)) 292 | - [x] Sensors using `A` as their unit type 293 | - [x] CO2 level: `co2_level` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/float-instance-docpage/#float-instance__co2_level)) 294 | - [x] Air quality monitoring entities (`air_quality`) 295 | - [x] Humidity: `humidity` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/float-instance-docpage/#float-instance__humidity)) 296 | - [x] Sensors with `humidity` device class 297 | - [x] Climate entities with `current_humidity` attribute 298 | - [x] Power: `power` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/float-instance-docpage/#float-instance__power)) 299 | - [x] Sensors using `W` and `kW` as their unit type 300 | - [x] Temperature: `temperature` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/float-instance-docpage/#float-instance__temperature)) 301 | - [x] Sensors with `temperature` device class 302 | - [x] Climate entities with `current_temperature` attribute 303 | - [x] Voltage: `voltage` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/float-instance-docpage/#float-instance__voltage)) 304 | - [x] Sensors using `kV`, `mV` and `MV` as their unit type 305 | - [x] Water level: `water_level` ([docs](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/float-instance-docpage/#float-instance__water_level)) 306 | - [x] Entities with `water_level` attribute 307 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # _Yandex Smart Home_ for HomeAssistant 2 | [![GitHub Page](https://img.shields.io/badge/GitHub-alryaz%2Fhass--component--yandex--smart--home-blue)](https://github.com/alryaz/hass-component-yandex-smart-home) 3 | [![Donate Yandex](https://img.shields.io/badge/Donate-Yandex-red.svg)](https://money.yandex.ru/to/410012369233217) 4 | [![Donate PayPal](https://img.shields.io/badge/Donate-Paypal-blueviolet.svg)](https://www.paypal.me/alryaz) 5 | {% set mainline_num_ver = version_available.replace("v", "").replace(".", "") | int %}{%- set features = { 6 | 'v2.0.0': 'Стандартный релиз; переопределения умений и свойств; настройка на русском', 7 | }-%}{%- set breaking_changes = namespace(header=True, changes={}) -%}{%- set bug_fixes = namespace(header=True, changes={ 8 | 'v2.0.2': ['Fixed `input_source` capability on default setting'], 9 | 'v2.0.3': ['Исправлены свойства режимов работы'], 10 | }) -%} 11 | 12 | {% if installed %}{% if version_installed == "master" %} 13 | #### ⚠ Вы используете версию для разработчиков 14 | Эта ветка может оказаться нестабильной, так как содержит изменения, которые не всегда протестированы. 15 | Пожалуйста, не используйте данную ветку для развёртывания в боевой среде. 16 | {% else %}{% set num_ver = version_installed.replace("v", "").replace(".","") | int %}{% if version_installed == version_available %} 17 | #### ✔ Вы используете последнюю версию{% else %} 18 | #### 🚨 Вы используете устаревшую версию{% if num_ver < 20 %} 19 | 20 | {% for ver, changes in breaking_changes.changes.items() %}{% set ver = ver.replace("v", "").replace(".","") | int %}{% if num_ver < ver %}{% if breaking_changes.header %} 21 | ##### Критические изменения (`{{ version_installed }}` -> `{{ version_available }}`){% set breaking_changes.header = False %}{% endif %}{% for change in changes %} 22 | {{ '- '+change }}{% endfor %}{% endif %}{% endfor %} 23 | {% endif %}{% endif %} 24 | 25 | {% for ver, fixes in bug_fixes.changes.items() %}{% set ver = ver.replace("v", "").replace(".","") | int %}{% if num_ver < ver %}{% if bug_fixes.header %} 26 | ##### Исправления (`{{ version_installed }}` -> `{{ version_available }}`){% set bug_fixes.header = False %}{% endif %}{% for fix in fixes %} 27 | {{ '- ' + fix }}{% endfor %}{% endif %}{% endfor %} 28 | 29 | ## Возможности{% for ver, text in features.items() %}{% set feature_ver = ver.replace("v", "").replace(".", "") | int %} 30 | - {% if num_ver < feature_ver %}**{% endif %}`{{ ver }}` {% if num_ver < feature_ver %}NEW** {% endif %}{{ text }}{% endfor %} 31 | 32 | Пожалуйста, сообщайте об ошибках [в репозиторий GitHub](https://github.com/alryaz/hass-component-yandex-smart-home/issues). 33 | {% endif %}{% else %} 34 | ## Возможности{% for ver, text in features.items() %} 35 | - {{ text }} _(supported since `{{ ver }}`)_{% endfor %} 36 | {% endif %} 37 | 38 | ## Установка 39 | 1. Обновите HomeAssistant до версии 0.96.0 и выше 40 | 1. Настройте SSL для HomeAssistant (***do not*** use self-signed certificate) 41 | 1. Самоподписанные сертификаты **не подходят** 42 | 1. Для быстрой настройки советуется использовать [Let's Encrypt](https://www.home-assistant.io/blog/2015/12/13/setup-encryption-using-lets-encrypt/) 43 | 1. Создайте диалог в [разделе разработчика диалогов Яндекс](https://dialogs.yandex.ru/developer/) (см. [детальное описание этапа](#create_dialog)) 44 | 1. Установите данный компонент 45 | 1. Перезапустите HomeAssistant 46 | 1. Следуйте одной из инструкций по настройке компонента ниже 47 | 1. Обновите устройства в приложении _Яндекс_ для [Android](https://play.google.com/store/apps/details?id=ru.yandex.searchplugin&hl=ru) / iOS (или в [панели разработчика](https://dialogs.yandex.ru/developer/) в тестовом режиме). 48 | 1. **Не забывайте повторять последнюю операцию при добавлении новых устройств в HomeAssistant!** 49 | 50 | Для работоспособности интеграции требуется произвести её настройку в два этапа. Этап для HomeAssistant 51 | указан в данном разделе. Для перехода ко второму этапу, нажмите 52 | 53 | ## Настройка через меню `Интеграции` 54 | 1. Откройте `Настройки` -> `Интеграции` 55 | 1. Нажмите внизу справа страницы кнопку с плюсом 56 | 1. Введите в поле поиска `Yandex Smart Home` 57 | 1. Если по какой-то причине интеграция не была найдена, убедитесь, что HomeAssistant был перезапущен после 58 | установки интеграции. 59 | 1. Выберите первый результат из списка 60 | 1. Пройдите несколько этапов настройки следуя инструкциям на экране 61 | 1. Если при настройке не были допущены ошибки, интеграция будет добавлена. 62 | После Вы можете перейти ко [второму этапу](#create_dialog) настройки. 63 | 64 | ## Настройка через `configuration.yaml` 65 | 66 | ### Базовая конфигурация 67 | Указывается пустой новый раздел конфигруации под именем `yandex_smart_home`. 68 | В данном режиме **все поддерживаемые объекты** будут передаваться в Яндекс. 69 | ```yaml 70 | # configuration.yaml 71 | ... 72 | 73 | yandex_smart_home: 74 | ``` 75 | 76 | ### Фильтр объектов 77 | Чтобы выбрать объекты, которые следует передавать в Яндекс, задайте фильтр объектов. 78 | 79 | Фильтр задаётся через ключ `filter` конфигурации. 80 | Возможные атрибуты фильтра: 81 | - `include_domains` — разрешить объекты из указанных доменов 82 | - `exclude_domains` — запретить объекты из указанных доменов _(не должно пересекаться с предыдущей опцией)_ 83 | - `include_entities` — разрешить указанные объекты 84 | - `exclude_entities` — запретить указанные объекты 85 | 86 | #### Пример конфигурации: 87 | ```yaml 88 | yandex_smart_home: 89 | filter: 90 | # Разрешение доменов `media_player` и `switch` 91 | include_domains: ['media_player', 'switch'] 92 | 93 | # Исключение домена `automation` 94 | exclude_domains: automation 95 | 96 | # Разрешение объекта `light.living_room` 97 | include_entities: light.living_room 98 | 99 | # Исключение объектов `switch.door_one` и `switch.door_two` 100 | exclude_entities: 101 | - switch.door_one 102 | - switch.door_two 103 | ``` 104 | 105 | ### Расширенная конфигурация объектов 106 | Дополнительная настройка включает в себя возможность настраивать отдельно некоторые функции и свойства объектов. 107 | Для указания объектов и соответствующих им параметров, добавьте ключ `entity_config` в конфигурацию. 108 | 109 | #### Пример конфигурации 110 | ```yaml 111 | yandex_smart_home: 112 | entity_config: 113 | ... 114 | ``` 115 | ##### Объект любого домена 116 | ```yaml 117 | ... 118 | switch.kitchen: 119 | # Переопределение имени в Yandex 120 | # Определяется автоматически, по умолчанию значение атрибута `friendly_name` 121 | name: Кухонный выключатель 122 | 123 | # Переопределение комнаты в Yandex 124 | # Определяется автоматически, по умолчанию значение берётся из комнат в HomeAssistant 125 | room: Кухня 126 | 127 | # Переопределение типа устройства 128 | # Влияет в основном на иконку в приложении Яндекс и на интерфейс 129 | # взаимодействия; не влияет на автоматическое определение возможностей 130 | # Определяется автоматически, по умолчанию: devices.type.other 131 | type: devices.type.light 132 | ... 133 | ``` 134 | Список поддерживаемых типов для опции `type`: [Устройства - Технологии Яндекса](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-types-docpage/) 135 | ##### Объект домена `media_player` 136 | ```yaml 137 | ... 138 | media_player.kitchen_tv: 139 | # Установка канала через атрибут `media_content_id` 140 | # По умолчанию: false 141 | channel_set_via_media_content_id: true 142 | 143 | # Форсировать относительное изменение громкости 144 | # По умолчанию: false 145 | relative_volume_only: true 146 | 147 | # Указание соответствия источников значениям Яндекс 148 | # По умолчанию: генерируется массив соответствий первых 10 и менее источников 149 | # из атрибута `sources_list`. 150 | # Возможные ключи: one, two, three, four, five, six, seven, nine, ten 151 | sources: 152 | one: "HDMI 1" 153 | two: "HDMI 2" 154 | three: "Composite" 155 | four: "Netflix App" 156 | 157 | # Скрипт переключения на следующий канал 158 | # Для использования доступна переменная `entity_id`, совпадающая 159 | # с ключом объекта (в данном случае 160 | сhannel_up: 161 | service: custom_tv_component.next_channel 162 | data_template: 163 | entity_id: {{ entity_id }} 164 | 165 | # Аналогичный скрипт для переключения на предыдущий канал 166 | channel_down: 167 | service: custom_tv_component.prev_channel 168 | data_template: 169 | {{ entity_id }} 170 | ... 171 | ``` 172 | ##### Объект домена `light` 173 | ```yaml 174 | ... 175 | light.rgb_controller: 176 | # (для объектов с эффектами) Указание соответствия програм значениям Яндекс 177 | # По умолчанию: генерируется массив соответствий первых 10 и менее эффектов 178 | # из атрибута `effects_list`. 179 | # Возможные ключи: one, two, three, four, five, six, seven, nine, ten 180 | programs: 181 | one: 0 182 | two: 1 183 | three: 2 184 | four: 3 185 | ``` 186 | 187 | ### Расширенная конфигурация: _Переключатели (Capability ⇨ Toggle)_ 188 | Компонент поддерживает все переключатели Yandex на момент последнего обновления. Для большинства переключателей 189 | существует процесс автоматического определения совместимости, однако в случае невозможности определить 190 | компонентом подходящие переключатели, возможно также определить их вручную. 191 | 192 | _Внимание:_ При наличии автоматической совместимости объекта и переключателя, и установке переопределения 193 | посредством конфигурации, переопределение замещает собственный переключатель объекта. 194 | 195 | Установка переопределений производится под ключом `toggles` в формате `тип: объект-переключатель` 196 | _Требования для объектов-переключателей:_ состояния объекта должны быть `on` или `off` 197 | Полный список доступных переключателей: [Список функций Toggle - Технологии Яндекса](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-instance-docpage/) 198 | 199 | #### Пример конфигурации 200 | ```yaml 201 | yandex_smart_home: 202 | entity_config: 203 | media_player.tv_lg: 204 | toggles: 205 | # Блокировка управления телевизором 206 | controls_locked: switch.custom_webostv_controls_lock 207 | 208 | # Подсветка 209 | backlight: light.raspberry_pi_ambilight 210 | 211 | # Приглушить звук 212 | mute: switch.sound_system 213 | ``` 214 | 215 | 216 | ### Расширенная конфигурация: _Свойства (Property)_ 217 | Компонент поддерживает все свойства Yandex на момент последнего обновления. Для большинства свойств 218 | существует процесс автоматического определения совместимости, однако в случае невозможности определить 219 | компонентом подходящие свойства, возможно также переопределить свойства вручную. 220 | 221 | Полный список доступных свойств: [Список свойств Float - Технологии Яндекса](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/float-instance-docpage/) 222 | На данный момент доступны только свойства _Float_ 223 | 224 | #### Пример конфигурации 225 | ```yaml 226 | yandex_smart_home: 227 | entity_config: 228 | sensor.home_power_meter: 229 | properties: 230 | # Использование атрибута `voltage` объекта `sensor.home_power_meter` 231 | voltage: voltage 232 | 233 | # Использование состояния объекта `sensor.home_power_meter_current` 234 | current: sensor.home_power_meter_current 235 | 236 | # Использование атрибута `temperature` объекта `sensor.home_power_meter_temperature` 237 | temperature: 238 | entity_id: sensor.home_power_meter_temeperature 239 | attribute: temperature 240 | ``` 241 | 242 | ### Расширенная конфигурация: _Диапазоны (Capability -> Range)_ 243 | Для данного раздела конфигурации действуют те же регламенты, что и для _Переключателей_, за исключением того, 244 | что целевой объект получения данных должен иметь числовое значение состояния / атрибута. 245 | 246 | Полный список доступных диапазонов: [Список функций Range - Технологии Яндекса](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-instance-docpage/) 247 | 248 | #### Пример конфигурации 249 | ```yaml 250 | yandex_smart_home: 251 | entity_config: 252 | light.christmas_lights: 253 | ranges: 254 | # Пример: Диапазон яркости 255 | brightness: 256 | # (обязательно) Целевой объект 257 | entity_id: group.christmas_light_brightness 258 | 259 | # (обязательно) Скрипт/служба установки значения 260 | # При выполнении доступна переменная `value` типа `float`, содержащая 261 | # значение, запрошенное Яндексом, помноженное на `multiplier` (см. ниже) 262 | # Также доступна переменная `entity_id` с ID объекта. 263 | set_script: 264 | service: light.turn_on 265 | data_template: 266 | entity_id: {{ entity_id }} 267 | brightness: {{ value }} 268 | 269 | # Минимальное значение (для Яндекса) 270 | minimum: 0 271 | 272 | # Максимальное значение (для Яндекса) 273 | maximum: 100 274 | 275 | # Шаг изменения (в данном случае: +5, -5) 276 | prceision: 5 277 | 278 | # Множитель для значений (по умолчанию 1) 279 | # Требуется указывать для объектов, которые используют 280 | # в качестве значений дробные числа в диапазоне от 0 до 1. 281 | multiplier: 0.01 282 | 283 | ``` 284 | 285 | ### Расширенная конфигурация: _Режимы (Capability -> Mode)_ 286 | Для данного раздела конфигурации действуют те же регламенты, что и для _Переключателей_, за исключением того, 287 | что целевой объект получения данных должен иметь состояния из списка состояний соответствующего режима, 288 | если не указан отдельный массив соответствий между состояниями. 289 | 290 | Полный список доступных режимов: [Список функций Mode - Технологии Яндекса](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-instance-docpage/) 291 | 292 | #### Пример конфигурации 293 | ```yaml 294 | yandex_smart_home: 295 | entity_config: 296 | switch.kitchen_kettle: 297 | modes: 298 | # Пример: Режим кофемашины 299 | coffee_mode: 300 | # (обязательно) Целевой объект 301 | entity_id: sensor.uart_jura_coffee_mode 302 | 303 | # (обязательно) Скрипт/служба установки значения 304 | # При выполнении доступна переменная `value` типа `float`, содержащая 305 | # значение, запрошенное Яндексом, помноженное на `multiplier` (см. ниже). 306 | # Также доступна переменная `entity_id` с ID объекта. 307 | set_script: 308 | service: shell_command.send_jura_coffee_mode 309 | data_template: 310 | mode: {{ value }} 311 | 312 | # Сопоставление значений с объекта с доступными для режима. 313 | # Слева указывается режим. 314 | # Справа указываются все возможные состояния для соответствующего режима. 315 | # При установке режима, если справа задан список, будет использовано первое 316 | # значение из списка при вызове скрипта выше. 317 | mapping: 318 | americano: 01D3 319 | cappucino: ['3A12', '3A13'] 320 | double_espresso: DD11 321 | ``` 322 | 323 | ### Дополнительные параметры 324 | ```yaml 325 | yandex_smart_home: 326 | # Включение диагностического режима. 327 | # 328 | # !!! !!! ВНИМАНИЕ !!! !!! 329 | # НЕ ИСПОЛЬЗУЙТЕ ДАННУЮ ОПЦИЮ, ЕСЛИ ВАМ ОНА НЕ ТРЕБУЕТСЯ! 330 | # ОНА ПОЗВОЛЯЕТ ЛЮБОЙ СЛУЖБЕ / ПОЛЬЗОВАТЕЛЮ ПОСЫЛАТЬ НА ВАШ 331 | # HOMEASSISTANT ЗАПРОСЫ К API КОМПОНЕНТА БЕЗ АВТОРИЗАЦИИ! 332 | # ДАННАЯ ФУНКЦИЯ ПРЕДНАЗНАЧЕНА ИСКЛЮЧИТЕЛЬНО ДЛЯ РАЗРАБОТКИ! 333 | # !!! !!! ВНИМАНИЕ !!! !!! 334 | # 335 | # По умолчанию: false 336 | diagnostics_mode: true 337 | 338 | # Скрывать уведомления с конфигурацией и предупреждения 339 | # о работе режима диагностики. Это полезно если Вы часто 340 | # перезагружаете HomeAssistant и не желаете требовать Алису 341 | # выполнять команду каждый раз. 342 | # По умолчанию: false 343 | hide_notifications: true 344 | ``` 345 | 346 | ## Для разработчиков 347 | Если Вы являетесь разработчиком компонента, создающего объекты, Вы можете внедрить передачу правильного 348 | типа объекта, добавив атрибут `yandex_type` в список атрибутов состояния (`device_state_attributes`) 349 | объекта. Это позволит улучшить связку _Устройство_ -> _HomeAssistant_ -> _Yandex_ для конечного пользователя. 350 | 351 | ## Создание диалога 352 | Перейдите в [панель разработчика диалогов](https://dialogs.yandex.ru/developer/) и создайте новый навык _Умный дом_: 353 | 354 | [Панель разработчика диалогов](https://raw.githubusercontent.com/alryaz/hass-component-yandex-smart-home/master/images/step_developer_page.png) 355 | [Выбор типа умения](https://raw.githubusercontent.com/alryaz/hass-component-yandex-smart-home/master/images/step_developer_type.png) 356 | 357 | При создании конфигурации, используйте следующие параметры 358 | 359 | Поле | Значение 360 | ------------ | ------------- 361 | Название | Название диалога (любая строка) 362 | Endpoint URL | `https://<Хост HomeAssistant>:<Порт>/api/yandex_smart_home` 363 | Не показывать в каталоге | Отметить галочку 364 | Подзаголовок | Подзаголовок (любая строка) 365 | Имя разработчика | Ваше имя (или придуманное) 366 | Email разработчика | Ваш E-mail в Яндекс (для подтверждения) 367 | Официальный навык | `Нет` 368 | Описание | Описание навыка (любая строка) 369 | Иконка | Иконка навыка (пример: [картинка из поста](https://community.home-assistant.io/t/round-icon-for-android/23019/4)) 370 | 371 | Для связывания аккаунтов используйте кнопку внизу страницы настройки умения, 372 | и заполните значения таким образом: 373 | 374 | Поле | Значение 375 | ------------ | ------------- 376 | Идентификатор приложения | `https://social.yandex.net/` 377 | Секрет приложения | Любая непустая последовательность символов 378 | URL авторизации | `https://<Хост HomeAssistant>:<Порт>/auth/authorize` 379 | URL для получения токена | `https://<Хост HomeAssistant>:<Порт>/auth/token` 380 | URL для обновления токена | `https://<Хост HomeAssistant>:<Порт>/auth/token` 381 | Идентификатор группы действий | Оставить пустым -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for the Yandex Smart Home component.""" 2 | __all__ = [ 3 | 'YandexSmartHomeFlowHandler' 4 | ] 5 | 6 | import logging 7 | from collections import OrderedDict 8 | from typing import Any, Dict, Optional, List, Tuple, Union, Callable, Awaitable 9 | 10 | import voluptuous as vol 11 | from homeassistant import config_entries 12 | from homeassistant.components import ( 13 | media_player, 14 | script 15 | ) 16 | from homeassistant.config import YAML_CONFIG_FILE 17 | from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_ENTITY_ID 18 | from homeassistant.core import valid_entity_id 19 | from homeassistant.helpers import entityfilter as ef 20 | 21 | from .const import ( 22 | DOMAIN, CONF_FILTER, CONF_ENTITY_CONFIG, 23 | TYPE_OTHER, CONF_CHANNEL_SET_VIA_MEDIA_CONTENT_ID, CONF_TYPE, 24 | PREFIX_TYPES, CONF_ENTITY_PROPERTIES, CONF_ENTITY_TOGGLES, CONF_ATTRIBUTE) 25 | from .core.helpers import get_child_instances, AnyInstanceType 26 | from .core.type_mapper import get_supported_types, DOMAIN_TO_YANDEX_TYPES 27 | from .functions.capability import CAPABILITIES, CAPABILITIES_TOGGLE 28 | from .functions.prop import PROPERTIES 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | CONF_ADVANCED_CONFIGURATION = "advanced_configuration" 33 | YANDEX_DEVICE_SUBTYPES = sorted(map(lambda x: x.replace(PREFIX_TYPES, ''), get_supported_types())) 34 | 35 | DEFAULT_INCLUDE_ENTITIES = '' 36 | DEFAULT_EXCLUDE_ENTITIES = '' 37 | DEFAULT_CUSTOMIZE_EXPOSURE = False 38 | DEFAULT_ADVANCED_ENABLE = False 39 | DEFAULT_ADDITIONAL_ENABLE = False 40 | 41 | PAGE_FIELD_MAX_COUNT = 5 42 | 43 | AdditionalStepWrapperType = Callable[ 44 | ['YandexSmartHomeFlowHandler', Optional[Dict[str, str]]], 45 | Awaitable[Dict[str, Any]] 46 | ] 47 | 48 | ADDITIONAL_STEP_WRAPPERS: Dict[str, AdditionalStepWrapperType] = dict() 49 | ADDITIONAL_STEP_PREFIX = 'custom_' 50 | 51 | 52 | def custom_additional_step(key: str, source: List[AnyInstanceType], _type: Optional[str] = None): 53 | step_id = ADDITIONAL_STEP_PREFIX + key 54 | 55 | def wrapper_generator(func: Callable[['YandexSmartHomeFlowHandler', Dict[str, str]], Tuple[bool, Dict[str, Any]]]) \ 56 | -> AdditionalStepWrapperType: 57 | 58 | async def wrapper(self: 'YandexSmartHomeFlowHandler', user_input: Optional[Dict[str, str]] = None) \ 59 | -> Dict[str, Any]: 60 | 61 | _LOGGER.debug('async_step_%s %s [last_jndex=%s]' % (step_id, user_input, self._last_jndex)) 62 | reason = self._check_before_step() 63 | if reason: 64 | return self.async_abort(reason=reason) 65 | 66 | if self._last_jndex is None: 67 | self._last_jndex = 0 68 | 69 | if self.custom_additional_schemas is None: 70 | self.custom_additional_schemas = dict() 71 | 72 | step_schemas = self.custom_additional_schemas.get(step_id) 73 | if step_schemas is None: 74 | step_schemas = self._generate_instance_schemas(source, _type) 75 | self.custom_additional_schemas[step_id] = step_schemas 76 | 77 | entity_id, friendly_name, placeholders, exposure_dict = self._get_exposure_attributes( 78 | i=self._last_jndex, 79 | p=len(step_schemas) 80 | ) 81 | 82 | if user_input is None: 83 | _LOGGER.debug('[%s] Show form on missing input' % step_id) 84 | return self.async_show_form( 85 | step_id=step_id, 86 | data_schema=step_schemas[self._last_jndex], 87 | description_placeholders=placeholders 88 | ) 89 | 90 | success, result_dict = func(self, user_input) 91 | 92 | if success: 93 | if result_dict is not None: 94 | _LOGGER.debug('[%s] Merging result data: %s' % (step_id, result_dict)) 95 | self._merge_exposure_items(entity_id, {key: result_dict}) 96 | else: 97 | _LOGGER.debug('[%s] Not merging on no result' % step_id) 98 | else: 99 | _LOGGER.debug('[%s] Invalidated user input, errors: %s' % (step_id, result_dict)) 100 | return self.async_show_form( 101 | step_id=step_id, 102 | data_schema=step_schemas[self._last_jndex], 103 | description_placeholders=placeholders, 104 | errors=result_dict 105 | ) 106 | 107 | return await self._run_additional_steps() 108 | 109 | ADDITIONAL_STEP_WRAPPERS[key] = wrapper 110 | 111 | return wrapper 112 | 113 | return wrapper_generator 114 | 115 | 116 | class YandexSmartHomeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 117 | """Config flow for Yandex Smart Home.""" 118 | 119 | VERSION = 1 120 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH 121 | 122 | def __getattribute__(self, item: str): 123 | if item.startswith('async_step_' + ADDITIONAL_STEP_PREFIX): 124 | # Find custom step 125 | key = item[11 + len(ADDITIONAL_STEP_PREFIX):] 126 | if key in ADDITIONAL_STEP_WRAPPERS: 127 | # noinspection PyUnresolvedReferences 128 | return ADDITIONAL_STEP_WRAPPERS[key].__get__(self, self.__class__) 129 | 130 | return super(YandexSmartHomeFlowHandler, self).__getattribute__(item) 131 | 132 | def __init__(self): 133 | """Initialize Yandex Smart Home config flow.""" 134 | super().__init__() 135 | 136 | self._current_config: Optional[Dict[str, Any]] = None 137 | self._last_index: Optional[int] = None 138 | self._last_jndex: Optional[int] = None 139 | 140 | self.supported_domains = [ 141 | domain 142 | for domain, yandex_type in DOMAIN_TO_YANDEX_TYPES.items() 143 | if yandex_type != TYPE_OTHER 144 | ] 145 | 146 | configuration_type_schema = OrderedDict() 147 | configuration_type_schema[vol.Optional(CONF_ADVANCED_CONFIGURATION, default=DEFAULT_ADVANCED_ENABLE)] = bool 148 | self.configuration_type_schema = vol.Schema(configuration_type_schema) 149 | 150 | self.supported_schemas = None 151 | self.unsupported_schemas = None 152 | self.entity_filter_schema = None 153 | self.custom_additional_schemas = None 154 | 155 | def _generate_custom_schemas(self): 156 | """Generate schemas for support and unsupported domains.""" 157 | supported_schemas = [] 158 | unsupported_schemas = [] 159 | 160 | current_supported_schema = OrderedDict() 161 | current_unsupported_schema = OrderedDict() 162 | 163 | for domain, yandex_type in DOMAIN_TO_YANDEX_TYPES.items(): 164 | if domain in self.supported_domains: 165 | if len(current_supported_schema) == PAGE_FIELD_MAX_COUNT: 166 | supported_schemas.append(vol.Schema(current_supported_schema)) 167 | current_supported_schema = OrderedDict() 168 | 169 | current_supported_schema[vol.Optional(domain, default=True)] = bool 170 | 171 | else: 172 | if len(current_unsupported_schema) == PAGE_FIELD_MAX_COUNT: 173 | unsupported_schemas.append(vol.Schema(current_unsupported_schema)) 174 | current_unsupported_schema = OrderedDict() 175 | 176 | current_unsupported_schema[vol.Optional(domain, default=False)] = bool 177 | 178 | if current_supported_schema: 179 | supported_schemas.append(vol.Schema(current_supported_schema)) 180 | 181 | if current_unsupported_schema: 182 | unsupported_schemas.append(vol.Schema(current_unsupported_schema)) 183 | 184 | self.supported_schemas = supported_schemas 185 | self.unsupported_schemas = unsupported_schemas 186 | 187 | entities_schema = OrderedDict() 188 | entities_schema[vol.Optional(ef.CONF_INCLUDE_ENTITIES, default=DEFAULT_INCLUDE_ENTITIES)] = str 189 | entities_schema[vol.Optional(ef.CONF_EXCLUDE_ENTITIES, default=DEFAULT_EXCLUDE_ENTITIES)] = str 190 | entities_schema[vol.Optional(CONF_ENTITY_CONFIG, default=DEFAULT_CUSTOMIZE_EXPOSURE)] = bool 191 | self.entity_filter_schema = vol.Schema(entities_schema) 192 | 193 | @staticmethod 194 | def _generate_instance_schemas(source: List[AnyInstanceType], _type: Optional[str] = None) -> List[vol.Schema]: 195 | """ 196 | Generate list of schemas for instance-bound classes (capabilities, properties) 197 | :param source: List of property / capability classes 198 | :param _type: (optional) Filter by specific type 199 | :return: Schema list 200 | """ 201 | custom_schemas = [] 202 | current_schema = OrderedDict() 203 | 204 | for instance in get_child_instances(source, _type): 205 | if len(current_schema) == PAGE_FIELD_MAX_COUNT: 206 | custom_schemas.append(vol.Schema(current_schema)) 207 | current_schema = OrderedDict() 208 | 209 | current_schema[vol.Optional(instance)] = str 210 | 211 | custom_schemas.append(vol.Schema(current_schema)) 212 | 213 | return custom_schemas 214 | 215 | def _check_before_step(self): 216 | """Perform common checks before running steps.""" 217 | if self._async_current_entries(): 218 | return "single_instance_allowed" 219 | 220 | def _include_domains_from_input(self, user_input: Dict[str, bool]) -> List[str]: 221 | """ 222 | Include domains from `supported` and `unsupported` steps. 223 | :param user_input: Dictionary of `` => `` 224 | :return: List of all included domains 225 | """ 226 | included_domains = [ 227 | domain 228 | for domain, choice in user_input.items() 229 | if choice is True 230 | ] 231 | 232 | if included_domains: 233 | if ef.CONF_INCLUDE_DOMAINS in self._current_config[CONF_FILTER]: 234 | self._current_config[CONF_FILTER][ef.CONF_INCLUDE_DOMAINS].extend(included_domains) 235 | else: 236 | self._current_config[CONF_FILTER][ef.CONF_INCLUDE_DOMAINS] = included_domains 237 | 238 | return included_domains 239 | 240 | def _create_entry(self) -> Dict[str, Any]: 241 | """Finish config flow and create entry from current configuration.""" 242 | _LOGGER.debug('Create config: %s' % self._current_config) 243 | 244 | # Clear empty keys 245 | delete_keys = [k for k, v in self._current_config.items() if not v] 246 | for key in delete_keys: 247 | del self._current_config[key] 248 | 249 | return self.async_create_entry( 250 | title=f'Default', 251 | data=self._current_config, 252 | ) 253 | 254 | def _get_exposure_attributes(self, entity_id: Optional[str] = None, i: Optional[int] = None, 255 | p: Optional[int] = None, e_i: Optional[int] = None) \ 256 | -> Tuple[str, str, Dict[str, Union[str, int]], Optional[Dict[str, Any]]]: 257 | if i is None: 258 | i = self._last_index 259 | 260 | if e_i is None: 261 | e_i = self._last_index 262 | 263 | if p is None: 264 | p = len(self._included_entities) 265 | 266 | if entity_id is None: 267 | entity_id = self._included_entities[e_i] 268 | 269 | state = self.hass.states.get(entity_id) 270 | 271 | friendly_name = "?" 272 | if state: 273 | friendly_name = state.attributes.get(ATTR_FRIENDLY_NAME, friendly_name) 274 | 275 | placeholders = {"entity_id": entity_id, "friendly_name": friendly_name, "page": i + 1, "pages": p} 276 | 277 | exposure_dict = None 278 | if CONF_ENTITY_CONFIG in self._current_config: 279 | exposure_dict = self._current_config[CONF_ENTITY_CONFIG].get(entity_id) 280 | 281 | return entity_id, friendly_name, placeholders, exposure_dict 282 | 283 | @property 284 | def _included_entities(self) -> List[str]: 285 | """ 286 | Shortcut to return included entities list. 287 | :return: List[] 288 | """ 289 | return self._current_config[CONF_FILTER][ef.CONF_INCLUDE_ENTITIES] 290 | 291 | def _merge_exposure_items(self, entity_id: str, items: Dict[str, Any]) -> Dict[str, Any]: 292 | """ 293 | Merge data to entity's exposure configuration. 294 | Create exposure dictionary first should it not exist. 295 | :param entity_id: Entity ID to perform merge for 296 | :param items: Exposure configuration 297 | :return: Merged entity exposure configuration 298 | """ 299 | customize_exposure = self._current_config.setdefault(CONF_ENTITY_CONFIG, dict()) 300 | 301 | if entity_id in customize_exposure: 302 | customize_exposure[entity_id].update(items) 303 | else: 304 | customize_exposure[entity_id] = {**items} 305 | 306 | return customize_exposure 307 | 308 | # GUI steps 309 | async def async_step_user(self, user_input: Optional[Dict[str, bool]] = None) -> Dict[str, Any]: 310 | """Step 2: Handle a flow initialized by the user.""" 311 | _LOGGER.debug('async_step_user %s' % user_input) 312 | 313 | reason = self._check_before_step() 314 | if reason: 315 | return self.async_abort(reason=reason) 316 | 317 | if user_input is None: 318 | return self.async_show_form( 319 | step_id="user", 320 | data_schema=self.configuration_type_schema, 321 | description_placeholders={ 322 | 'domains': '`' + '`, `'.join(self.supported_domains) + '`' 323 | } 324 | ) 325 | 326 | if user_input.get(CONF_ADVANCED_CONFIGURATION): 327 | return await self.async_step_supported() 328 | 329 | self._current_config = { 330 | CONF_FILTER: { 331 | ef.CONF_INCLUDE_DOMAINS: self.supported_domains 332 | } 333 | } 334 | return self._create_entry() 335 | 336 | async def async_step_supported(self, user_input: Optional[Dict[str, bool]] = None) -> Dict[str, Any]: 337 | """Step 2: Initiate special configuration.""" 338 | _LOGGER.debug('async_step_supported %s' % user_input) 339 | 340 | reason = self._check_before_step() 341 | if reason: 342 | return self.async_abort(reason=reason) 343 | 344 | if self.supported_schemas is None: 345 | self._generate_custom_schemas() 346 | 347 | if user_input is None: 348 | # Show form with a list of supported domains 349 | if self._last_index is None: 350 | self._last_index = 0 351 | 352 | _LOGGER.debug('Showing page %d of %d from supported schemas' 353 | % (self._last_index + 1, len(self.supported_schemas))) 354 | 355 | return self.async_show_form( 356 | step_id="supported", 357 | data_schema=self.supported_schemas[self._last_index], 358 | errors={}, 359 | description_placeholders={ 360 | "page": self._last_index + 1, 361 | "pages": len(self.supported_schemas) 362 | } 363 | ) 364 | 365 | if self._current_config is None: 366 | self._current_config = { 367 | CONF_FILTER: {}, 368 | CONF_ENTITY_CONFIG: {}, 369 | } 370 | 371 | self._include_domains_from_input(user_input) 372 | 373 | self._last_index += 1 374 | if self._last_index < len(self.supported_schemas): 375 | return await self.async_step_supported() 376 | 377 | self._last_index = None 378 | 379 | if self.unsupported_schemas: 380 | return await self.async_step_unsupported() 381 | 382 | return await self.async_step_selective() 383 | 384 | async def async_step_unsupported(self, user_input: Optional[Dict[str, bool]] = None) -> Dict[str, Any]: 385 | """Step 3: Enable unsupported domains on demand.""" 386 | _LOGGER.debug('async_step_unsupported %s' % user_input) 387 | 388 | reason = self._check_before_step() 389 | if reason: 390 | return self.async_abort(reason=reason) 391 | 392 | if user_input is None: 393 | # Show form with a list of unsupported domains 394 | if self._last_index is None: 395 | self._last_index = 0 396 | 397 | return self.async_show_form( 398 | step_id="unsupported", 399 | data_schema=self.unsupported_schemas[self._last_index], 400 | errors={}, 401 | description_placeholders={ 402 | "page": self._last_index + 1, 403 | "pages": len(self.unsupported_schemas), 404 | } 405 | ) 406 | 407 | self._include_domains_from_input(user_input) 408 | 409 | self._last_index += 1 410 | if self._last_index < len(self.unsupported_schemas): 411 | return await self.async_step_unsupported() 412 | 413 | self._last_index = None 414 | 415 | return await self.async_step_selective() 416 | 417 | async def async_step_selective(self, user_input: Optional[Dict[str, Union[str, bool]]] = None) -> Dict[str, Any]: 418 | """Step 4: Select entities to include/exclude.""" 419 | _LOGGER.debug('async_step_selective %s' % user_input) 420 | 421 | reason = self._check_before_step() 422 | if reason: 423 | return self.async_abort(reason=reason) 424 | 425 | include_exclude_keys = (ef.CONF_INCLUDE_ENTITIES, ef.CONF_EXCLUDE_ENTITIES) 426 | 427 | if not user_input or any(a not in user_input for a in include_exclude_keys): 428 | return self.async_show_form( 429 | step_id="selective", 430 | data_schema=self.entity_filter_schema 431 | ) 432 | 433 | errors, placeholders = dict(), dict() 434 | filter_config = {} 435 | for key in include_exclude_keys: 436 | # Iterate over lists of filtered entity IDs 437 | filter_input = user_input[key].replace(' ', '') 438 | if filter_input: 439 | key_config = [] 440 | 441 | for entity_id in filter_input.split(','): 442 | # Iterate over extracted entity IDs 443 | if not entity_id: 444 | errors[key] = "empty_entity_id" 445 | break 446 | 447 | elif not valid_entity_id(entity_id): 448 | errors[key] = "invalid_entity_id" 449 | placeholders['invalid_entity_id'] = entity_id 450 | break 451 | 452 | key_config.append(entity_id) 453 | 454 | filter_config[key] = key_config 455 | 456 | if user_input[CONF_ENTITY_CONFIG] and not filter_config.get(ef.CONF_INCLUDE_ENTITIES): 457 | # Fail customization on empty includes list 458 | errors[CONF_ENTITY_CONFIG] = "customization_unavailable_with_empty_includes" 459 | 460 | if all(a in filter_config for a in include_exclude_keys): 461 | # Confirm both filter poles are configured 462 | if set(filter_config[ef.CONF_INCLUDE_ENTITIES]).intersection(filter_config[ef.CONF_EXCLUDE_ENTITIES]): 463 | # Fail setup on intersecting entity include/exclude lists 464 | errors['base'] = "conflicting_entity_includes" 465 | 466 | if errors: 467 | # There are errors, show them 468 | return self.async_show_form( 469 | step_id="selective", 470 | data_schema=self.entity_filter_schema, 471 | errors=errors, 472 | data_placeholders=placeholders 473 | ) 474 | 475 | if filter_config: 476 | # Update previous filter configuration 477 | if CONF_FILTER in self._current_config: 478 | self._current_config[CONF_FILTER].update(filter_config) 479 | else: 480 | self._current_config[CONF_FILTER] = filter_config 481 | 482 | elif CONF_FILTER not in self._current_config: 483 | # Check if filter returns no entities at all 484 | return self.async_show_form( 485 | step_id="selective", 486 | data_schema=self.entity_filter_schema, 487 | errors={"base": "filter_without_entities"} 488 | ) 489 | 490 | if user_input[CONF_ENTITY_CONFIG]: 491 | return await self.async_step_custom() 492 | 493 | return self._create_entry() 494 | 495 | async def async_step_custom(self, user_input: Optional[Dict[str, Union[str, bool]]] = None) -> Dict[str, Any]: 496 | """Step 5: Customize per-entity exposure.""" 497 | _LOGGER.debug('async_step_custom %s [last_index=%s]' % (user_input, self._last_index)) 498 | 499 | reason = self._check_before_step() 500 | if reason: 501 | return self.async_abort(reason=reason) 502 | 503 | if self._last_index is None: 504 | self._last_index = 0 505 | elif user_input is None: 506 | self._last_index += 1 507 | 508 | if self._last_index < len(self._included_entities): 509 | entity_id, friendly_name, placeholders, _ = self._get_exposure_attributes() 510 | 511 | if user_input is None: 512 | domain = entity_id.split('.')[0] 513 | schema = OrderedDict() 514 | schema[vol.Optional(CONF_TYPE)] = vol.In(YANDEX_DEVICE_SUBTYPES) 515 | 516 | # For media players, enable `channel_set_via_media_content_id` 517 | if domain == media_player.DOMAIN: 518 | schema[vol.Optional(CONF_CHANNEL_SET_VIA_MEDIA_CONTENT_ID, default=False)] = bool 519 | 520 | # Add additional steps 521 | for key, step in ADDITIONAL_STEP_WRAPPERS.items(): 522 | schema[vol.Optional(key, default=DEFAULT_ADDITIONAL_ENABLE)] = bool 523 | 524 | return self.async_show_form( 525 | step_id="custom", 526 | data_schema=vol.Schema(schema), 527 | description_placeholders=placeholders 528 | ) 529 | 530 | if user_input: 531 | self._merge_exposure_items(entity_id, user_input) 532 | return await self._run_additional_steps() 533 | 534 | # Finish flow 535 | return self._create_entry() 536 | 537 | async def _run_additional_steps(self): 538 | entity_id = self._included_entities[self._last_index] 539 | exposure_dict = self._current_config[CONF_ENTITY_CONFIG].get(entity_id, {}) 540 | 541 | _LOGGER.debug('Running additional steps with exposure dict: %s' % exposure_dict) 542 | 543 | for key, wrapper in ADDITIONAL_STEP_WRAPPERS.items(): 544 | enabled = exposure_dict.get(key) 545 | 546 | if enabled is not None: 547 | if isinstance(enabled, bool): 548 | _LOGGER.debug('Clearing key: %s' % key) 549 | del exposure_dict[key] 550 | 551 | if enabled is True: 552 | _LOGGER.debug('Awaiting additional step: %s' % key) 553 | return await wrapper(self, None) 554 | 555 | elif not enabled: 556 | _LOGGER.debug('Key %s contains empty config' % key) 557 | del exposure_dict[key] 558 | 559 | if not exposure_dict: 560 | del self._current_config[CONF_ENTITY_CONFIG][entity_id] 561 | 562 | return self._create_entry() 563 | 564 | @custom_additional_step(CONF_ENTITY_TOGGLES, CAPABILITIES, CAPABILITIES_TOGGLE) 565 | def custom_toggles_processor(self, user_input: Dict[str, str]): 566 | """This is a processor for custom_toggles""" 567 | errors: Dict[str, str] = dict() 568 | for instance, override_entity_id in user_input.items(): 569 | if not valid_entity_id(override_entity_id): 570 | errors[instance] = "invalid_entity_id" 571 | continue 572 | 573 | parts = override_entity_id.split('.') 574 | if parts[0] in (script.DOMAIN,): 575 | errors[instance] = "invalid_domain" 576 | 577 | return not errors, errors or user_input 578 | 579 | @custom_additional_step(CONF_ENTITY_PROPERTIES, PROPERTIES) 580 | def custom_properties_processor(self, user_input: Dict[str, str]): 581 | """This is a processor for custom_properties""" 582 | entity_properties = dict() 583 | errors = dict() 584 | for instance, value in user_input.items(): 585 | entity_property = dict() 586 | parts = value.split('.') 587 | parts_count = len(parts) 588 | 589 | if parts_count > 1: 590 | entity_id = '.'.join(parts[:2]) 591 | if not valid_entity_id: 592 | errors[instance] = "invalid_entity_id" 593 | continue 594 | 595 | entity_property[CONF_ENTITY_ID] = entity_id 596 | if 2 < len(parts) < 4: 597 | entity_property[CONF_ATTRIBUTE] = '.'.join(parts[2]) 598 | else: 599 | errors[instance] = "invalid_entity_attribute" 600 | continue 601 | 602 | else: 603 | entity_property[CONF_ATTRIBUTE] = parts[0] 604 | 605 | if entity_property: 606 | entity_properties[instance] = entity_property 607 | 608 | return not errors, errors or entity_properties 609 | 610 | # Import step 611 | async def async_step_import(self, _) -> Dict[str, Any]: 612 | """Import a config entry from configuration.yaml.""" 613 | if self._async_current_entries(): 614 | _LOGGER.warning("Only one configuration of Yandex Smart Home is allowed.") 615 | return self.async_abort(reason="single_instance_allowed") 616 | 617 | return self.async_create_entry( 618 | title=YAML_CONFIG_FILE, 619 | data={}, 620 | description='Configuration imported from YAML' 621 | ) 622 | -------------------------------------------------------------------------------- /custom_components/yandex_smart_home/functions/capability.py: -------------------------------------------------------------------------------- 1 | """Implement the Yandex Smart Home capabilities.""" 2 | import logging 3 | from typing import Any, Optional, Dict, TYPE_CHECKING, Tuple, Type, List, Union, Mapping, Sequence, Callable, Iterable 4 | 5 | from homeassistant.components import ( 6 | automation, 7 | camera, 8 | climate, 9 | cover, 10 | group, 11 | fan, 12 | input_boolean, 13 | media_player, 14 | light, 15 | scene, 16 | script, 17 | switch, 18 | vacuum, 19 | water_heater, 20 | lock, 21 | ) 22 | from homeassistant.components.water_heater import ( 23 | STATE_ELECTRIC, SERVICE_SET_OPERATION_MODE 24 | ) 25 | from homeassistant.const import ( 26 | ATTR_ENTITY_ID, 27 | ATTR_SUPPORTED_FEATURES, 28 | SERVICE_CLOSE_COVER, 29 | SERVICE_OPEN_COVER, 30 | SERVICE_TURN_OFF, 31 | SERVICE_TURN_ON, 32 | SERVICE_LOCK, 33 | SERVICE_UNLOCK, 34 | STATE_OFF, 35 | STATE_ON, CONF_ENTITY_ID, 36 | CONF_MAXIMUM, CONF_MINIMUM 37 | ) 38 | from homeassistant.core import DOMAIN as HA_DOMAIN, State 39 | from homeassistant.helpers.script import Script 40 | from homeassistant.helpers.typing import HomeAssistantType 41 | from homeassistant.util import color as color_util 42 | 43 | from ..const import ( 44 | ERR_INVALID_VALUE, 45 | ERR_NOT_SUPPORTED_IN_CURRENT_MODE, CONF_PROGRAMS, 46 | CONF_CHANNEL_SET_VIA_MEDIA_CONTENT_ID, CONF_RELATIVE_VOLUME_ONLY, 47 | CONF_INPUT_SOURCES, CONF_ENTITY_TOGGLES, 48 | CONF_SCRIPT_CHANNEL_UP, CONF_SCRIPT_CHANNEL_DOWN, CONF_ENTITY_MODES, CONF_MAPPING, ERR_INTERNAL_ERROR, 49 | CONF_SET_SCRIPT, CONF_PRECISION, CONF_MULTIPLIER, CONF_ENTITY_RANGES, MODES_NUMERIC, ATTR_VALUE) 50 | from ..core.error import SmartHomeError, DefaultNotImplemented, \ 51 | OverrideNotImplemented 52 | 53 | if TYPE_CHECKING: 54 | from ..core.helpers import RequestData 55 | 56 | _LOGGER = logging.getLogger(__name__) 57 | 58 | PREFIX_CAPABILITIES = 'devices.capabilities.' 59 | CAPABILITIES_ON_OFF = PREFIX_CAPABILITIES + 'on_off' 60 | CAPABILITIES_TOGGLE = PREFIX_CAPABILITIES + 'toggle' 61 | CAPABILITIES_RANGE = PREFIX_CAPABILITIES + 'range' 62 | CAPABILITIES_MODE = PREFIX_CAPABILITIES + 'mode' 63 | CAPABILITIES_COLOR_SETTING = PREFIX_CAPABILITIES + 'color_setting' 64 | 65 | CAPABILITIES: List[Type['_Capability']] = [] 66 | 67 | 68 | def register_capability(capability): 69 | """Decorate a function to register a capability.""" 70 | CAPABILITIES.append(capability) 71 | return capability 72 | 73 | 74 | class _CompatibilityConfig: 75 | def __init__(self, domain: str, required_feature: Optional[int] = None, 76 | retrievable_feature: Optional[int] = None): 77 | self.domain = domain 78 | self.required_feature = required_feature 79 | self.retrievable_feature = retrievable_feature 80 | 81 | def __repr__(self): 82 | return '<{}[{}]>'.format(self.__class__.__name__, ', '.join([ 83 | '{}={}'.format(k, v) 84 | for k, v in self.__dict__.items() 85 | ])) 86 | 87 | def __str__(self): 88 | return self.__class__.__name__ + '(' + self.domain + ')' 89 | 90 | def is_compatible(self, domain: str, features: int, attributes: Dict[str, Any]) -> bool: 91 | return self.domain == domain and (self.required_feature is None or features & self.required_feature) 92 | 93 | 94 | class _Capability(object): 95 | """Represents a Capability.""" 96 | 97 | type = NotImplemented 98 | instance = NotImplemented 99 | retrievable = True 100 | 101 | def __init__(self, hass: HomeAssistantType, state: State, entity_config: Dict): 102 | """Initialize a trait for a state.""" 103 | self.hass = hass 104 | self.state = state 105 | self.entity_config = entity_config 106 | 107 | self.use_override = self.has_override(state.domain, entity_config, state.attributes) 108 | 109 | @classmethod 110 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 111 | """Check whether current entity is supported.""" 112 | return False 113 | 114 | @classmethod 115 | def has_override(cls, domain: str, entity_config: Dict, attributes: Dict) -> bool: 116 | """Return whether current capability instance has associated overrides. 117 | 118 | Capabilities can implement this method as well as the next two 119 | to provide ways to implement configuration-bound overrides. 120 | """ 121 | return False 122 | 123 | def description(self) -> Dict: 124 | """Return description for a devices request.""" 125 | response = { 126 | 'type': self.type, 127 | 'retrievable': self.retrievable, 128 | } 129 | parameters = self.parameters() 130 | if parameters is not None: 131 | response['parameters'] = parameters 132 | 133 | return response 134 | 135 | def get_state(self) -> Dict: 136 | """Return the state of this capability for this entity.""" 137 | return { 138 | 'type': self.type, 139 | 'state': { 140 | 'instance': self.instance, 141 | 'value': self.get_value(), 142 | } 143 | } 144 | 145 | def parameters(self) -> Dict: 146 | """Return parameters for a devices request.""" 147 | if self.use_override: 148 | return self.parameters_override() 149 | return self.parameters_default() 150 | 151 | def parameters_default(self) -> Dict[str, Any]: 152 | raise DefaultNotImplemented(self.__class__) 153 | 154 | def parameters_override(self) -> Dict[str, Any]: 155 | raise OverrideNotImplemented(self.__class__) 156 | 157 | def get_value(self) -> Any: 158 | """Return the state value of this capability for this entity.""" 159 | if self.use_override: 160 | return self.get_value_override() 161 | return self.get_value_default() 162 | 163 | def get_value_default(self) -> Optional[Union[str, float, int]]: 164 | """Return the state value of this capability for this entity using default mechanism.""" 165 | raise DefaultNotImplemented(self.__class__) 166 | 167 | def get_value_override(self) -> Optional[Union[str, float, int]]: 168 | """Return the state value of this capability for this entity using override.""" 169 | raise OverrideNotImplemented(self.__class__) 170 | 171 | async def set_state(self, data: 'RequestData', state: Dict) -> None: 172 | """Set device state.""" 173 | if self.use_override: 174 | return await self.set_state_override(data, state) 175 | return await self.set_state_default(data, state) 176 | 177 | async def set_state_default(self, data: 'RequestData', state: Dict) -> None: 178 | """Set device state.""" 179 | raise DefaultNotImplemented(self.__class__) 180 | 181 | async def set_state_override(self, data: 'RequestData', state: Dict) -> None: 182 | """Set device state using override.""" 183 | raise OverrideNotImplemented(self.__class__) 184 | 185 | 186 | class _CompatibleCapability(_Capability): 187 | _compatibility_configs: Sequence[_CompatibilityConfig] = NotImplemented 188 | compatibility_config: _CompatibilityConfig = None 189 | 190 | def __init__(self, hass: HomeAssistantType, state: State, entity_config: Dict[str, Any]): 191 | super().__init__(hass, state, entity_config) 192 | attributes = state.attributes 193 | self.compatibility_config = self.get_compatibility_config( 194 | domain=state.domain, 195 | features=attributes.get(ATTR_SUPPORTED_FEATURES, 0), 196 | attributes=attributes 197 | ) if not self.use_override else None 198 | 199 | @property 200 | def retrievable(self): 201 | if self.use_override: 202 | return True 203 | conf = self.compatibility_config 204 | return ( 205 | conf.retrievable_feature is None 206 | or self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) 207 | & conf.retrievable_feature 208 | ) 209 | 210 | @classmethod 211 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 212 | """Determine whether mode capability is supported.""" 213 | return cls.get_compatibility_config(domain, features, attributes) is not None 214 | 215 | @classmethod 216 | def get_compatibility_config(cls, domain: str, features: int, attributes: Dict[str, Any]): 217 | if cls._compatibility_configs is NotImplemented: 218 | return None 219 | 220 | for config in cls._compatibility_configs: 221 | if config.is_compatible(domain, features, attributes): 222 | return config 223 | 224 | 225 | @register_capability 226 | class OnOffCapability(_Capability): 227 | """On_off to offer basic on and off functionality. 228 | 229 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/on_off-docpage/ 230 | """ 231 | 232 | type = CAPABILITIES_ON_OFF 233 | instance = 'on' 234 | 235 | water_heater_operations = { 236 | STATE_ON: [STATE_ON, 'On', 'ON', STATE_ELECTRIC], 237 | STATE_OFF: [STATE_OFF, 'Off', 'OFF'], 238 | } 239 | 240 | def __init__(self, hass, state, config): 241 | super().__init__(hass, state, config) 242 | self.retrievable = state.domain not in (scene.DOMAIN, script.DOMAIN) 243 | 244 | @classmethod 245 | def get_water_heater_operation(cls, required_mode, operations_list): 246 | for operation in cls.water_heater_operations[required_mode]: 247 | if operation in operations_list: 248 | return operation 249 | return None 250 | 251 | @classmethod 252 | def issue_state_retrieval(cls, entity_state: State) -> bool: 253 | """Return the state value of this capability for given entity.""" 254 | entity_domain = entity_state.domain 255 | current_state = entity_state.state 256 | 257 | if entity_domain == cover.DOMAIN: 258 | return current_state == cover.STATE_OPEN 259 | 260 | elif entity_domain == vacuum.DOMAIN: 261 | return current_state == STATE_ON or current_state == \ 262 | vacuum.STATE_CLEANING 263 | 264 | elif entity_domain == climate.DOMAIN: 265 | return current_state != climate.HVAC_MODE_OFF 266 | 267 | elif entity_domain == lock.DOMAIN: 268 | return current_state == lock.STATE_UNLOCKED 269 | 270 | elif entity_domain == water_heater.DOMAIN: 271 | operation_mode = entity_state.attributes.get(water_heater.ATTR_OPERATION_MODE) 272 | operation_list = entity_state.attributes.get(water_heater.ATTR_OPERATION_LIST) 273 | return operation_mode != cls.get_water_heater_operation(STATE_OFF, operation_list) 274 | 275 | return current_state != STATE_OFF 276 | 277 | @classmethod 278 | async def issue_state_command(cls, hass: HomeAssistantType, entity_state: State, data: 'RequestData', state: Dict): 279 | """Set state for given entity.""" 280 | new_state = state['value'] 281 | if type(new_state) is not bool: 282 | raise SmartHomeError(ERR_INVALID_VALUE, "Value is not boolean") 283 | 284 | entity_domain = entity_state.domain 285 | entity_id = entity_state.entity_id 286 | 287 | service_domain = entity_domain 288 | service_data = { 289 | ATTR_ENTITY_ID: entity_id, 290 | } 291 | if entity_domain == group.DOMAIN: 292 | service_domain = HA_DOMAIN 293 | service = SERVICE_TURN_ON if new_state else SERVICE_TURN_OFF 294 | 295 | elif entity_domain == cover.DOMAIN: 296 | service = SERVICE_OPEN_COVER if new_state else \ 297 | SERVICE_CLOSE_COVER 298 | 299 | elif entity_domain == vacuum.DOMAIN: 300 | features = entity_state.attributes.get(ATTR_SUPPORTED_FEATURES) 301 | if new_state: 302 | if features & vacuum.SUPPORT_START: 303 | service = vacuum.SERVICE_START 304 | else: 305 | service = SERVICE_TURN_ON 306 | else: 307 | if features & vacuum.SUPPORT_RETURN_HOME: 308 | service = vacuum.SERVICE_RETURN_TO_BASE 309 | elif features & vacuum.SUPPORT_STOP: 310 | service = vacuum.SERVICE_STOP 311 | else: 312 | service = SERVICE_TURN_OFF 313 | 314 | elif entity_domain == scene.DOMAIN or entity_domain == script.DOMAIN: 315 | if new_state is False: 316 | _LOGGER.warning(("An 'off' command was issued via Yandex to %s. " 317 | "Please, check your configuration.") % entity_id) 318 | return 319 | service = SERVICE_TURN_ON 320 | 321 | elif entity_domain == lock.DOMAIN: 322 | service = SERVICE_UNLOCK if new_state else \ 323 | SERVICE_LOCK 324 | 325 | elif entity_domain == water_heater.DOMAIN: 326 | operation_list = entity_state.attributes.get(water_heater.ATTR_OPERATION_LIST) 327 | service = SERVICE_SET_OPERATION_MODE 328 | if new_state: 329 | service_data[water_heater.ATTR_OPERATION_MODE] = \ 330 | cls.get_water_heater_operation(STATE_ON, operation_list) 331 | else: 332 | service_data[water_heater.ATTR_OPERATION_MODE] = \ 333 | cls.get_water_heater_operation(STATE_OFF, operation_list) 334 | else: 335 | service = SERVICE_TURN_ON if new_state else SERVICE_TURN_OFF 336 | 337 | await hass.services.async_call( 338 | service_domain, 339 | service, 340 | service_data, 341 | blocking=(entity_domain != script.DOMAIN), 342 | context=data.context 343 | ) 344 | 345 | @classmethod 346 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 347 | """Test if state is supported.""" 348 | if domain == media_player.DOMAIN: 349 | return bool(features & media_player.SUPPORT_TURN_ON and features & media_player.SUPPORT_TURN_OFF) 350 | 351 | if domain == vacuum.DOMAIN: 352 | return bool((features & vacuum.SUPPORT_START and ( 353 | features & vacuum.SUPPORT_RETURN_HOME or features & vacuum.SUPPORT_STOP)) or ( 354 | features & vacuum.SUPPORT_TURN_ON and features & vacuum.SUPPORT_TURN_OFF)) 355 | 356 | if domain == water_heater.DOMAIN and features & water_heater.SUPPORT_OPERATION_MODE: 357 | operation_list = attributes.get(water_heater.ATTR_OPERATION_LIST) 358 | if cls.get_water_heater_operation(STATE_ON, operation_list) is None: 359 | return False 360 | if cls.get_water_heater_operation(STATE_OFF, operation_list) is None: 361 | return False 362 | return True 363 | 364 | return domain in ( 365 | automation.DOMAIN, 366 | camera.DOMAIN, 367 | cover.DOMAIN, 368 | group.DOMAIN, 369 | input_boolean.DOMAIN, 370 | switch.DOMAIN, 371 | fan.DOMAIN, 372 | light.DOMAIN, 373 | climate.DOMAIN, 374 | scene.DOMAIN, 375 | script.DOMAIN, 376 | lock.DOMAIN, 377 | ) 378 | 379 | def parameters(self): 380 | """Return parameters for a devices request.""" 381 | return None 382 | 383 | def get_value_default(self) -> Optional[Union[str, float, int]]: 384 | """Return the state value of this capability for this entity.""" 385 | return self.issue_state_retrieval(self.state) 386 | 387 | async def set_state_default(self, data: 'RequestData', state: Dict): 388 | """Set state for this entity.""" 389 | await self.issue_state_command(self.hass, self.state, data, state) 390 | 391 | 392 | class ToggleCapabilityConfig(_CompatibilityConfig): 393 | def __init__(self, domain: str, 394 | service_id_on: str, 395 | state_attr: Optional[str] = None, 396 | service_id_off: Optional[str] = None, 397 | required_feature: Optional[int] = None, 398 | retrievable_feature: Optional[int] = None, 399 | comp_state: Optional[Tuple[str, bool]] = None): 400 | super().__init__( 401 | domain=domain, 402 | required_feature=required_feature, 403 | retrievable_feature=retrievable_feature 404 | ) 405 | 406 | self.state_attr = state_attr 407 | self.service_id_on = service_id_on 408 | self.service_id_off = service_id_off 409 | self.comp_state = comp_state 410 | 411 | 412 | class _ToggleCapability(_CompatibleCapability): 413 | """Base toggle functionality. 414 | 415 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-docpage/ 416 | """ 417 | type = CAPABILITIES_TOGGLE 418 | 419 | _compatibility_configs: Sequence[ToggleCapabilityConfig] = NotImplemented 420 | compatibility_config: ToggleCapabilityConfig = None 421 | 422 | def get_value_default(self) -> Optional[Union[str, float, int]]: 423 | """Return the state value of this capability for this entity.""" 424 | conf = self.compatibility_config 425 | comp_state = conf.comp_state 426 | 427 | if conf.state_attr is not None: 428 | state = self.state.attributes.get(conf.state_attr) 429 | if state is None: 430 | return False 431 | if comp_state is None: 432 | return bool(state) 433 | return (state == comp_state[0]) is comp_state[1] 434 | return (self.state.state == comp_state[0]) is comp_state[1] 435 | 436 | async def set_state_default(self, data: 'RequestData', state: Dict) -> None: 437 | """Set device state.""" 438 | new_state = state['value'] 439 | if type(new_state) is not bool: 440 | raise SmartHomeError(ERR_INVALID_VALUE, "Value is not boolean") 441 | 442 | state = self.state 443 | conf = self.compatibility_config 444 | 445 | # Test for attribute existence 446 | if conf.state_attr is not None and state.attributes.get(conf.state_attr) is None: 447 | raise SmartHomeError(ERR_NOT_SUPPORTED_IN_CURRENT_MODE, 448 | "Device probably turned off") 449 | 450 | # Select service 451 | service_id = conf.service_id_on 452 | service_data = {ATTR_ENTITY_ID: self.state.entity_id} 453 | if conf.service_id_off is None: 454 | service_data[conf.state_attr] = new_state 455 | elif new_state is False: 456 | service_id = conf.service_id_off 457 | 458 | service_data[ATTR_ENTITY_ID] = self.state.entity_id 459 | 460 | await self.hass.services.async_call( 461 | domain=state.domain, 462 | service=service_id, 463 | service_data=service_data, 464 | blocking=True, 465 | context=data.context 466 | ) 467 | 468 | # Override config 469 | @classmethod 470 | def has_override(cls, domain: str, entity_config: Dict, attributes: Dict) -> bool: 471 | """Determine whether toggle capability has an override.""" 472 | return bool(cls.get_override_entity_id(entity_config)) 473 | 474 | def parameters(self): 475 | """Return parameters for a devices request.""" 476 | return {"instance": self.instance} 477 | 478 | @classmethod 479 | def get_override_entity_id(cls, entity_config: Dict) -> Optional[str]: 480 | """Return override entity ID for toggles.""" 481 | entity_toggles = entity_config.get(CONF_ENTITY_TOGGLES) 482 | if entity_toggles: 483 | return entity_toggles.get(cls.instance) 484 | 485 | @classmethod 486 | def get_override_entity_state(cls, hass: HomeAssistantType, entity_config: Dict) -> Optional[State]: 487 | """Get state of overriding entity.""" 488 | entity_id = cls.get_override_entity_id(entity_config) 489 | if entity_id: 490 | return hass.states.get(entity_id) 491 | 492 | def get_value_override(self) -> Optional[Union[str, float, int]]: 493 | """Return override value.""" 494 | override_entity_state = self.get_override_entity_state(self.hass, self.entity_config) 495 | return OnOffCapability.issue_state_retrieval(override_entity_state) 496 | 497 | async def set_state_override(self, data: 'RequestData', state: Dict): 498 | override_entity_state = self.get_override_entity_state(self.hass, self.entity_config) 499 | await OnOffCapability.issue_state_command(self.hass, override_entity_state, data, state) 500 | 501 | 502 | @register_capability 503 | class ControlsLockedCapability(_ToggleCapability): 504 | """Controls locking functionality.""" 505 | 506 | instance = "controls_locked" 507 | 508 | 509 | @register_capability 510 | class BacklightCapability(_ToggleCapability): 511 | """Backlight functionality""" 512 | 513 | instance = "backlight" 514 | 515 | 516 | @register_capability 517 | class IonizationCapability(_ToggleCapability): 518 | """Ionization functionality.""" 519 | 520 | instance = "ionization" 521 | 522 | 523 | @register_capability 524 | class KeepWarmCapability(_ToggleCapability): 525 | """Keep warm capability.""" 526 | 527 | instance = "keep_warm" 528 | 529 | 530 | @register_capability 531 | class MuteCapability(_ToggleCapability): 532 | """Mute and unmute functionality.""" 533 | 534 | instance = "mute" 535 | 536 | _compatibility_configs = [ 537 | ToggleCapabilityConfig( 538 | domain=media_player.DOMAIN, 539 | required_feature=media_player.SUPPORT_VOLUME_MUTE, 540 | state_attr=media_player.ATTR_MEDIA_VOLUME_MUTED, 541 | service_id_on=media_player.SERVICE_VOLUME_MUTE, 542 | ) 543 | ] 544 | 545 | 546 | @register_capability 547 | class OscillationCapability(_ToggleCapability): 548 | """Oscillation capability""" 549 | 550 | instance = "oscillation" 551 | 552 | _compatibility_configs = [ 553 | ToggleCapabilityConfig( 554 | domain=fan.DOMAIN, 555 | required_feature=fan.SUPPORT_OSCILLATE, 556 | state_attr=fan.ATTR_OSCILLATING, 557 | service_id_on=fan.ATTR_OSCILLATING, 558 | ) 559 | ] 560 | 561 | 562 | @register_capability 563 | class PauseCapability(_ToggleCapability): 564 | """Pause and unpause functionality.""" 565 | 566 | instance = "pause" 567 | 568 | _compatibility_configs = [ 569 | ToggleCapabilityConfig( 570 | domain=media_player.DOMAIN, 571 | required_feature=media_player.SUPPORT_PLAY | media_player.SUPPORT_PAUSE, 572 | service_id_on=media_player.SERVICE_MEDIA_PLAY, 573 | service_id_off=media_player.SERVICE_MEDIA_PAUSE, 574 | comp_state=(media_player.STATE_PLAYING, False), 575 | ), 576 | ToggleCapabilityConfig( 577 | domain=vacuum.DOMAIN, 578 | required_feature=vacuum.SUPPORT_PAUSE & vacuum.SUPPORT_START, 579 | service_id_on=vacuum.SERVICE_START, 580 | service_id_off=vacuum.SERVICE_PAUSE, 581 | comp_state=(vacuum.STATE_PAUSED, True), 582 | ) 583 | ] 584 | 585 | 586 | class ModeCompatibilityConfig(_CompatibilityConfig): 587 | def __init__(self, 588 | domain: str, 589 | mode_attr: str, modes_list_attr: str, 590 | service_id: str, service_attr: Optional[str] = None, 591 | default_modes_mapping: Optional[Dict[str, str]] = None, 592 | compatibility_checker: Optional[Callable[[str, int, Dict[str, Any]], bool]] = None, 593 | required_feature: Optional[int] = None): 594 | super().__init__(domain) 595 | self.mode_attr = mode_attr 596 | self.modes_list_attr = modes_list_attr 597 | self.service_id = service_id 598 | self.service_attr = mode_attr if service_attr is None else service_attr 599 | self._is_compatible = compatibility_checker 600 | self._default_modes_mapping = default_modes_mapping 601 | self.required_feature = required_feature 602 | 603 | def __repr__(self): 604 | return '<{}[{}]>'.format(self.__class__.__name__, ', '.join([ 605 | '{}={}'.format(k, v) 606 | for k, v in self.__dict__.items() 607 | ])) 608 | 609 | def __str__(self): 610 | return self.__class__.__name__ + '(' + self.domain + ')' 611 | 612 | def is_compatible(self, domain: str, features: int, attributes: Dict[str, Any]) -> bool: 613 | if domain != self.domain: 614 | return False 615 | 616 | if not (self.required_feature is None or self.required_feature & features): 617 | return False 618 | 619 | if self._is_compatible is None: 620 | modes_list = attributes.get(self.modes_list_attr) 621 | if modes_list is None: 622 | return False 623 | return bool(set(modes_list) & self.get_default_modes_mapping(attributes).keys()) 624 | return self._is_compatible(domain, features, attributes) 625 | 626 | def get_default_modes_mapping(self, attributes: Dict[str, Any]) -> Dict[str, str]: 627 | """Get default mode mapping (HA => Yandex)""" 628 | modes_list = attributes.get(self.modes_list_attr, []) 629 | if self._default_modes_mapping is None: 630 | return dict(zip(modes_list, MODES_NUMERIC)) 631 | # Filter default modes mapping for given entity 632 | return {ha: ya for ha, ya in self._default_modes_mapping.items() if ha in modes_list} 633 | 634 | 635 | class RangeEnum: 636 | def __init__(self, range1: Tuple[float, str], range2: Tuple[float, str], *args, enum_low: str, enum_high: str): 637 | self.enum_low = enum_low 638 | self.enum_high = enum_high 639 | self.ranges = dict(sorted([range1, range2, *args])) 640 | 641 | 642 | class _ModeCapability(_CompatibleCapability): 643 | """Base class of capabilities with mode functionality like thermostat mode 644 | or fan speed. 645 | 646 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-docpage/ 647 | """ 648 | 649 | type = CAPABILITIES_MODE 650 | 651 | # Yandex modes 652 | internal_modes: Sequence[str] = NotImplemented 653 | 654 | # Key for configuration overrides 655 | custom_modes_key: Optional[str] = NotImplemented 656 | 657 | # Service with domain to call for setting new value 658 | # Must be implemented, unless mode is override-only 659 | # (Domain, Required feature) -> Mode Compatibility Config 660 | _compatibility_configs: Iterable[ModeCompatibilityConfig] = NotImplemented 661 | compatibility_config: Optional[ModeCompatibilityConfig] = None 662 | 663 | def __init__(self, hass: HomeAssistantType, state: State, entity_config: Dict): 664 | """Mode capability initializer.""" 665 | super().__init__(hass, state, entity_config) 666 | if self.use_override: 667 | # Generate set script 668 | override_config = self.get_override_config(entity_config) 669 | self.set_script = Script(hass, override_config[CONF_SET_SCRIPT]) 670 | 671 | # Intended for overriding 672 | @classmethod 673 | def _get_custom_parameters_mapping(cls, entity_config: Dict): 674 | """ 675 | Get custom modes mapping of Yandex modes to entity modes (Yandex => HA). 676 | :param entity_config: Entity config 677 | """ 678 | if cls.custom_modes_key is not NotImplemented: 679 | custom_modes = entity_config.get(cls.custom_modes_key) 680 | if isinstance(custom_modes, Mapping): 681 | return dict(custom_modes) 682 | 683 | # Default implementations 684 | def get_modes_mapping(self) -> Optional[Dict[str, str]]: 685 | """ 686 | Get modes mapping of entity modes to Yandex modes (HA => Yandex). 687 | This method checks whether common custom configurations for modes 688 | are present, and runs default mapping fetching if otherwise. 689 | :return: Mapping | None (when entity explicitly does not support this capability) 690 | """ 691 | custom_mapping = self._get_custom_parameters_mapping(self.entity_config) 692 | if custom_mapping is not None: 693 | # convert (Yandex => HA) to (HA => Yandex) 694 | return {v: k for k, v in custom_mapping.items()} 695 | 696 | return self.compatibility_config.get_default_modes_mapping(self.state.attributes) 697 | 698 | def parameters_default(self) -> Dict[str, Any]: 699 | """Get default parameters""" 700 | return { 701 | "instance": self.instance, 702 | "modes": [ 703 | {"value": v} 704 | for v in set(self.get_modes_mapping().values()) 705 | ] 706 | } 707 | 708 | def get_value_default(self) -> Optional[str]: 709 | """Return the state value of this capability for this entity.""" 710 | mapping = self.get_modes_mapping() 711 | ent_modes = list(mapping.keys()) 712 | 713 | mode_attr = self.compatibility_config.mode_attr 714 | ent_mode = self.state.attributes.get(mode_attr) 715 | if ent_mode is None or ent_mode not in ent_modes: 716 | return self.internal_modes[0] 717 | 718 | return list(mapping.values())[ent_modes.index(ent_mode)] 719 | 720 | async def set_state_default(self, data: 'RequestData', state: Dict[str, Any]) -> None: 721 | mapping = self.get_modes_mapping() 722 | new_mode = state["value"] 723 | 724 | yandex_modes = list(mapping.values()) 725 | 726 | if new_mode not in yandex_modes: 727 | raise SmartHomeError(ERR_INVALID_VALUE, "Unacceptable value") 728 | 729 | new_ent_mode = list(mapping.keys())[yandex_modes.index(new_mode)] 730 | 731 | await self.hass.services.async_call( 732 | domain=self.state.domain, 733 | service=self.compatibility_config.service_id, 734 | service_data={ 735 | ATTR_ENTITY_ID: self.state.entity_id, 736 | self.compatibility_config.service_attr: new_ent_mode 737 | }, 738 | blocking=True, 739 | context=data.context 740 | ) 741 | 742 | # Override implementation 743 | @classmethod 744 | def has_override(cls, domain: str, entity_config: Dict, attributes: Dict) -> bool: 745 | """Determine whether mode capability has an override.""" 746 | return bool(cls.get_override_config(entity_config)) 747 | 748 | @classmethod 749 | def get_override_config(cls, entity_config: Dict) -> Optional[Dict[str, Any]]: 750 | """Return override entity ID for modes.""" 751 | modes_config = entity_config.get(CONF_ENTITY_MODES) 752 | if modes_config: 753 | return modes_config.get(cls.instance) 754 | 755 | def parameters_override(self) -> Dict[str, Any]: 756 | override_config = self.get_override_config(self.entity_config) 757 | iterator = override_config[CONF_MAPPING].keys() if CONF_MAPPING in override_config \ 758 | else self.internal_modes 759 | 760 | return { 761 | "instance": self.instance, 762 | "modes": [{"value": v} for v in iterator] 763 | } 764 | 765 | def get_value_override(self) -> Optional[Union[str, float, int]]: 766 | override_config = self.get_override_config(self.entity_config) 767 | 768 | override_entity_state = self.hass.states.get(override_config[CONF_ENTITY_ID]) 769 | if override_entity_state: 770 | if CONF_MAPPING in override_config: 771 | for yandex_mode, states in override_config[CONF_MAPPING]: 772 | if override_entity_state.state in states: 773 | return yandex_mode 774 | 775 | elif override_entity_state.state in self.internal_modes: 776 | return override_entity_state.state 777 | 778 | raise SmartHomeError( 779 | ERR_INTERNAL_ERROR, 780 | msg='Mapping for state "%s" unavailable for entity "%s"' 781 | % (override_entity_state.state, override_entity_state.entity_id) 782 | ) 783 | 784 | return self.internal_modes[0] 785 | 786 | async def set_state_override(self, data: 'RequestData', state: Dict): 787 | override_config = self.get_override_config(self.entity_config) 788 | value = state['value'] 789 | 790 | if CONF_MAPPING in override_config: 791 | if value not in override_config: 792 | raise SmartHomeError(ERR_INVALID_VALUE, msg="Unsupported mode") 793 | value = override_config[value][0] 794 | 795 | await self.set_script.async_run({ 796 | ATTR_VALUE: value, 797 | ATTR_ENTITY_ID: override_config[CONF_ENTITY_ID] 798 | }, context=data.context) 799 | 800 | 801 | @register_capability 802 | class ProgramCapability(_ModeCapability): 803 | """Program functionality.""" 804 | 805 | instance = "program" 806 | custom_modes_key = CONF_PROGRAMS 807 | internal_modes = MODES_NUMERIC 808 | 809 | _compatibility_configs = [ 810 | ModeCompatibilityConfig( 811 | domain=climate.DOMAIN, 812 | mode_attr=climate.ATTR_PRESET_MODE, 813 | modes_list_attr=climate.ATTR_PRESET_MODES, 814 | service_id=climate.SERVICE_SET_PRESET_MODE, 815 | required_feature=climate.SUPPORT_PRESET_MODE, 816 | ), 817 | ModeCompatibilityConfig( 818 | domain=light.DOMAIN, 819 | mode_attr=light.ATTR_EFFECT, 820 | modes_list_attr=light.ATTR_EFFECT_LIST, 821 | service_id=light.SERVICE_TURN_ON, 822 | required_feature=light.SUPPORT_EFFECT, 823 | ), 824 | ] 825 | 826 | 827 | @register_capability 828 | class InputSourceCapability(_ModeCapability): 829 | """Input Source functionality""" 830 | 831 | instance = "input_source" 832 | custom_modes_key = CONF_INPUT_SOURCES 833 | internal_modes = MODES_NUMERIC 834 | 835 | _compatibility_configs = [ 836 | ModeCompatibilityConfig( 837 | domain=media_player.DOMAIN, 838 | mode_attr=media_player.ATTR_INPUT_SOURCE, 839 | modes_list_attr=media_player.ATTR_INPUT_SOURCE_LIST, 840 | service_id=media_player.SERVICE_SELECT_SOURCE, 841 | required_feature=media_player.SUPPORT_SELECT_SOURCE, 842 | ), 843 | ] 844 | 845 | 846 | @register_capability 847 | class ThermostatCapability(_ModeCapability): 848 | """Thermostat functionality""" 849 | 850 | instance = 'thermostat' 851 | internal_modes = ('auto', 'cool', 'dry', 'fan_only', 'heat', 'preheat') 852 | 853 | _compatibility_configs = { 854 | ModeCompatibilityConfig( 855 | domain=climate.DOMAIN, 856 | mode_attr=climate.ATTR_HVAC_MODE, 857 | modes_list_attr=climate.ATTR_HVAC_MODES, 858 | service_id=climate.SERVICE_SET_HVAC_MODE, 859 | default_modes_mapping={ 860 | climate.const.HVAC_MODE_AUTO: internal_modes[0], 861 | climate.const.HVAC_MODE_COOL: internal_modes[1], 862 | climate.const.HVAC_MODE_DRY: internal_modes[2], 863 | climate.const.HVAC_MODE_FAN_ONLY: internal_modes[3], 864 | climate.const.HVAC_MODE_HEAT: internal_modes[4], 865 | } 866 | ) 867 | } 868 | 869 | @register_capability 870 | class FanSpeedCapability(_ModeCapability): 871 | """Fan speed functionality.""" 872 | 873 | instance = 'fan_speed' 874 | internal_modes = ("auto", "low", "medium", "high", "turbo") 875 | 876 | __default_modes_mapping = {m: k for k, v in { 877 | internal_modes[0]: ['auto', 'Automatic'], 878 | internal_modes[1]: ['low', 'min', 'minimum', 'Quiet', 'silent'], 879 | internal_modes[2]: ['medium', 'middle'], 880 | internal_modes[3]: ['favorite', 'high', 'max', 'Max', 'maximum', 'strong'], 881 | }.items() for m in v} 882 | 883 | _compatibility_configs = { 884 | ModeCompatibilityConfig( 885 | domain=fan.DOMAIN, 886 | mode_attr=fan.ATTR_SPEED, 887 | modes_list_attr=fan.ATTR_SPEED_LIST, 888 | service_id=fan.SERVICE_SET_SPEED, 889 | required_feature=fan.SUPPORT_SET_SPEED, 890 | default_modes_mapping=__default_modes_mapping, 891 | ), 892 | ModeCompatibilityConfig( 893 | domain=climate.DOMAIN, 894 | mode_attr=climate.ATTR_FAN_MODE, 895 | modes_list_attr=climate.ATTR_FAN_MODES, 896 | service_id=climate.SERVICE_SET_FAN_MODE, 897 | required_feature=climate.SUPPORT_FAN_MODE, 898 | default_modes_mapping=__default_modes_mapping, 899 | ), 900 | ModeCompatibilityConfig( 901 | domain=vacuum.DOMAIN, 902 | mode_attr=vacuum.ATTR_FAN_SPEED, 903 | modes_list_attr=vacuum.ATTR_FAN_SPEED_LIST, 904 | service_id=vacuum.SERVICE_SET_FAN_SPEED, 905 | required_feature=vacuum.SUPPORT_FAN_SPEED, 906 | default_modes_mapping=__default_modes_mapping, 907 | ), 908 | } 909 | 910 | 911 | @register_capability 912 | class CleanupModeCapability(_ModeCapability): 913 | """Cleanup mode functionality.""" 914 | 915 | instance = "cleanup_mode" 916 | internal_modes = ("auto", "eco", "express", "normal", "quiet") 917 | 918 | @classmethod 919 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 920 | return False 921 | 922 | 923 | @register_capability 924 | class SwingCapability(_ModeCapability): 925 | """Swing capability""" 926 | 927 | instance = "swing" 928 | internal_modes = ("auto", "horizontal", "stationary", "vertical") 929 | 930 | _compatibility_configs = [ 931 | ModeCompatibilityConfig( 932 | domain=climate.DOMAIN, 933 | mode_attr=climate.ATTR_SWING_MODE, 934 | modes_list_attr=climate.ATTR_SWING_MODES, 935 | service_id=climate.SERVICE_SET_SWING_MODE, 936 | required_feature=climate.SUPPORT_SWING_MODE, 937 | default_modes_mapping={ 938 | climate.const.SWING_BOTH: internal_modes[0], 939 | climate.const.SWING_HORIZONTAL: internal_modes[1], 940 | climate.const.SWING_OFF: internal_modes[2], 941 | climate.const.SWING_VERTICAL: internal_modes[3], 942 | }, 943 | ), 944 | ] 945 | 946 | 947 | class _RangeCapability(_Capability): 948 | """Base class of capabilities with range functionality like volume or 949 | brightness. 950 | 951 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-docpage/ 952 | """ 953 | 954 | type = CAPABILITIES_RANGE 955 | unit: Optional[str] = NotImplemented 956 | retrievable = True 957 | 958 | @property 959 | def min_max_precision(self) -> Optional[Tuple[Union[int, float], Union[int, float], Union[int, float]]]: 960 | return None 961 | 962 | @property 963 | def random_access(self) -> bool: 964 | return True 965 | 966 | @classmethod 967 | def has_override(cls, domain: str, entity_config: Dict, attributes: Dict) -> bool: 968 | """Determine whether mode capability has an override.""" 969 | return bool(cls.get_override_config(entity_config)) 970 | 971 | @classmethod 972 | def get_override_config(cls, entity_config: Dict) -> Optional[Dict[str, Any]]: 973 | """Return override entity ID for modes.""" 974 | modes_config = entity_config.get(CONF_ENTITY_RANGES) 975 | if modes_config: 976 | return modes_config.get(cls.instance) 977 | 978 | def parameters_default(self) -> Dict[str, Any]: 979 | """Return parameters for a devices request.""" 980 | parameters = { 981 | "instance": self.instance, 982 | "random_access": self.random_access, 983 | } 984 | 985 | min_max_precision = self.min_max_precision 986 | if min_max_precision is not None: 987 | parameters['range'] = dict(zip(['min', 'max', 'precision'], min_max_precision)) 988 | 989 | if self.unit is not None: 990 | parameters['unit'] = self.unit 991 | 992 | return parameters 993 | 994 | def parameters_override(self) -> Dict[str, Any]: 995 | override_config = self.get_override_config(self.entity_config) 996 | 997 | parameters = { 998 | "instance": self.instance, 999 | "random_access": True, 1000 | "range": { 1001 | "max": override_config[CONF_MAXIMUM], 1002 | "min": override_config[CONF_MINIMUM], 1003 | "precision": override_config[CONF_PRECISION], 1004 | } 1005 | } 1006 | 1007 | if self.unit: 1008 | parameters['unit'] = self.unit 1009 | 1010 | return parameters 1011 | 1012 | def get_value_override(self) -> Optional[Union[str, float, int]]: 1013 | override_config = self.get_override_config(self.entity_config) 1014 | 1015 | override_entity_state = self.hass.states.get(override_config[CONF_ENTITY_ID]) 1016 | if override_entity_state: 1017 | try: 1018 | source_state = float(override_entity_state) 1019 | 1020 | except ValueError: 1021 | source_state = 0 1022 | 1023 | value = source_state / override_config[CONF_MULTIPLIER] 1024 | 1025 | return min(override_config[CONF_MAXIMUM], max(override_config[CONF_MINIMUM], value)) 1026 | 1027 | return override_config[CONF_MINIMUM] 1028 | 1029 | async def set_state_override(self, data: 'RequestData', state: Dict): 1030 | override_config = self.get_override_config(self.entity_config) 1031 | value = float(state['value']) * override_config[CONF_MULTIPLIER] 1032 | script_object = Script(self.hass, override_config[CONF_SET_SCRIPT]) 1033 | 1034 | await script_object.async_run({ 1035 | 'value': value, 1036 | 'entity_id': override_config[CONF_ENTITY_ID] 1037 | }, context=data.context) 1038 | 1039 | 1040 | @register_capability 1041 | class HumidityCapability(_RangeCapability): 1042 | """Set humidity functionality.""" 1043 | 1044 | instance = 'humidity' 1045 | unit = "unit.percent" 1046 | 1047 | ATTR_TARGET_HUMIDITY = "target_humidity" 1048 | ATTR_CURRENT_HUMIDITY = "current_humidity" 1049 | ATTR_HUMIDITY_STEP = "humidity_step" 1050 | ATTR_SERVICE_SET_HUMIDITY = "set_humidity" 1051 | ATTR_MIN_HUMIDITY = "min_humidity" 1052 | ATTR_MAX_HUMIDITY = "max_humidity" 1053 | SERVICE_PARAMS = "service_config" 1054 | 1055 | supported_humidifiers = { 1056 | climate.DOMAIN: [ 1057 | { # Default entity support 1058 | ATTR_TARGET_HUMIDITY: climate.ATTR_HUMIDITY, 1059 | ATTR_CURRENT_HUMIDITY: climate.ATTR_CURRENT_HUMIDITY, 1060 | ATTR_SERVICE_SET_HUMIDITY: (climate.DOMAIN, climate.SERVICE_SET_HUMIDITY), 1061 | ATTR_MIN_HUMIDITY: climate.ATTR_MIN_HUMIDITY, 1062 | ATTR_MAX_HUMIDITY: climate.ATTR_MAX_HUMIDITY, 1063 | ATTR_HUMIDITY_STEP: 1, 1064 | SERVICE_PARAMS: lambda humidity: {climate.ATTR_HUMIDITY: humidity} 1065 | } 1066 | ], 1067 | } 1068 | 1069 | def __init__(self, hass: HomeAssistantType, state: State, entity_config): 1070 | super().__init__(hass, state, entity_config) 1071 | 1072 | parameters = self._get_access_parameters(state.domain, state.attributes) 1073 | if parameters is None: 1074 | raise ValueError('Unsupported entity state') 1075 | 1076 | domain, attributes = parameters 1077 | self._service_domain = domain 1078 | self._attrs = attributes 1079 | 1080 | @classmethod 1081 | def _get_access_parameters(cls, domain: str, attributes: Dict[str, Any]) -> Optional[dict]: 1082 | access_parameters = cls.supported_humidifiers.get(domain) 1083 | if access_parameters: 1084 | for attr_config in access_parameters: 1085 | if all([attr_config[a] in attributes for a in [cls.ATTR_CURRENT_HUMIDITY, cls.ATTR_TARGET_HUMIDITY]]): 1086 | return attr_config 1087 | 1088 | def _get_entity_attribute(self, attribute_type: str): 1089 | """ 1090 | Get attribute from skimmed entity attributes. 1091 | :param attribute_type: Attribute from supported attributes 1092 | :return: 1093 | """ 1094 | return self.state.attributes.get(self._attrs[attribute_type]) 1095 | 1096 | @property 1097 | def min_max_precision(self) -> Tuple[Union[int, float], Union[int, float], Union[int, float]]: 1098 | """Return min / max / precision values.""" 1099 | return ( 1100 | self._get_entity_attribute(self.ATTR_MIN_HUMIDITY), 1101 | self._get_entity_attribute(self.ATTR_MAX_HUMIDITY), 1102 | self._get_entity_attribute(self.ATTR_HUMIDITY_STEP) 1103 | ) 1104 | 1105 | @classmethod 1106 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 1107 | """Test if state is supported.""" 1108 | return bool(cls._get_access_parameters(domain, attributes)) 1109 | 1110 | def get_value_default(self) -> Optional[Union[str, float, int]]: 1111 | return self._get_entity_attribute(self.ATTR_CURRENT_HUMIDITY) 1112 | 1113 | async def set_state_default(self, data: 'RequestData', state: Dict) -> None: 1114 | """ 1115 | Set target humidity (default variant). 1116 | :param data: Request data 1117 | :param state: Requested state 1118 | """ 1119 | domain, service = self._attrs[climate.SERVICE_SET_HUMIDITY] 1120 | 1121 | service_params = {ATTR_ENTITY_ID: self.state.entity_id} 1122 | service_params.update(self._attrs[self.SERVICE_PARAMS](state['value'])) 1123 | 1124 | self.hass.services.async_call(domain, service, service_params, blocking=True, context=data.context) 1125 | 1126 | 1127 | @register_capability 1128 | class TemperatureCapability(_RangeCapability): 1129 | """Set temperature functionality.""" 1130 | 1131 | instance = 'temperature' 1132 | unit = "unit.temperature.celsius" 1133 | 1134 | @classmethod 1135 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 1136 | """Test if state is supported.""" 1137 | if domain == water_heater.DOMAIN: 1138 | return features & water_heater.SUPPORT_TARGET_TEMPERATURE 1139 | 1140 | elif domain == climate.DOMAIN: 1141 | return features & climate.const.SUPPORT_TARGET_TEMPERATURE 1142 | 1143 | return False 1144 | 1145 | @property 1146 | def min_max_precision(self): 1147 | if self.state.domain == water_heater.DOMAIN: 1148 | min_temp = self.state.attributes.get(water_heater.ATTR_MIN_TEMP) 1149 | max_temp = self.state.attributes.get(water_heater.ATTR_MAX_TEMP) 1150 | elif self.state.domain == climate.DOMAIN: 1151 | min_temp = self.state.attributes.get(climate.ATTR_MIN_TEMP) 1152 | max_temp = self.state.attributes.get(climate.ATTR_MAX_TEMP) 1153 | else: 1154 | min_temp = 0 1155 | max_temp = 100 1156 | 1157 | return min_temp, max_temp, 0.5 1158 | 1159 | def get_value_default(self) -> Optional[Union[str, float, int]]: 1160 | """Return the state value of this capability for this entity.""" 1161 | temperature = None 1162 | if self.state.domain == water_heater.DOMAIN: 1163 | temperature = self.state.attributes.get(water_heater.ATTR_TEMPERATURE) 1164 | 1165 | elif self.state.domain == climate.DOMAIN: 1166 | temperature = self.state.attributes.get(climate.ATTR_TEMPERATURE) 1167 | 1168 | if temperature is None: 1169 | return 0 1170 | 1171 | return float(temperature) 1172 | 1173 | async def set_state_default(self, data: 'RequestData', state: Dict) -> None: 1174 | """Set device state.""" 1175 | 1176 | if self.state.domain == water_heater.DOMAIN: 1177 | service = water_heater.SERVICE_SET_TEMPERATURE 1178 | attr = water_heater.ATTR_TEMPERATURE 1179 | 1180 | elif self.state.domain == climate.DOMAIN: 1181 | service = climate.SERVICE_SET_TEMPERATURE 1182 | attr = climate.ATTR_TEMPERATURE 1183 | 1184 | else: 1185 | raise SmartHomeError(ERR_INVALID_VALUE, "Unsupported domain") 1186 | 1187 | await self.hass.services.async_call( 1188 | self.state.domain, 1189 | service, { 1190 | ATTR_ENTITY_ID: self.state.entity_id, 1191 | attr: state['value'] 1192 | }, blocking=True, context=data.context) 1193 | 1194 | 1195 | @register_capability 1196 | class BrightnessCapability(_RangeCapability): 1197 | """Set brightness functionality.""" 1198 | 1199 | instance = 'brightness' 1200 | unit = "unit.percent" 1201 | 1202 | @classmethod 1203 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 1204 | """Test if state is supported.""" 1205 | return domain == light.DOMAIN and features & light.SUPPORT_BRIGHTNESS 1206 | 1207 | @property 1208 | def min_max_precision(self) -> Tuple[Union[int, float], Union[int, float], Union[int, float]]: 1209 | return 0, 100, 1 1210 | 1211 | def get_value_default(self) -> Optional[Union[str, float, int]]: 1212 | """Return the state value of this capability for this entity.""" 1213 | brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) 1214 | if brightness is None: 1215 | return 0 1216 | 1217 | return int(100 * (brightness / 255)) 1218 | 1219 | async def set_state_default(self, data: 'RequestData', state: Dict): 1220 | """Set device state.""" 1221 | await self.hass.services.async_call( 1222 | light.DOMAIN, 1223 | light.SERVICE_TURN_ON, { 1224 | ATTR_ENTITY_ID: self.state.entity_id, 1225 | light.ATTR_BRIGHTNESS_PCT: state['value'] 1226 | }, blocking=True, context=data.context) 1227 | 1228 | 1229 | @register_capability 1230 | class VolumeCapability(_RangeCapability): 1231 | """Set volume functionality.""" 1232 | 1233 | instance = 'volume' 1234 | unit = None 1235 | 1236 | def __init__(self, hass: HomeAssistantType, state: State, entity_config: Dict[str, Any]): 1237 | super().__init__(hass, state, entity_config) 1238 | features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) 1239 | self.retrievable = ( 1240 | self.has_override(state.domain, entity_config, state.attributes) 1241 | or features & media_player.SUPPORT_VOLUME_SET != 0 1242 | ) 1243 | 1244 | @classmethod 1245 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 1246 | """Test if state is supported.""" 1247 | return bool(domain == media_player.DOMAIN and features & media_player.SUPPORT_VOLUME_STEP) 1248 | 1249 | @property 1250 | def random_access(self) -> bool: 1251 | return not self.is_relative_volume_only() 1252 | 1253 | @property 1254 | def min_max_precision(self) -> Tuple[Union[int, float], Union[int, float], Union[int, float]]: 1255 | return None if self.is_relative_volume_only() else (0, 100, 1) 1256 | 1257 | def is_relative_volume_only(self): 1258 | return not self.retrievable or self.entity_config.get( 1259 | CONF_RELATIVE_VOLUME_ONLY) 1260 | 1261 | def get_value_default(self) -> Optional[Union[str, float, int]]: 1262 | """Return the state value of this capability for this entity.""" 1263 | level = self.state.attributes.get( 1264 | media_player.ATTR_MEDIA_VOLUME_LEVEL) 1265 | if level is None: 1266 | return 0 1267 | else: 1268 | return int(level * 100) 1269 | 1270 | async def set_state_default(self, data: 'RequestData', state: Dict): 1271 | """Set device state.""" 1272 | if self.is_relative_volume_only(): 1273 | if state['value'] > 0: 1274 | service = media_player.SERVICE_VOLUME_UP 1275 | else: 1276 | service = media_player.SERVICE_VOLUME_DOWN 1277 | await self.hass.services.async_call( 1278 | media_player.DOMAIN, 1279 | service, { 1280 | ATTR_ENTITY_ID: self.state.entity_id 1281 | }, blocking=True, context=data.context) 1282 | else: 1283 | await self.hass.services.async_call( 1284 | media_player.DOMAIN, 1285 | media_player.SERVICE_VOLUME_SET, { 1286 | ATTR_ENTITY_ID: self.state.entity_id, 1287 | media_player.const.ATTR_MEDIA_VOLUME_LEVEL: 1288 | state['value'] / 100, 1289 | }, blocking=True, context=data.context) 1290 | 1291 | 1292 | @register_capability 1293 | class ChannelCapability(_RangeCapability): 1294 | """Set channel functionality.""" 1295 | 1296 | instance = 'channel' 1297 | unit = None 1298 | 1299 | script_channel_up = None 1300 | script_channel_down = None 1301 | 1302 | def __init__(self, hass, state, config): 1303 | super().__init__(hass, state, config) 1304 | features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) 1305 | self.retrievable = features & media_player.SUPPORT_PLAY_MEDIA != 0 and \ 1306 | self.entity_config.get(CONF_CHANNEL_SET_VIA_MEDIA_CONTENT_ID) 1307 | 1308 | channel_up = config.get(CONF_SCRIPT_CHANNEL_UP) 1309 | if channel_up: 1310 | self.script_channel_up = Script(hass, channel_up) 1311 | 1312 | channel_down = config.get(CONF_SCRIPT_CHANNEL_DOWN) 1313 | if channel_down: 1314 | self.script_channel_down = Script(hass, channel_down) 1315 | 1316 | @classmethod 1317 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 1318 | """Test if state is supported.""" 1319 | if domain == media_player.DOMAIN: 1320 | return (features & media_player.SUPPORT_PLAY_MEDIA and 1321 | entity_config.get(CONF_CHANNEL_SET_VIA_MEDIA_CONTENT_ID) and 1322 | (features & media_player.SUPPORT_PREVIOUS_TRACK or 1323 | entity_config.get(CONF_SCRIPT_CHANNEL_DOWN)) and 1324 | (features & media_player.SUPPORT_NEXT_TRACK) or 1325 | entity_config.get(CONF_SCRIPT_CHANNEL_UP)) 1326 | 1327 | return False 1328 | 1329 | @property 1330 | def min_max_precision(self) -> Optional[Tuple[Union[int, float], Union[int, float], Union[int, float]]]: 1331 | return (0, 999, 1) if self.retrievable else None 1332 | 1333 | @property 1334 | def random_access(self) -> bool: 1335 | return self.retrievable 1336 | 1337 | def get_value_default(self) -> Optional[Union[str, float, int]]: 1338 | """Return the state value of this capability for this entity.""" 1339 | if not self.retrievable or self.state.attributes.get( 1340 | media_player.ATTR_MEDIA_CONTENT_TYPE) \ 1341 | != media_player.const.MEDIA_TYPE_CHANNEL: 1342 | return 0 1343 | 1344 | try: 1345 | return int(self.state.attributes.get( 1346 | media_player.ATTR_MEDIA_CONTENT_ID)) 1347 | 1348 | except ValueError: 1349 | return 0 1350 | 1351 | except TypeError: 1352 | return 0 1353 | 1354 | async def set_state_default(self, data: 'RequestData', state: Dict): 1355 | """Set device state.""" 1356 | if 'relative' in state and state['relative']: 1357 | if state['value'] > 0: 1358 | if self.script_channel_up: 1359 | await self.script_channel_up.async_run({ 1360 | ATTR_ENTITY_ID: self.state.entity_id, 1361 | }, context=data.context) 1362 | return 1363 | else: 1364 | service = media_player.SERVICE_MEDIA_NEXT_TRACK 1365 | else: 1366 | if self.script_channel_down: 1367 | await self.script_channel_down.async_run({ 1368 | ATTR_ENTITY_ID: self.state.entity_id, 1369 | }, context=data.context) 1370 | return 1371 | else: 1372 | service = media_player.SERVICE_MEDIA_PREVIOUS_TRACK 1373 | 1374 | await self.hass.services.async_call( 1375 | media_player.DOMAIN, 1376 | service, { 1377 | ATTR_ENTITY_ID: self.state.entity_id 1378 | }, blocking=True, context=data.context) 1379 | 1380 | else: 1381 | await self.hass.services.async_call( 1382 | media_player.DOMAIN, 1383 | media_player.SERVICE_PLAY_MEDIA, { 1384 | ATTR_ENTITY_ID: self.state.entity_id, 1385 | media_player.const.ATTR_MEDIA_CONTENT_ID: state['value'], 1386 | media_player.const.ATTR_MEDIA_CONTENT_TYPE: 1387 | media_player.const.MEDIA_TYPE_CHANNEL, 1388 | }, blocking=True, context=data.context) 1389 | 1390 | 1391 | @register_capability 1392 | class OpenCapability(_RangeCapability): 1393 | instance = "open" 1394 | unit = None 1395 | 1396 | @classmethod 1397 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 1398 | if domain == cover.DOMAIN: 1399 | return features & cover.SUPPORT_SET_POSITION 1400 | 1401 | return False 1402 | 1403 | @property 1404 | def min_max_precision(self) -> Optional[Tuple[Union[int, float], Union[int, float], Union[int, float]]]: 1405 | return 0, 100, 1 1406 | 1407 | @property 1408 | def random_access(self) -> bool: 1409 | return True 1410 | 1411 | def get_value_default(self) -> Optional[Union[str, float, int]]: 1412 | return self.state.attributes.get(cover.ATTR_CURRENT_POSITION) 1413 | 1414 | async def set_state_default(self, data: 'RequestData', state: Dict): 1415 | await self.hass.services.async_call( 1416 | cover.DOMAIN, 1417 | cover.SERVICE_SET_COVER_POSITION, { 1418 | ATTR_ENTITY_ID: self.state.entity_id, 1419 | cover.ATTR_POSITION: state['value'] 1420 | }, blocking=True, context=data.context) 1421 | 1422 | 1423 | class _ColorSettingCapability(_Capability): 1424 | """Base color setting functionality. 1425 | 1426 | https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/color_setting-docpage/ 1427 | """ 1428 | 1429 | type = CAPABILITIES_COLOR_SETTING 1430 | 1431 | def parameters_default(self): 1432 | """Return parameters for a devices request.""" 1433 | result = {} 1434 | 1435 | features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) 1436 | 1437 | if features & light.SUPPORT_COLOR: 1438 | result['color_model'] = 'rgb' 1439 | 1440 | if features & light.SUPPORT_COLOR_TEMP: 1441 | max_temp = self.state.attributes[light.ATTR_MIN_MIREDS] 1442 | min_temp = self.state.attributes[light.ATTR_MAX_MIREDS] 1443 | result['temperature_k'] = { 1444 | 'min': color_util.color_temperature_mired_to_kelvin(min_temp), 1445 | 'max': color_util.color_temperature_mired_to_kelvin(max_temp) 1446 | } 1447 | 1448 | return result 1449 | 1450 | @classmethod 1451 | def has_override(cls, domain: str, entity_config: Dict, attributes: Dict) -> bool: 1452 | return False 1453 | 1454 | 1455 | @register_capability 1456 | class RgbCapability(_ColorSettingCapability): 1457 | """RGB color functionality.""" 1458 | 1459 | instance = 'rgb' 1460 | 1461 | @classmethod 1462 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 1463 | """Test if state is supported.""" 1464 | return domain == light.DOMAIN and features & light.SUPPORT_COLOR 1465 | 1466 | def get_value_default(self) -> Optional[Union[str, float, int]]: 1467 | """Return the state value of this capability for this entity.""" 1468 | color = self.state.attributes.get(light.ATTR_RGB_COLOR) 1469 | if color is None: 1470 | return 0 1471 | 1472 | rgb = color[0] 1473 | rgb = (rgb << 8) + color[1] 1474 | rgb = (rgb << 8) + color[2] 1475 | 1476 | return rgb 1477 | 1478 | async def set_state_default(self, data: 'RequestData', state: Dict) -> None: 1479 | """Set device state.""" 1480 | red = (state['value'] >> 16) & 0xFF 1481 | green = (state['value'] >> 8) & 0xFF 1482 | blue = state['value'] & 0xFF 1483 | 1484 | await self.hass.services.async_call( 1485 | light.DOMAIN, 1486 | light.SERVICE_TURN_ON, { 1487 | ATTR_ENTITY_ID: self.state.entity_id, 1488 | light.ATTR_RGB_COLOR: (red, green, blue) 1489 | }, blocking=True, context=data.context) 1490 | 1491 | 1492 | @register_capability 1493 | class TemperatureKCapability(_ColorSettingCapability): 1494 | """Color temperature functionality.""" 1495 | 1496 | instance = 'temperature_k' 1497 | 1498 | @classmethod 1499 | def supported(cls, domain: str, features: int, entity_config: Dict, attributes: Dict) -> bool: 1500 | """Test if state is supported.""" 1501 | return domain == light.DOMAIN and features & light.SUPPORT_COLOR_TEMP 1502 | 1503 | def get_value_default(self) -> Optional[Union[str, float, int]]: 1504 | """Return the state value of this capability for this entity.""" 1505 | kelvin = self.state.attributes.get(light.ATTR_COLOR_TEMP) 1506 | if kelvin is None: 1507 | return 0 1508 | 1509 | return color_util.color_temperature_mired_to_kelvin(kelvin) 1510 | 1511 | async def set_state_default(self, data: 'RequestData', state: Dict) -> None: 1512 | """Set device state.""" 1513 | await self.hass.services.async_call( 1514 | light.DOMAIN, 1515 | light.SERVICE_TURN_ON, { 1516 | ATTR_ENTITY_ID: self.state.entity_id, 1517 | light.ATTR_KELVIN: state['value'] 1518 | }, blocking=True, context=data.context) 1519 | --------------------------------------------------------------------------------