├── .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 | >[](https://github.com/dmitry-k/yandex_smart_home)
10 | >[](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 | [](https://github.com/custom-components/hacs)
2 |
3 | [](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 | [](https://github.com/alryaz/hass-component-yandex-smart-home)
3 | [](https://money.yandex.ru/to/410012369233217)
4 | [](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 |
--------------------------------------------------------------------------------