first commit

This commit is contained in:
Your Name
2024-01-11 15:10:51 +03:00
commit eddf07c652
602 changed files with 67154 additions and 0 deletions
@@ -0,0 +1,293 @@
import asyncio
import json
import logging
import voluptuous as vol
from homeassistant.components.media_player import ATTR_MEDIA_CONTENT_ID, \
ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, ATTR_ENTITY_ID, \
EVENT_HOMEASSISTANT_STOP, CONF_TOKEN, CONF_INCLUDE, CONF_DEVICES, \
CONF_HOST, CONF_PORT, CONF_NAME
from homeassistant.core import ServiceCall, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .core import utils
from .core.const import *
from .core.yandex_glagol import YandexIOListener
from .core.yandex_quasar import YandexQuasar
from .core.yandex_session import YandexSession
_LOGGER = logging.getLogger(__name__)
MAIN_DOMAINS = ['media_player', 'select']
SUB_DOMAINS = [
'climate', 'light', 'remote', 'switch', 'vacuum', 'humidifier', 'sensor',
'water_heater'
]
CONF_TTS_NAME = 'tts_service_name'
CONF_INTENTS = 'intents'
CONF_DEBUG = 'debug'
CONF_RECOGNITION_LANG = 'recognition_lang'
CONF_PROXY = 'proxy'
DATA_SPEAKERS = 'speakers'
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
vol.Optional(CONF_TOKEN): cv.string,
vol.Optional(CONF_TTS_NAME): cv.string,
vol.Optional(CONF_INTENTS): dict,
vol.Optional(CONF_INCLUDE): cv.ensure_list,
vol.Optional(CONF_DEVICES): {
cv.string: vol.Schema({
vol.Optional(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=1961): cv.port,
}, extra=vol.ALLOW_EXTRA),
},
vol.Optional(CONF_MEDIA_PLAYERS): vol.Any(dict, list),
vol.Optional(CONF_RECOGNITION_LANG): cv.string,
vol.Optional(CONF_PROXY): cv.string,
vol.Optional(CONF_DEBUG, default=False): cv.boolean,
}, extra=vol.ALLOW_EXTRA),
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass: HomeAssistant, hass_config: dict):
"""Main setup of component."""
config: dict = hass_config.get(DOMAIN) or {}
hass.data[DOMAIN] = {
DATA_CONFIG: config,
DATA_SPEAKERS: {}
}
YandexSession.proxy = config.get(CONF_PROXY)
await _init_local_discovery(hass)
await _init_services(hass)
await _setup_entry_from_config(hass)
hass.http.register_view(utils.StreamingView(hass))
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
async def update_cookie_and_token(**kwargs):
hass.config_entries.async_update_entry(entry, data=kwargs)
session = async_create_clientsession(hass)
yandex = YandexSession(session, **entry.data)
yandex.add_update_listener(update_cookie_and_token)
try:
ok = await yandex.refresh_cookies()
except Exception as e:
raise ConfigEntryNotReady from e
if not ok:
hass.components.persistent_notification.async_create(
"Необходимо заново авторизоваться в Яндексе. Для этого [добавьте "
"новую интеграцию](/config/integrations) с тем же логином.",
title="Yandex.Station")
return False
quasar = YandexQuasar(yandex)
await quasar.init()
# entry.unique_id - user login
hass.data[DOMAIN][entry.unique_id] = quasar
# add stations to global list
speakers = hass.data[DOMAIN][DATA_SPEAKERS]
for device in quasar.speakers + quasar.modules:
did = device['quasar_info']['device_id']
if did in speakers:
device.update(speakers[did])
speakers[did] = device
await _setup_intents(hass, quasar)
await _setup_include(hass, entry)
await _setup_devices(hass, quasar)
async def speaker_update(payload: dict):
hass.bus.async_fire('yandex_speaker', payload)
quasar.handle_updates(speaker_update)
for domain in MAIN_DOMAINS:
hass.async_create_task(hass.config_entries.async_forward_entry_setup(
entry, domain
))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
quasar: YandexQuasar = hass.data[DOMAIN][entry.unique_id]
quasar.stop()
await asyncio.gather(*[
hass.config_entries.async_forward_entry_unload(entry, domain)
for domain in MAIN_DOMAINS + SUB_DOMAINS
])
return True
async def _init_local_discovery(hass: HomeAssistant):
"""Init descovery local speakers with Zeroconf (mDNS)."""
speakers: dict = hass.data[DOMAIN][DATA_SPEAKERS]
async def found_local_speaker(info: dict):
speaker = speakers.setdefault(info['device_id'], {})
speaker.update(info)
if 'entity' in speaker:
entity: "YandexStation" = speaker['entity']
await entity.init_local_mode()
entity.async_write_ha_state()
zeroconf = await utils.get_zeroconf_singleton(hass)
listener = YandexIOListener(hass.loop)
listener.start(found_local_speaker, zeroconf)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, listener.stop)
async def _init_services(hass: HomeAssistant):
"""Init Yandex Station TTS service."""
speakers: dict = hass.data[DOMAIN][DATA_SPEAKERS]
async def send_command(call: ServiceCall):
data = dict(call.data)
device = data.pop('device', None)
entity_ids = (data.pop(ATTR_ENTITY_ID, None) or
utils.find_station(speakers.values(), device))
_LOGGER.debug(f"Send command to: {entity_ids}")
if not entity_ids:
_LOGGER.error("Entity_id parameter required")
return
data = {
ATTR_ENTITY_ID: entity_ids,
ATTR_MEDIA_CONTENT_ID: data.get('text'),
ATTR_MEDIA_CONTENT_TYPE: 'dialog',
} if data.get('command') == 'dialog' else {
ATTR_ENTITY_ID: entity_ids,
ATTR_MEDIA_CONTENT_ID: json.dumps(data),
ATTR_MEDIA_CONTENT_TYPE: 'json',
}
await hass.services.async_call(DOMAIN_MP, SERVICE_PLAY_MEDIA, data,
blocking=True)
hass.services.async_register(DOMAIN, 'send_command', send_command)
async def yandex_station_say(call: ServiceCall):
entity_ids = (call.data.get(ATTR_ENTITY_ID) or
utils.find_station(speakers.values()))
_LOGGER.debug(f"Yandex say to: {entity_ids}")
if not entity_ids:
_LOGGER.error("Entity_id parameter required")
return
message = call.data.get('message')
data = {
ATTR_MEDIA_CONTENT_ID: message,
ATTR_MEDIA_CONTENT_TYPE: 'tts',
ATTR_ENTITY_ID: entity_ids,
}
if 'options' in call.data:
data['extra'] = call.data['options']
await hass.services.async_call(DOMAIN_MP, SERVICE_PLAY_MEDIA, data,
blocking=True)
config = hass.data[DOMAIN][DATA_CONFIG]
service_name = config.get(CONF_TTS_NAME, 'yandex_station_say')
hass.services.async_register('tts', service_name, yandex_station_say)
async def _setup_entry_from_config(hass: HomeAssistant):
"""Support legacy config from YAML."""
config = hass.data[DOMAIN][DATA_CONFIG]
if CONF_USERNAME not in config:
return
# check if already configured
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == config[CONF_USERNAME]:
return
# load config/.yandex_station.json
x_token = utils.load_token_from_json(hass)
if x_token:
config['x_token'] = x_token
# need username and token or password
if 'x_token' not in config and CONF_PASSWORD not in config:
return
hass.async_create_task(hass.config_entries.flow.async_init(
DOMAIN, context={'source': SOURCE_IMPORT}, data=config
))
async def _setup_intents(hass: HomeAssistant, quasar: YandexQuasar):
"""Setup Intents MediaPlayer and scenarios for Yandex Account."""
config = hass.data[DOMAIN][DATA_CONFIG]
if CONF_INTENTS not in config:
return
intents: dict = config[CONF_INTENTS]
if CONF_INTENTS not in hass.data[DOMAIN]:
hass.data[DOMAIN][CONF_INTENTS] = True
discovered = {CONF_INTENTS: list(intents.keys())}
hass.async_create_task(discovery.async_load_platform(
hass, DOMAIN_MP, DOMAIN, discovered, config))
if quasar.hass_id:
for i, intent in enumerate(intents.keys(), 1):
try:
await quasar.add_intent(intent, intents[intent], i)
except:
pass
async def _setup_devices(hass: HomeAssistant, quasar: YandexQuasar):
"""Set speakers additional config from YAML."""
config = hass.data[DOMAIN][DATA_CONFIG]
if CONF_DEVICES not in config:
return
confdevices = config[CONF_DEVICES]
for device in quasar.speakers + quasar.modules:
did = device['quasar_info']['device_id']
# support device_id in upper/lower cases
upd = confdevices.get(did) or confdevices.get(did.lower())
if upd:
device.update(upd)
async def _setup_include(hass: HomeAssistant, entry: ConfigEntry):
"""Setup additional devices from Yandex account."""
config = hass.data[DOMAIN][DATA_CONFIG]
if CONF_INCLUDE not in config:
return
for domain in SUB_DOMAINS:
hass.async_create_task(hass.config_entries.async_forward_entry_setup(
entry, domain
))
@@ -0,0 +1,193 @@
import logging
from homeassistant.components.climate import ClimateEntity, HVAC_MODE_OFF, HVAC_MODE_HEAT, \
SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE
from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE
from . import DOMAIN, CONF_INCLUDE, DATA_CONFIG, YandexQuasar
_LOGGER = logging.getLogger(__name__)
DEVICES = ['devices.types.thermostat.ac', 'devices.types.thermostat']
async def async_setup_entry(hass, entry, async_add_entities):
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
quasar = hass.data[DOMAIN][entry.unique_id]
devices = [
YandexClimate(quasar, device)
for device in quasar.devices
if device['name'] in include and device['type'] in DEVICES
]
async_add_entities(devices, True)
# noinspection PyAbstractClass
class YandexClimate(ClimateEntity):
_min_temp = None
_max_temp = None
_precision = None
_hvac_mode = None
_hvac_modes = None
_preset_mode = None
_preset_modes = None
_is_on = None
_t_temp = None
_c_temp = None
_fan_mode = None
_fan_modes = None
_supported = 0
def __init__(self, quasar: YandexQuasar, device: dict):
self.quasar = quasar
self.device = device
@property
def unique_id(self):
return self.device['id'].replace('-', '')
@property
def name(self):
return self.device['name']
@property
def should_poll(self):
return True
@property
def precision(self):
return self._precision
@property
def temperature_unit(self):
return TEMP_CELSIUS
@property
def hvac_mode(self):
return self._hvac_mode if self._is_on else HVAC_MODE_OFF
@property
def hvac_modes(self):
return self._hvac_modes
@property
def preset_mode(self):
return self._preset_mode
@property
def preset_modes(self):
return self._preset_modes
@property
def current_temperature(self):
if self._c_temp is None:
return self._t_temp
return self._c_temp
@property
def target_temperature(self):
return self._t_temp
@property
def fan_mode(self):
return self._fan_mode
@property
def fan_modes(self):
return self._fan_modes
@property
def supported_features(self):
return self._supported
@property
def min_temp(self):
return self._min_temp
@property
def max_temp(self):
return self._max_temp
async def async_set_hvac_mode(self, hvac_mode):
if hvac_mode == HVAC_MODE_OFF:
await self.quasar.device_action(self.device['id'], on=False)
elif hvac_mode == HVAC_MODE_HEAT:
if self._preset_modes is not None:
await self.quasar.device_action(self.device['id'], on=True)
else:
await self.quasar.device_action(self.device['id'], on=True,
thermostat=hvac_mode)
else:
await self.quasar.device_action(self.device['id'], on=True,
thermostat=hvac_mode)
async def async_set_temperature(self, **kwargs):
await self.quasar.device_action(self.device['id'],
temperature=kwargs[ATTR_TEMPERATURE])
async def async_set_fan_mode(self, fan_mode):
await self.quasar.device_action(self.device['id'], fan_speed=fan_mode)
async def async_set_preset_mode(self, preset_mode):
await self.quasar.device_action(self.device['id'], heat=preset_mode)
async def init_params(self, capabilities: dict):
for capability in capabilities:
parameters = capability['parameters']
instance = parameters.get('instance')
if instance == 'temperature':
self._supported |= SUPPORT_TARGET_TEMPERATURE
range_ = parameters['range']
self._min_temp = range_['min']
self._max_temp = range_['max']
self._precision = range_['precision']
elif instance == 'fan_speed':
self._supported |= SUPPORT_FAN_MODE
self._fan_modes = [
p['value'] for p in parameters['modes']
]
elif instance == 'thermostat':
self._hvac_modes = [HVAC_MODE_OFF] + [
p['value'] for p in parameters['modes']
]
elif instance == 'heat':
self._supported |= SUPPORT_PRESET_MODE
self._preset_modes = [
p['value'] for p in parameters['modes']
]
self._hvac_mode = HVAC_MODE_HEAT
self._hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF]
async def async_update(self):
data = await self.quasar.get_device(self.device['id'])
# first time init
if self._is_on is None:
await self.init_params(data['capabilities'])
for capability in data['capabilities']:
if not capability['retrievable']:
continue
instance = capability['state']['instance']
if instance == 'on':
self._is_on = capability['state']['value']
elif instance == 'temperature':
self._t_temp = capability['state']['value']
elif instance == 'fan_speed':
self._fan_mode = capability['state']['value']
elif instance == 'thermostat':
self._hvac_mode = capability['state']['value']
elif instance == 'heat':
self._preset_mode = capability['state']['value']
for property in data["properties"]:
if not property['retrievable']:
continue
instance = property["parameters"]["instance"]
if instance == "temperature":
self._c_temp = property["state"]["value"]
@@ -0,0 +1,191 @@
"""
1. User can enter login/pass from GUI
2. User can set login/pass in YAML
3. If the password requires updating, user need to configure another component
with the same login.
4. Captcha will be requested if necessary
5. If authorization through YAML does not work, user can continue it through
the GUI.
"""
import logging
from functools import lru_cache
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from . import DOMAIN
from .core.yandex_session import YandexSession, LoginResponse
_LOGGER = logging.getLogger(__name__)
# noinspection PyUnusedLocal
class YandexStationFlowHandler(ConfigFlow, domain=DOMAIN):
@property
@lru_cache()
def yandex(self):
session = async_create_clientsession(self.hass)
return YandexSession(session)
async def async_step_import(self, data: dict):
"""Init by component setup. Forward YAML login/pass to auth."""
await self.async_set_unique_id(data['username'])
self._abort_if_unique_id_configured()
if 'x_token' in data:
return self.async_create_entry(
title=data['username'],
data={'x_token': data['x_token']})
else:
return await self.async_step_auth(data)
async def async_step_user(self, user_input=None):
"""Init by user via GUI"""
if user_input is None:
return self.async_show_form(
step_id='user',
data_schema=vol.Schema({
vol.Required('method', default='qr'): vol.In({
'qr': "QR-код",
'auth': "Пароль или одноразовый ключ",
'email': "Ссылка на E-mail",
'cookies': "Cookies",
'token': "Токен"
})
})
)
method = user_input['method']
if method == "qr":
return self.async_show_form(
step_id="qr", description_placeholders={
"qr_url": await self.yandex.get_qr()
}
)
if method == "auth":
return self.async_show_form(
step_id=method, data_schema=vol.Schema({
vol.Required("username"): str,
vol.Required("password"): str,
})
)
if method == "email":
return self.async_show_form(
step_id=method, data_schema=vol.Schema({
vol.Required('username'): str,
})
)
# cookies, token
return self.async_show_form(
step_id=method, data_schema=vol.Schema({
vol.Required(method): str,
})
)
async def async_step_qr(self, user_input):
resp = await self.yandex.login_qr()
if not resp:
self.cur_step["errors"] = {"base": "unauthorised"}
return self.cur_step
return await self._check_yandex_response(resp)
async def async_step_auth(self, user_input):
"""User submited username and password. Or YAML error."""
resp = await self.yandex.login_username(user_input['username'])
if resp.ok:
resp = await self.yandex.login_password(user_input['password'])
return await self._check_yandex_response(resp)
async def async_step_email(self, user_input):
resp = await self.yandex.login_username(user_input['username'])
if not resp.magic_link_email:
self.cur_step["errors"] = {"base": "email.unsupported"}
return self.cur_step
await self.yandex.get_letter()
return self.async_show_form(
step_id="email2", description_placeholders={
"email": resp.magic_link_email
}
)
async def async_step_email2(self, user_input):
resp = await self.yandex.login_letter()
if not resp:
self.cur_step["errors"] = {"base": "unauthorised"}
return self.cur_step
return await self._check_yandex_response(resp)
async def async_step_cookies(self, user_input):
resp = await self.yandex.login_cookies(user_input['cookies'])
return await self._check_yandex_response(resp)
async def async_step_token(self, user_input):
resp = await self.yandex.validate_token(user_input['token'])
return await self._check_yandex_response(resp)
async def async_step_captcha(self, user_input):
"""User submited captcha. Or YAML error."""
if user_input is None:
return self.cur_step
ok = await self.yandex.login_captcha(user_input['captcha_answer'])
if not ok:
return self.cur_step
return self.async_show_form(
step_id="captcha2", data_schema=vol.Schema({
vol.Required('password'): str,
})
)
async def async_step_captcha2(self, user_input):
resp = await self.yandex.login_password(user_input['password'])
return await self._check_yandex_response(resp)
async def _check_yandex_response(self, resp: LoginResponse):
"""Check Yandex response. Do not create entry for the same login. Show
captcha form if captcha required. Show auth form with error if error.
"""
if resp.ok:
# set unique_id or return existing entry
entry = await self.async_set_unique_id(resp.display_login)
if entry:
# update existing entry with same login
self.hass.config_entries.async_update_entry(
entry, data={'x_token': resp.x_token}
)
return self.async_abort(reason='account_updated')
else:
# create new entry for new login
return self.async_create_entry(
title=resp.display_login, data={'x_token': resp.x_token}
)
elif resp.error_captcha_required:
_LOGGER.debug(f"Captcha required")
return self.async_show_form(
step_id='captcha',
data_schema=vol.Schema({
vol.Required('captcha_answer'): str,
}),
description_placeholders={
'captcha_url': await self.yandex.get_captcha()
}
)
elif resp.errors:
_LOGGER.debug(f"Config error: {resp.error}")
if self.cur_step:
self.cur_step["errors"] = {'base': resp.error}
return self.cur_step
raise AbortFlow("not_implemented")
@@ -0,0 +1,5 @@
DOMAIN = 'yandex_station'
CONF_MEDIA_PLAYERS = 'media_players'
DATA_CONFIG = 'config'
@@ -0,0 +1,62 @@
class Protobuf:
page_size = 0
pos = 0
def __init__(self, raw: bytes):
self.raw = raw
def read(self, length: int) -> bytes:
self.pos += length
return self.raw[self.pos - length:self.pos]
def read_byte(self):
res = self.raw[self.pos]
self.pos += 1
return res
# https://developers.google.com/protocol-buffers/docs/encoding#varints
def read_varint(self) -> int:
res = 0
shift = 0
while True:
b = self.read_byte()
res += (b & 0x7F) << shift
if b & 0x80 == 0:
break
shift += 7
return res
def read_bytes(self) -> bytes:
length = self.read_varint()
return self.read(length)
def read_dict(self) -> dict:
res = {}
while self.pos < len(self.raw):
b = self.read_varint()
t = b & 0b111
k = b >> 3
if t == 0:
v = self.read_varint()
elif t == 1:
v = self.read(8)
elif t == 2:
v = self.read_bytes()
if v[0] >> 3 == 1:
pb = Protobuf(v)
v = pb.read_dict()
elif t == 5:
v = self.read(4)
else:
raise NotImplementedError
if k in res:
if isinstance(res[k], list):
res[k] += [v]
else:
res[k] = [res[k], v]
else:
res[k] = v
return res
@@ -0,0 +1,453 @@
import hashlib
import json
import logging
import os
import re
import uuid
from datetime import datetime
from logging import Logger
from typing import List
from aiohttp import web, ClientSession
from homeassistant.components import frontend
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import SUPPORT_PLAY_MEDIA
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import network
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import HomeAssistantType
from yarl import URL
from .const import *
_LOGGER = logging.getLogger(__name__)
# remove uiid, IP
RE_PRIVATE = re.compile(
r"\b([a-z0-9]{20}|[A-Z0-9]{24}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b")
NOTIFY_TEXT = (
'<a href="%s" target="_blank">Открыть лог<a> | '
'[README](https://github.com/AlexxIT/YandexStation)')
HTML = ('<!DOCTYPE html><html><head><title>YandexStation</title>'
'<meta http-equiv="refresh" content="%s"></head>'
'<body><pre>%s</pre></body></html>')
class YandexDebug(logging.Handler, HomeAssistantView):
name = "yandex_station_debug"
requires_auth = False
text = ''
def __init__(self, hass: HomeAssistantType, logger: Logger):
super().__init__()
logger.addHandler(self)
logger.setLevel(logging.DEBUG)
hass.loop.create_task(self.system_info(hass))
# random url because without authorization!!!
self.url = f"/{uuid.uuid4()}"
hass.http.register_view(self)
hass.components.persistent_notification.async_create(
NOTIFY_TEXT % self.url, title="YandexStation DEBUG")
@staticmethod
async def system_info(hass):
info = await hass.helpers.system_info.async_get_system_info()
info.pop('installation_type', None) # fix HA v0.109.6
info.pop('timezone')
_LOGGER.debug(f"SysInfo: {info}")
def handle(self, rec: logging.LogRecord) -> None:
dt = datetime.fromtimestamp(rec.created).strftime("%Y-%m-%d %H:%M:%S")
module = 'main' if rec.module == '__init__' else rec.module
# remove private data
msg = RE_PRIVATE.sub("...", str(rec.msg))
self.text += f"{dt} {rec.levelname:7} {module:13} {msg}\n"
async def get(self, request: web.Request):
reload = request.query.get('r', '')
return web.Response(text=HTML % (reload, self.text),
content_type="text/html")
def update_form(name: str, **kwargs):
return {
'command': 'serverAction',
'serverActionEventPayload': {
'type': 'server_action',
'name': 'update_form',
'payload': {
'form_update': {
'name': name,
'slots': [{'type': 'string', 'name': k, 'value': v}
for k, v in kwargs.items()]
},
'resubmit': True
}
}
}
def find_station(devices: list, name: str = None):
"""Найти станцию по ID, имени или просто первую попавшуюся."""
for device in devices:
if device.get('entity') and (
device['quasar_info']['device_id'] == name or
device['name'] == name or name is None
):
return device['entity'].entity_id
return None
async def error(hass: HomeAssistantType, text: str):
_LOGGER.error(text)
hass.components.persistent_notification.async_create(
text, title="YandexStation ERROR")
def clean_v1(hass_dir):
"""Подчищаем за первой версией компонента."""
path = hass_dir.path('.yandex_station.txt')
if os.path.isfile(path):
os.remove(path)
path = hass_dir.path('.yandex_station_cookies.pickle')
if os.path.isfile(path):
os.remove(path)
async def has_custom_icons(hass: HomeAssistantType):
# GUI off mode
if 'lovelace' not in hass.data:
return False
resources = hass.data['lovelace']['resources']
await resources.async_get_info()
for resource in resources.async_items():
if '/yandex-icons.js' in resource['url']:
return True
return False
def play_video_by_descriptor(provider: str, item_id: str):
return {
'command': 'serverAction',
'serverActionEventPayload': {
'type': 'server_action',
'name': 'bass_action',
'payload': {
'data': {
'video_descriptor': {
'provider_item_id': item_id,
'provider_name': provider
}
},
'name': 'quasar.play_video_by_descriptor'
}
}
}
RE_MEDIA = {
'youtube': re.compile(
r'https://(?:youtu\.be/|www\.youtube\.com/.+?v=)([0-9A-Za-z_-]{11})'),
'kinopoisk': re.compile(r'https://hd\.kinopoisk\.ru/.*([0-9a-z]{32})'),
'strm': re.compile(r'https://yandex.ru/efir\?.*stream_id=([^&]+)'),
'music.yandex.playlist': re.compile(
r'https://music\.yandex\.[a-z]+/users/(.+?)/playlists/(\d+)'),
'music.yandex': re.compile(
r'https://music\.yandex\.[a-z]+/.*(artist|track|album)/(\d+)'),
'kinopoisk.id': re.compile(r'https?://www\.kinopoisk\.ru/film/(\d+)/'),
'yavideo': re.compile(
r'(https?://ok\.ru/video/\d+|https?://vk.com/video-?[0-9_]+)'),
'vk': re.compile(r'https://vk\.com/.*(video-?[0-9_]+)'),
}
async def get_media_payload(text: str, session):
for k, v in RE_MEDIA.items():
m = v.search(text)
if m:
if k in ('youtube', 'kinopoisk', 'strm', 'yavideo'):
return play_video_by_descriptor(k, m[1])
elif k == 'vk':
url = 'https://vk.com/' + m[1]
return play_video_by_descriptor('yavideo', url)
elif k == 'music.yandex.playlist':
uid = await get_userid_v2(session, m[1])
if uid:
return {
'command': 'playMusic',
'type': 'playlist',
'id': f"{uid}:{m[2]}",
}
elif k == 'music.yandex':
return {
'command': 'playMusic',
'type': m[1],
'id': m[2],
}
elif k == 'kinopoisk.id':
try:
r = await session.get(
'https://ott-widget.kinopoisk.ru/ott/api/'
'kp-film-status/', params={'kpFilmId': m[1]})
resp = await r.json()
return play_video_by_descriptor('kinopoisk', resp['uuid'])
except:
return None
return None
async def get_zeroconf_singleton(hass: HomeAssistantType):
try:
# Home Assistant 0.110.0 and above
from homeassistant.components.zeroconf import async_get_instance
return await async_get_instance(hass)
except:
from zeroconf import Zeroconf
return Zeroconf()
RE_ID3 = re.compile(br'(Text|TIT2)(....)\x00\x00\x03(.+?)\x00',
flags=re.DOTALL)
async def get_tts_message(session: ClientSession, url: str):
"""Текст сообщения записывается в файл в виде ID3-тегов. Нужно скачать файл
и прочитать этот тег. В старых версиях ХА валидный ID3-тег, а в новых -
битый.
"""
try:
r = await session.get(url, ssl=False)
data = await r.read()
m = RE_ID3.findall(data)
if len(m) == 1 and m[0][0] == b'TIT2':
# old Hass version has valid ID3 tags with `TIT2` for Title
_LOGGER.debug(f"Получение TTS из ID3")
m = m[0]
elif len(m) == 3 and m[2][0] == b'Text':
# latest Hass version has bug with `Text` for all tags
# there are 3 tags and the last one we need
_LOGGER.debug(f"Получение TTS из битого ID3")
m = m[2]
else:
_LOGGER.debug(f"Невозможно получить TTS: {data}")
return None
# check tag value length
if int.from_bytes(m[1], 'big') - 2 == len(m[2]):
return m[2].decode('utf-8')
except:
_LOGGER.exception("Ошибка получения сообщения TTS")
return None
# noinspection PyProtectedMember
def fix_recognition_lang(hass: HomeAssistantType, folder: str, lng: str):
path = frontend._frontend_root(None).joinpath(folder)
for child in path.iterdir():
# find all chunc.xxxx.js files
if child.suffix != '.js' and 'chunk.' not in child.name:
continue
with open(child, 'rb') as f:
raw = f.read()
# find chunk file with recognition code
if b'this.recognition.lang=' not in raw:
continue
raw = raw.replace(b'en-US', lng.encode())
async def recognition_lang(request):
_LOGGER.debug("Send fixed recognition lang to client")
return web.Response(body=raw,
content_type='application/javascript')
hass.http.app.router.add_get('/frontend_latest/' + child.name,
recognition_lang)
resource = hass.http.app.router._resources.pop()
hass.http.app.router._resources.insert(40, resource)
_LOGGER.debug(f"Fix recognition lang in {folder} to {lng}")
return
RE_CLOUD_TEXT = re.compile(r'(<.+?>|[^А-Яа-яЁёA-Za-z0-9-,!.:=? ]+)')
RE_CLOUD_SPACE = re.compile(r' +')
def fix_cloud_text(text: str) -> str:
"""В облачном тексте есть ограничения:
1. Команда Алисе может содержать только кириллицу, латиницу, цифры и
спецсимволы: "-,!.:=?".
2. Команда Алисе должна быть не длиннее 100 символов
3. Нельзя использовать 2 пробела подряд (PS: что с ними не так?!)
"""
text = RE_CLOUD_TEXT.sub('', text)
text = RE_CLOUD_SPACE.sub(' ', text)
return text.strip()[:100]
# https://music.yandex.ru/users/alexey.khit/playlists
async def get_userid_v1(session: ClientSession, username: str,
playlist_id: str):
try:
payload = {
'owner': username, 'kinds': playlist_id, 'light': 'true',
'withLikesCount': 'false', 'lang': 'ru',
'external-domain': 'music.yandex.ru',
'overembed': 'false'
}
r = await session.get(
'https://music.yandex.ru/handlers/playlist.jsx',
params=payload)
resp = await r.json()
return resp['playlist']['owner']['uid']
except:
return None
async def get_userid_v2(session: ClientSession, username: str):
try:
r = await session.get(
f"https://music.yandex.ru/users/{username}/playlists")
resp = await r.text()
return re.search(r'"uid":"(\d+)",', resp)[1]
except:
return None
def dump_capabilities(data: dict) -> dict:
for k in ('id', 'request_id', 'updates_url', 'external_id'):
if k in data:
data.pop(k)
return data
def load_token_from_json(hass: HomeAssistant):
"""Load token from .yandex_station.json"""
filename = hass.config.path('.yandex_station.json')
if os.path.isfile(filename):
with open(filename, 'rt') as f:
raw = json.load(f)
return raw['main_token']['access_token']
return None
@callback
def get_media_players(hass: HomeAssistant) -> List[dict]:
"""Get all Hass media_players not from yandex_station with support
play_media service.
"""
# check entity_components because MPD not in entity_registry and DLNA has
# wrong supported_features
try:
conf = hass.data[DOMAIN][DATA_CONFIG].get(CONF_MEDIA_PLAYERS)
if conf:
if isinstance(conf, dict):
return [{"entity_id": k, "name": v} for k, v in conf.items()]
assert all("entity_id" in i and "name" in i for i in conf), conf
return conf
ec: EntityComponent = hass.data["entity_components"]["media_player"]
return [{
"entity_id": entity.entity_id,
"name": (
(entity.registry_entry and entity.registry_entry.name) or
entity.name
),
} for entity in ec.entities if (
entity.platform.platform_name != DOMAIN and
entity.supported_features & SUPPORT_PLAY_MEDIA
)]
except Exception:
return []
def encode_media_source(query: dict) -> str:
"""Convert message param as URL query and all other params as hex path."""
if "message" in query:
message = query.pop("message")
return encode_media_source(query) + "?message=" + message
return URL.build(query=query).query_string.encode().hex()
def decode_media_source(media_id: str) -> dict:
url = URL(media_id)
try:
url = URL(f"?{bytes.fromhex(url.name).decode()}&{url.query_string}")
except Exception:
pass
return dict(url.query)
class StreamingView(HomeAssistantView):
requires_auth = False
url = "/api/yandex_station/{sid}/{uid}.mp3"
name = "api:yandex_station"
links: dict = {}
def __init__(self, hass: HomeAssistant):
self.session = async_get_clientsession(hass)
@staticmethod
def get_url(hass: HomeAssistant, sid: str, url: str):
sid = sid.lower()
uid = hashlib.md5(url.encode()).hexdigest()
StreamingView.links[sid] = url
return network.get_url(hass) + f"/api/yandex_station/{sid}/{uid}.mp3"
async def head(self, request: web.Request, sid: str, uid: str):
url: str = self.links.get(sid)
if not url or hashlib.md5(url.encode()).hexdigest() != uid:
return web.HTTPNotFound()
async with self.session.head(url) as r:
return web.Response(headers={
"Accept-Ranges": "bytes",
# important for DLNA players
"Content-Type": "audio/mpeg",
# inportant for SamsungTV
"Content-Length": r.headers["Content-Length"],
})
async def get(self, request: web.Request, sid: str, uid: str):
url: str = self.links.get(sid)
if not url or hashlib.md5(url.encode()).hexdigest() != uid:
return web.HTTPNotFound()
try:
rng = request.headers.get("Range")
headers = {"Range": rng} if rng else None
async with self.session.get(url, headers=headers) as r:
response = web.StreamResponse()
response.headers.update(r.headers)
await response.prepare(request)
# same chunks as default web.FileResponse
async for chunk in r.content.iter_chunked(256 * 1024):
await response.write(chunk)
except Exception:
pass
@@ -0,0 +1,256 @@
import asyncio
import ipaddress
import json
import logging
import time
import uuid
from asyncio import Future
from typing import Callable, Optional, Dict
from aiohttp import ClientWebSocketResponse, WSMsgType, ClientConnectorError
from custom_components.yandex_station.core.yandex_session import YandexSession
from zeroconf import ServiceBrowser, Zeroconf, ServiceStateChange
_LOGGER = logging.getLogger(__name__)
class YandexGlagol:
"""Класс для работы с колонкой по локальному протоколу."""
device_token = None
url: Optional[str] = None
ws: Optional[ClientWebSocketResponse] = None
# next_ping_ts = 0
# keep_task: Task = None
update_handler: Callable = None
waiters: Dict[str, Future] = {}
def __init__(self, session: YandexSession, device: dict):
self.session = session
self.device = device
self.loop = asyncio.get_event_loop()
def debug(self, text: str):
_LOGGER.debug(f"{self.device['name']} | {text}")
def is_device(self, device: str):
return (self.device['quasar_info']['device_id'] == device or
self.device['name'] == device)
@property
def name(self):
return self.device['name']
async def get_device_token(self):
self.debug("Обновление токена устройства")
payload = {
'device_id': self.device['quasar_info']['device_id'],
'platform': self.device['quasar_info']['platform']
}
r = await self.session.get(
'https://quasar.yandex.net/glagol/token', params=payload)
resp = await r.json()
assert resp['status'] == 'ok', resp
return resp['token']
async def start_or_restart(self):
# first time
if not self.url:
self.url = f"wss://{self.device['host']}:{self.device['port']}"
asyncio.create_task(self._connect(0))
# check IP change
elif self.device['host'] not in self.url:
self.debug("Обновление IP-адреса устройства")
self.url = f"wss://{self.device['host']}:{self.device['port']}"
# force close session
if self.ws:
await self.ws.close()
async def stop(self):
self.debug("Останавливаем локальное подключение")
self.url = None
if self.ws:
await self.ws.close()
async def _connect(self, fails: int):
self.debug("Локальное подключение")
try:
if not self.device_token:
self.device_token = await self.get_device_token()
self.ws = await self.session.ws_connect(self.url, heartbeat=55,
ssl=False)
await self.ping(command="softwareVersion")
if not self.ws.closed:
fails = 0
# if not self.keep_task or self.keep_task.done():
# self.keep_task = self.loop.create_task(self._keep_connection())
async for msg in self.ws:
if msg.type == WSMsgType.TEXT:
# _LOGGER.debug("update")
# Большая станция в режиме idle шлёт статус раз в 5 секунд,
# в режиме playing шлёт чаще раза в 1 секунду
# self.next_ping_ts = time.time() + 6
data = json.loads(msg.data)
response = None
resp = data.get('vinsResponse')
if resp:
try:
# payload only in yandex module
card = resp['payload']['response']['card'] \
if 'payload' in resp \
else resp['response']['card']
if card:
response = card
except Exception as e:
_LOGGER.debug(f"Response error: {e}")
request_id = data.get('requestId')
if request_id in self.waiters:
self.waiters[request_id].set_result(response)
self.update_handler(data)
# TODO: find better place
self.device_token = None
except ClientConnectorError as e:
self.debug(f"Ошибка подключения: {e.args}")
fails += 1
except (asyncio.CancelledError, RuntimeError) as e:
# сюда попадаем при остановке HA
if isinstance(e, RuntimeError):
assert e.args[0] == "Session is closed", e.args
self.debug(f"Останавливаем подключение: {e}")
if self.ws and not self.ws.closed:
await self.ws.close()
return
except:
_LOGGER.exception(f"{self.name} | Station connect")
fails += 1
# возвращаемся в облачный режим
self.update_handler(None)
# останавливаем попытки
if not self.url:
return
if fails:
# 30s, 60s, ... 5 min
timeout = 30 * min(fails, 10)
self.debug(f"Таймаут до следующего подключения {timeout}")
await asyncio.sleep(timeout)
asyncio.create_task(self._connect(fails))
# async def _keep_connection(self):
# _LOGGER.debug("Start keep connection task")
# while not self.ws.closed:
# await asyncio.sleep(1)
# if time.time() > self.next_ping_ts:
# await self.ping()
async def ping(self, command="ping"):
# _LOGGER.debug("ping")
try:
await self.ws.send_json({
'conversationToken': self.device_token,
'id': str(uuid.uuid4()),
'payload': {'command': command},
'sentTime': int(round(time.time() * 1000)),
})
except:
pass
async def send(self, payload: dict) -> Optional[dict]:
_LOGGER.debug(f"{self.name} => local | {payload}")
request_id = str(uuid.uuid4())
try:
await self.ws.send_json({
'conversationToken': self.device_token,
'id': request_id,
'payload': payload,
'sentTime': int(round(time.time() * 1000)),
})
self.waiters[request_id] = self.loop.create_future()
# limit future wait time
await asyncio.wait_for(self.waiters[request_id], 5)
# self.next_ping_ts = time.time() + 0.5
return self.waiters.pop(request_id).result()
except asyncio.TimeoutError:
_ = self.waiters.pop(request_id, None)
except Exception as e:
_LOGGER.error(e)
async def reset_session(self):
payload = {'command': 'serverAction', 'serverActionEventPayload': {
'type': 'server_action', 'name': 'on_reset_session'}}
await self.send(payload)
class YandexIOListener:
add_handlerer = None
browser = None
def __init__(self, loop):
self.loop = loop
def start(self, handlerer: Callable, zeroconf: Zeroconf):
self.add_handlerer = handlerer
self.browser = ServiceBrowser(zeroconf, '_yandexio._tcp.local.',
handlers=[self._zeroconf_handler])
def stop(self, *args):
self.browser.cancel()
self.browser.zc.close()
def _zeroconf_handler(self, zeroconf: Zeroconf, service_type: str,
name: str, state_change: ServiceStateChange):
try:
info = zeroconf.get_service_info(service_type, name)
except Exception as e:
_LOGGER.warning("Can't get zeroconf info", exc_info=e)
return
if not info or len(info.addresses) == 0:
return
properties = {
k.decode(): v.decode() if isinstance(v, bytes) else v
for k, v in info.properties.items()
}
coro = self.add_handlerer({
'device_id': properties['deviceId'],
'platform': properties['platform'],
'host': str(ipaddress.ip_address(info.addresses[0])),
'port': info.port
})
self.loop.create_task(coro)
@@ -0,0 +1,39 @@
import hashlib
import operator
import re
from .yandex_session import YandexSession
XML = re.compile(r"<(host|path|ts|s)>([^<]+)")
ID = re.compile(r"\.(\d+)-")
async def get_mp3(session: YandexSession, player_state: dict):
try:
tid = player_state['id']
aid = ID.search(player_state["extra"]["coverURI"])[1]
# thanks to https://github.com/MarshalX/yandex-music-api
r = await session.get(
f"https://api.music.yandex.net/tracks/{tid}:{aid}/download-info",
)
res = await r.json()
res = sorted(
[p for p in res["result"] if p["codec"] == "mp3"],
key=operator.itemgetter("bitrateInKbps"), reverse=True
)
r = await session.session.get(res[0]["downloadInfoUrl"])
res = await r.text()
doc = dict(XML.findall(res))
sign = hashlib.md5(
("XGRlBW9FXlekgbPrRHuSiA" + doc["path"][1:] + doc["s"]).encode()
).hexdigest()
return f"https://{doc['host']}/get-mp3/{sign}/{doc['ts']}{doc['path']}"
except Exception:
return None
@@ -0,0 +1,490 @@
import asyncio
import json
import logging
from typing import Optional
from aiohttp import WSMsgType
from .yandex_session import YandexSession
_LOGGER = logging.getLogger(__name__)
IOT_TYPES = {
'on': 'devices.capabilities.on_off',
'temperature': 'devices.capabilities.range',
'fan_speed': 'devices.capabilities.mode',
'thermostat': 'devices.capabilities.mode',
'heat': 'devices.capabilities.mode',
'volume': 'devices.capabilities.range',
'pause': 'devices.capabilities.toggle',
'mute': 'devices.capabilities.toggle',
'channel': 'devices.capabilities.range',
'input_source': 'devices.capabilities.mode',
'brightness': 'devices.capabilities.range',
'color': 'devices.capabilities.color_setting',
'work_speed': 'devices.capabilities.mode',
'humidity': 'devices.capabilities.range',
'ionization': 'devices.capabilities.toggle',
'backlight': 'devices.capabilities.toggle',
# kettle:
'keep_warm': 'devices.capabilities.toggle',
'tea_mode': 'devices.capabilities.mode',
# don't work
'hsv': 'devices.capabilities.color_setting',
'rgb': 'devices.capabilities.color_setting',
'temperature_k': 'devices.capabilities.color_setting',
}
MASK_EN = '0123456789abcdef-'
MASK_RU = 'оеаинтсрвлкмдпуяы'
URL_USER = 'https://iot.quasar.yandex.ru/m/user'
def encode(uid: str) -> str:
"""Кодируем UID в рус. буквы. Яндекс привередливый."""
return 'ХА ' + ''.join([MASK_RU[MASK_EN.index(s)] for s in uid])
def decode(uid: str) -> Optional[str]:
"""Раскодируем UID из рус.букв."""
try:
return ''.join([MASK_EN[MASK_RU.index(s)] for s in uid[3:]])
except Exception:
return None
class YandexQuasar:
# all devices
devices = None
online_updated: asyncio.Event = None
updates_task: asyncio.Task = None
def __init__(self, session: YandexSession):
self.session = session
self.online_updated = asyncio.Event()
self.online_updated.set()
@property
def hass_id(self):
for device in self.devices:
if device['name'] == "Yandex Intents":
return device['id']
return None
async def init(self):
"""Основная функция. Возвращает список колонок."""
_LOGGER.debug("Получение списка устройств.")
r = await self.session.get(f"{URL_USER}/devices")
resp = await r.json()
assert resp['status'] == 'ok', resp
self.devices = [device for room in resp['rooms']
for device in room['devices']]
self.devices += resp['speakers'] + resp['unconfigured_devices']
@property
def speakers(self):
return [
d for d in self.devices
if d['type'].startswith("devices.types.smart_speaker")
]
@property
def modules(self):
# modules don't have cloud scenarios
return [d for d in self.devices if ".yandex.module" in d["type"]]
async def load_speakers(self) -> list:
speakers = self.speakers
# Яндекс начали добавлять device_id и platform с полным списком
# устройств
# for speaker in speakers:
# await self.load_speaker_config(speaker)
scenarios = await self.load_scenarios()
for speaker in speakers:
device_id = speaker['id']
if device_id not in scenarios:
await self.add_scenario(device_id)
scenarios = await self.load_scenarios()
speaker['scenario_id'] = scenarios[device_id]['id']
return speakers
async def load_speaker_config(self, device: dict):
"""Загружаем device_id и platform для колонок. Они не приходят с полным
списком устройств.
"""
r = await self.session.get(
f"{URL_USER}/devices/{device['id']}/configuration")
resp = await r.json()
assert resp['status'] == 'ok', resp
# device_id and platform
device.update(resp['quasar_info'])
async def load_scenarios(self) -> dict:
"""Получает список сценариев, которые мы ранее создали."""
r = await self.session.get(f"{URL_USER}/scenarios")
resp = await r.json()
assert resp['status'] == 'ok', resp
return {
decode(d['name']): d
for d in resp['scenarios']
if d['name'].startswith('ХА ')
}
async def add_scenario(self, device_id: str):
"""Добавляет сценарий-пустышку."""
name = encode(device_id)
payload = {
'name': name,
'icon': 'home',
'triggers': [{
'type': 'scenario.trigger.voice',
'value': name[3:]
}],
'steps': [{
'type': 'scenarios.steps.actions',
'parameters': {
'requested_speaker_capabilities': [],
'launch_devices': [{
'id': device_id,
'capabilities': [{
'type': 'devices.capabilities.quasar.server_action',
'state': {
'instance': 'phrase_action',
'value': 'пустышка'
}
}]
}]
}
}]
}
r = await self.session.post(f"{URL_USER}/scenarios", json=payload)
resp = await r.json()
assert resp['status'] == 'ok', resp
async def add_intent(self, name: str, text: str, num: int):
speaker = [{
'type': 'devices.capabilities.quasar.server_action',
'state': {
'instance': 'phrase_action',
'value': text
}
}] if text else [{
'type': 'devices.capabilities.quasar.server_action',
'state': {
'instance': 'text_action',
'value': "Yandex Intents громкость 100"
}
}]
payload = {
'name': name,
'icon': 'home',
'triggers': [{
'type': 'scenario.trigger.voice',
'value': name
}],
'steps': [{
'type': 'scenarios.steps.actions',
'parameters': {
'requested_speaker_capabilities': speaker,
'launch_devices': [{
'id': self.hass_id,
'capabilities': [{
'type': 'devices.capabilities.range',
'state': {
'instance': 'volume',
'relative': False,
'value': num
}
}]
}]
}
}]
}
r = await self.session.post(f"{URL_USER}/scenarios", json=payload)
resp = await r.json()
assert resp['status'] == 'ok', resp
async def send(self, device: dict, text: str, is_tts: bool = False):
"""Запускает сценарий на выполнение команды или TTS."""
# skip send for yandex modules
if "scenario_id" not in device:
return
_LOGGER.debug(f"{device['name']} => cloud | {text}")
action = 'phrase_action' if is_tts else 'text_action'
name = encode(device['id'])
payload = {
'name': name,
'icon': 'home',
'triggers': [{
'type': 'scenario.trigger.voice',
'value': name[3:]
}],
'steps': [{
'type': 'scenarios.steps.actions',
'parameters': {
'requested_speaker_capabilities': [],
'launch_devices': [{
'id': device['id'],
'capabilities': [{
'type': 'devices.capabilities.quasar.server_action',
'state': {
'instance': action,
'value': text
}
}]
}]
}
}]
}
sid = device['scenario_id']
r = await self.session.put(f"{URL_USER}/scenarios/{sid}", json=payload)
resp = await r.json()
assert resp['status'] == 'ok', resp
r = await self.session.post(f"{URL_USER}/scenarios/{sid}/actions")
resp = await r.json()
assert resp['status'] == 'ok', resp
async def load_local_speakers(self):
"""Загружает список локальных колонок. Не используется."""
try:
r = await self.session.get(
'https://quasar.yandex.net/glagol/device_list')
resp = await r.json()
return [{
'device_id': d['id'],
'name': d['name'],
'platform': d['platform']
} for d in resp['devices']]
except:
_LOGGER.exception("Load local speakers")
return None
async def get_device_config(self, device: dict) -> dict:
payload = {'device_id': device['quasar_info']['device_id'],
'platform': device['quasar_info']['platform']}
r = await self.session.get(
'https://quasar.yandex.ru/get_device_config', params=payload
)
resp = await r.json()
assert resp['status'] == 'ok', resp
return resp['config']
async def set_device_config(self, device: dict, device_config: dict):
_LOGGER.debug(f"Меняем конфиг станции: {device_config}")
payload = {'device_id': device['quasar_info']['device_id'],
'platform': device['quasar_info']['platform']}
r = await self.session.post(
'https://quasar.yandex.ru/set_device_config', params=payload,
json=device_config
)
resp = await r.json()
assert resp['status'] == 'ok', resp
async def get_device(self, deviceid: str):
r = await self.session.get(f"{URL_USER}/devices/{deviceid}")
resp = await r.json()
assert resp['status'] == 'ok', resp
return resp
async def device_action(self, deviceid: str, **kwargs):
_LOGGER.debug(f"Device action: {kwargs}")
actions = []
for k, v in kwargs.items():
type_ = (
'devices.capabilities.custom.button'
if k.isdecimal() else IOT_TYPES[k]
)
state = (
{'instance': k, 'value': v, 'relative': True}
if k in ('volume', 'channel')
else {'instance': k, 'value': v}
)
actions.append({'type': type_, 'state': state})
r = await self.session.post(
f"{URL_USER}/devices/{deviceid}/actions", json={'actions': actions}
)
resp = await r.json()
assert resp['status'] == 'ok', resp
async def update_online_stats(self):
if not self.online_updated.is_set():
await self.online_updated.wait()
return
self.online_updated.clear()
# _LOGGER.debug(f"Update speakers online status")
try:
r = await self.session.get(
'https://quasar.yandex.ru/devices_online_stats')
resp = await r.json()
assert resp['status'] == 'ok', resp
except:
return
finally:
self.online_updated.set()
for speaker in resp['items']:
for device in self.devices:
if 'quasar_info' not in device or \
device['quasar_info']['device_id'] != speaker['id']:
continue
device["online"] = speaker["online"]
break
async def _updates_connection(self, handler):
r = await self.session.get(
'https://iot.quasar.yandex.ru/m/v3/user/devices'
)
resp = await r.json()
assert resp['status'] == 'ok', resp
ws = await self.session.ws_connect(resp['updates_url'], heartbeat=60)
_LOGGER.debug("Start quasar updates connection")
async for msg in ws:
if msg.type != WSMsgType.TEXT:
break
resp = msg.json()
# "ping", "update_scenario_list"
if resp.get("operation") != "update_states":
continue
try:
resp = json.loads(resp['message'])
for upd in resp['updated_devices']:
if not upd.get('capabilities'):
continue
for cap in upd['capabilities']:
state = cap.get('state')
if not state:
continue
if cap['type'] == \
'devices.capabilities.quasar.server_action':
for speaker in self.speakers:
if speaker['id'] == upd['id']:
entity = speaker.get('entity')
if not entity:
break
state['entity_id'] = entity.entity_id
state['name'] = entity.name
await handler(state)
break
except:
_LOGGER.debug(f"Parse quasar update error: {msg.data}")
async def _updates_loop(self, handler):
while True:
try:
await self._updates_connection(handler)
except Exception as e:
_LOGGER.debug(f"Quasar update error: {e}")
await asyncio.sleep(30)
def handle_updates(self, handler):
self.updates_task = asyncio.create_task(self._updates_loop(handler))
def stop(self):
if self.updates_task:
self.updates_task.cancel()
async def set_account_config(self, key: str, value):
kv = ACCOUNT_CONFIG.get(key)
assert kv and value in kv['values'], f"{key}={value}"
if kv.get("api") == "user/settings":
# https://iot.quasar.yandex.ru/m/user/settings
r = await self.session.post(URL_USER + "/settings", json={
kv["key"]: kv["values"][value]
})
else:
r = await self.session.get(
'https://quasar.yandex.ru/get_account_config'
)
resp = await r.json()
assert resp['status'] == 'ok', resp
payload: dict = resp['config']
payload[kv['key']] = kv['values'][value]
r = await self.session.post(
'https://quasar.yandex.ru/set_account_config', json=payload
)
resp = await r.json()
assert resp['status'] == 'ok', resp
BOOL_CONFIG = {'да': True, 'нет': False}
ACCOUNT_CONFIG = {
'без лишних слов': {
'api': 'user/settings',
'key': 'iot',
'values': {
'да': {'response_reaction_type': 'sound'},
'нет': {'response_reaction_type': 'nlg'},
}
},
'ответить шепотом': {
'api': 'user/settings',
'key': 'tts_whisper',
'values': BOOL_CONFIG
},
'звук активации': {
'key': 'jingle', # /get_account_config
'values': BOOL_CONFIG
},
'одним устройством': {
'key': 'smartActivation', # /get_account_config
'values': BOOL_CONFIG
},
'понимать детей': {
'key': 'useBiometryChildScoring', # /get_account_config
'values': BOOL_CONFIG
},
'рассказывать о навыках': {
'key': 'aliceProactivity', # /get_account_config
'values': BOOL_CONFIG
},
'взрослый голос': {
'key': 'contentAccess', # /get_account_config
'values': {
'умеренный': 'medium',
'семейный': 'children',
'безопасный': 'safe',
'без ограничений': 'without',
}
},
'детский голос': {
'key': 'childContentAccess', # /get_account_config
'values': {
'безопасный': 'safe',
'семейный': 'children',
}
},
'имя': {
'key': 'spotter', # /get_account_config
'values': {
'алиса': 'alisa',
'яндекс': 'yandex',
}
},
}
@@ -0,0 +1,497 @@
"""
Yandex supports base auth methods:
- password
- magic_link - auth via link to email
- sms_code - auth via pin code to mobile phone
- magic (otp?) - auth via key-app (30 seconds password)
- magic_x_token - auth via QR-conde (do not need username)
Advanced auth methods:
- x_token - auth via super-token (1 year)
- cookies - auth via cookies from passport.yandex.ru site
Errors:
- account.not_found - wrong login
- password.not_matched
- captcha.required
"""
import base64
import json
import logging
import pickle
import re
from aiohttp import ClientSession
_LOGGER = logging.getLogger(__name__)
class LoginResponse:
""""
status: ok
uid: 1234567890
display_name: John
public_name: John
firstname: John
lastname: McClane
gender: m
display_login: j0hn.mcclane
normalized_display_login: j0hn-mcclane
native_default_email: j0hn.mcclane@yandex.ru
avatar_url: XXX
is_avatar_empty: True
public_id: XXX
access_token: XXX
cloud_token: XXX
x_token: XXX
x_token_issued_at: 1607490000
access_token_expires_in: 24650000
x_token_expires_in: 24650000
status: error
errors: [captcha.required]
captcha_image_url: XXX
status: error
errors: [account.not_found]
errors: [password.not_matched]
"""
def __init__(self, resp: dict):
self.raw = resp
@property
def ok(self):
return self.raw.get("status") == "ok"
@property
def errors(self):
return self.raw.get("errors", [])
@property
def error(self):
return self.raw['errors'][0]
@property
def display_login(self):
return self.raw['display_login']
@property
def x_token(self):
return self.raw['x_token']
@property
def magic_link_email(self):
return self.raw.get("magic_link_email")
@property
def error_captcha_required(self):
return "captcha.required" in self.errors
# noinspection PyPep8
class YandexSession:
"""Class for login in yandex via username, token, capcha."""
auth_payload: dict = None
csrf_token = None
proxy: str = None
def __init__(self, session: ClientSession, x_token: str = None,
music_token: str = None, cookie: str = None):
"""
:param x_token: optional x-token
:param music_token: optional token for glagol API
:param cookie: optional base64 cookie from last session
"""
self.session = session
self.x_token = x_token
self.music_token = music_token
if cookie:
raw = base64.b64decode(cookie)
self.session.cookie_jar._cookies = pickle.loads(raw)
self._update_listeners = []
def add_update_listener(self, coro):
"""Listeners to handle automatic cookies update."""
self._update_listeners.append(coro)
async def login_username(self, username: str) -> LoginResponse:
"""Create login session and return supported auth methods."""
# step 1: csrf_token
r = await self.session.get(
"https://passport.yandex.ru/am?app_platform=android",
proxy=self.proxy
)
resp = await r.text()
m = re.search(r'"csrf_token" value="([^"]+)"', resp)
assert m, resp
self.auth_payload = {"csrf_token": m[1]}
# step 2: track_id
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/auth/multi_step/start",
data={**self.auth_payload, "login": username}, proxy=self.proxy
)
resp = await r.json()
if resp.get("can_register") is True:
return LoginResponse({"errors": ["account.not_found"]})
assert resp.get("can_authorize") is True, resp
self.auth_payload["track_id"] = resp["track_id"]
# "preferred_auth_method":"password","auth_methods":["password","magic_link","magic_x_token"]}
# "preferred_auth_method":"password","auth_methods":["password","sms_code","magic_x_token"]}
# "preferred_auth_method":"magic","auth_methods":["magic","otp"]
# "preferred_auth_method":"magic_link","auth_methods":["magic_link"]
return LoginResponse(resp)
async def login_password(self, password: str) -> LoginResponse:
"""Login using password or key-app (30 second password)."""
assert self.auth_payload
# step 3: password or 30 seconds key
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/auth/multi_step/commit_password",
data={
**self.auth_payload,
"password": password,
"retpath": "https://passport.yandex.ru/am/finish?status=ok&from=Login"
},
proxy=self.proxy
)
resp = await r.json()
if resp["status"] != "ok":
return LoginResponse(resp)
if "redirect_url" in resp:
return LoginResponse({"errors": ["redirect.unsupported"]})
# step 4: x_token
return await self.login_cookies()
async def get_qr(self) -> str:
"""Get link to QR-code auth."""
# step 1: csrf_token
r = await self.session.get(
"https://passport.yandex.ru/am?app_platform=android",
proxy=self.proxy
)
resp = await r.text()
m = re.search(r'"csrf_token" value="([^"]+)"', resp)
assert m, resp
# step 2: track_id
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/auth/password/submit",
data={
"csrf_token": m[1],
"retpath": "https://passport.yandex.ru/profile",
"with_code": 1,
},
proxy=self.proxy
)
resp = await r.json()
assert resp['status'] == 'ok', resp
self.auth_payload = {
"csrf_token": resp["csrf_token"],
"track_id": resp["track_id"]
}
return "https://passport.yandex.ru/auth/magic/code/?track_id=" + \
resp["track_id"]
async def login_qr(self) -> LoginResponse:
"""Check if already logged in."""
assert self.auth_payload
r = await self.session.post(
"https://passport.yandex.ru/auth/new/magic/status/",
data=self.auth_payload, proxy=self.proxy
)
resp = await r.json()
# resp={} if no auth yet
if resp.get('status') != 'ok':
return LoginResponse({})
return await self.login_cookies()
async def get_sms(self):
"""Request an SMS to user phone."""
assert self.auth_payload
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/phone-confirm-code-submit",
data={**self.auth_payload, 'mode': 'tracked'}, proxy=self.proxy
)
resp = await r.json()
assert resp['status'] == 'ok'
async def login_sms(self, code: str) -> LoginResponse:
"""Login with code from SMS."""
assert self.auth_payload
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/phone-confirm-code",
data={**self.auth_payload, 'mode': 'tracked', 'code': code},
proxy=self.proxy
)
resp = await r.json()
assert resp['status'] == 'ok'
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/multi-step-commit-sms-code",
data={
**self.auth_payload,
"retpath": "https://passport.yandex.ru/am/finish?status=ok&from=Login"
},
proxy=self.proxy
)
resp = await r.json()
assert resp['status'] == 'ok'
return await self.login_cookies()
async def get_letter(self):
"""Request an magic link to user E-mail address."""
assert self.auth_payload
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/auth/send_magic_letter",
data=self.auth_payload, proxy=self.proxy
)
resp = await r.json()
assert resp['status'] == 'ok'
async def login_letter(self) -> LoginResponse:
"""Check if already logged in."""
assert self.auth_payload
r = await self.session.post(
"https://passport.yandex.ru/auth/letter/status/",
data=self.auth_payload, proxy=self.proxy
)
resp = await r.json()
assert resp['status'] == 'ok'
if not resp['magic_link_confirmed']:
return LoginResponse({})
return await self.login_cookies()
async def get_captcha(self) -> str:
"""Get link to captcha image."""
assert self.auth_payload
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/textcaptcha",
data=self.auth_payload,
headers={"X-Requested-With": "XMLHttpRequest"},
proxy=self.proxy
)
resp = await r.json()
assert resp['status'] == 'ok'
self.auth_payload["key"] = resp["key"]
return resp["image_url"]
async def login_captcha(self, captcha_answer: str) -> bool:
"""Login with answer to captcha from login_username."""
_LOGGER.debug("Login in Yandex with captcha")
assert self.auth_payload
r = await self.session.post(
"https://passport.yandex.ru/registration-validations/checkHuman",
data={**self.auth_payload, "answer": captcha_answer},
headers={"X-Requested-With": "XMLHttpRequest"},
proxy=self.proxy
)
resp = await r.json()
return resp['status'] == 'ok'
async def login_cookies(self, cookies: str = None) -> LoginResponse:
"""Support three formats:
1. Empty - cookies will be loaded from the session
2. JSON from Copy Cookies (Google Chrome extension)
https://chrome.google.com/webstore/detail/copy-cookies/jcbpglbplpblnagieibnemmkiamekcdg
3. Raw cookie string `key1=value1; key2=value2`
For JSON format support cookies from different Yandex domains.
"""
host = "passport.yandex.ru"
if cookies is None:
cookies = "; ".join([
f"{c.key}={c.value}" for c in self.session.cookie_jar
if c["domain"].endswith("yandex.ru")
])
elif cookies[0] == "[":
raw = json.loads(cookies)
host = next(
p["domain"] for p in raw
if p["domain"].startswith("passport.yandex.")
)
cookies = "; ".join([f"{p['name']}={p['value']}" for p in raw])
r = await self.session.post(
"https://mobileproxy.passport.yandex.net/1/bundle/oauth/token_by_sessionid",
data={
"client_id": "c0ebe342af7d48fbbbfcf2d2eedb8f9e",
"client_secret": "ad0a908f0aa341a182a37ecd75bc319e",
}, headers={
"Ya-Client-Host": host,
"Ya-Client-Cookie": cookies
},
proxy=self.proxy
)
resp = await r.json()
x_token = resp["access_token"]
return await self.validate_token(x_token)
async def validate_token(self, x_token: str) -> LoginResponse:
"""Return user info using token."""
r = await self.session.get(
"https://mobileproxy.passport.yandex.net/1/bundle/account/short_info/?avatar_size=islands-300",
headers={'Authorization': f"OAuth {x_token}"}, proxy=self.proxy
)
resp = await r.json()
resp['x_token'] = x_token
return LoginResponse(resp)
async def login_token(self, x_token: str) -> bool:
"""Login to Yandex with x-token. Usual you should'n call this method.
Better pass your x-token to construstor and call refresh_cookies to
check if all fine.
"""
_LOGGER.debug("Login in Yandex with token")
payload = {
'type': 'x-token',
'retpath': 'https://www.yandex.ru'
}
headers = {'Ya-Consumer-Authorization': f"OAuth {x_token}"}
r = await self.session.post(
'https://mobileproxy.passport.yandex.net/1/bundle/auth/x_token/',
data=payload, headers=headers, proxy=self.proxy
)
resp = await r.json()
if resp['status'] != 'ok':
_LOGGER.error(f"Login with token error: {resp}")
return False
host = resp['passport_host']
payload = {'track_id': resp['track_id']}
r = await self.session.get(
f"{host}/auth/session/", params=payload, proxy=self.proxy,
allow_redirects=False
)
assert r.status == 302, await r.read()
return True
async def refresh_cookies(self) -> bool:
"""Checks if cookies ok and updates them if necessary."""
# check cookies
r = await self.session.get("https://yandex.ru/quasar?storage=1")
resp = await r.json()
if resp["storage"]["user"]["uid"]:
# if cookies fine - return
return True
# refresh cookies
ok = await self.login_token(self.x_token)
if ok:
await self._handle_update()
return ok
async def get_music_token(self, x_token: str):
"""Get music token using x-token. Usual you should'n call this method.
"""
_LOGGER.debug("Get music token")
payload = {
# Thanks to https://github.com/MarshalX/yandex-music-api/
'client_secret': '53bc75238f0c4d08a118e51fe9203300',
'client_id': '23cabbbdc6cd418abb4b39c32c41195d',
'grant_type': 'x-token',
'access_token': x_token
}
r = await self.session.post(
'https://oauth.mobile.yandex.net/1/token', data=payload
)
resp = await r.json()
assert 'access_token' in resp, resp
return resp['access_token']
async def get(self, url, **kwargs):
if '/glagol/' in url or '/tracks/' in url:
return await self._request_glagol(url, **kwargs)
return await self._request('get', url, **kwargs)
async def post(self, url, **kwargs):
return await self._request('post', url, **kwargs)
async def put(self, url, **kwargs):
return await self._request('put', url, **kwargs)
async def ws_connect(self, *args, **kwargs):
return await self.session.ws_connect(*args, **kwargs)
async def _request(self, method: str, url: str, retry: int = 2, **kwargs):
# all except GET should contain CSRF token
if method != 'get':
if self.csrf_token is None:
_LOGGER.debug(f"Обновление CSRF-токена, proxy: {self.proxy}")
r = await self.session.get("https://yandex.ru/quasar",
proxy=self.proxy)
raw = await r.text()
m = re.search('"csrfToken2":"(.+?)"', raw)
assert m, raw
self.csrf_token = m[1]
kwargs['headers'] = {'x-csrf-token': self.csrf_token}
r = await getattr(self.session, method)(url, **kwargs)
if r.status == 200:
return r
elif r.status == 400:
retry = 0
elif r.status == 401:
# 401 - no cookies
await self.refresh_cookies()
elif r.status == 403:
# 403 - no x-csrf-token
self.csrf_token = None
else:
_LOGGER.warning(f"{url} return {r.status} status")
if retry:
_LOGGER.debug(f"Retry {method} {url}")
return await self._request(method, url, retry - 1, **kwargs)
raise Exception(f"{url} return {r.status} status")
async def _request_glagol(self, url: str, retry: int = 2, **kwargs):
# update music token if needed
if not self.music_token:
assert self.x_token, "x-token required"
self.music_token = await self.get_music_token(self.x_token)
await self._handle_update()
# OAuth should be capitalize, or music will be 128 bitrate quality
headers = {'Authorization': f"OAuth {self.music_token}"}
r = await self.session.get(url, headers=headers, **kwargs)
if r.status == 200:
return r
elif r.status == 403:
# clear music token if problem
self.music_token = None
if retry:
_LOGGER.debug(f"Retry {url}")
return await self._request_glagol(url, retry - 1)
raise Exception(f"{url} return {r.status} status")
@property
def cookie(self):
# noinspection PyProtectedMember, PyUnresolvedReferences
raw = pickle.dumps(self.session.cookie_jar._cookies,
pickle.HIGHEST_PROTOCOL)
return base64.b64encode(raw).decode()
async def _handle_update(self):
for coro in self._update_listeners:
await coro(x_token=self.x_token, music_token=self.music_token,
cookie=self.cookie)
@@ -0,0 +1,190 @@
"""Support for Yandex Smart Home humidifier."""
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components.climate.const import SUPPORT_TARGET_HUMIDITY
from homeassistant.components.humidifier import HumidifierEntity
from homeassistant.const import ATTR_STATE
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from . import CONF_INCLUDE, DATA_CONFIG, DOMAIN, YandexQuasar
_LOGGER = logging.getLogger(__name__)
SERVICE_MUTE = "mute"
SERVICE_IONIZATION = "ionization"
SERVICE_BACKLIGHT = "backlight"
HUMIDIFIER_STATE_USER_SCHEMA = {vol.Required(ATTR_STATE): cv.boolean}
DEVICES = ["devices.types.humidifier"]
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up humidifier from a config entry."""
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
quasar = hass.data[DOMAIN][entry.unique_id]
devices = [
YandexHumidifier(quasar, device)
for device in quasar.devices
if device["name"] in include and device["type"] in DEVICES
]
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_MUTE, HUMIDIFIER_STATE_USER_SCHEMA, "mute"
)
platform.async_register_entity_service(
SERVICE_IONIZATION, HUMIDIFIER_STATE_USER_SCHEMA, "ionization"
)
platform.async_register_entity_service(
SERVICE_BACKLIGHT, HUMIDIFIER_STATE_USER_SCHEMA, "backlight"
)
async_add_entities(devices, True)
# noinspection PyAbstractClass
class YandexHumidifier(HumidifierEntity):
"""Yandex Home humidifier entity"""
_is_on = None
_min_humidity = None
_max_humidity = None
_target_humidity = None
_precision = None
_is_muted = None
_is_ionization_on = None
_is_backlight_on = None
_supported = 0
def __init__(self, quasar: YandexQuasar, device: dict) -> None:
"""Initialize entity."""
self.quasar = quasar
self.device = device
@property
def unique_id(self):
"""Return entity unique id."""
return self.device["id"].replace("-", "")
@property
def name(self):
"""Return entity name."""
return self.device["name"]
@property
def is_on(self) -> bool:
"""Return if device is turned on."""
return self._is_on
@property
def min_humidity(self) -> int:
"""Return min humidity."""
return self._min_humidity
@property
def max_humidity(self) -> int:
"""Return max humidity."""
return self._max_humidity
@property
def precision(self) -> int:
"""Return target humidity precision."""
return self._precision
@property
def target_humidity(self) -> int:
"""Return target humidity."""
return self._target_humidity
@property
def is_muted(self) -> bool:
"""Return if device is muted."""
return self._is_muted
@property
def is_ionization_on(self) -> bool:
"""Return if ionization is turned on."""
return self._is_ionization_on
@property
def is_backlight_on(self) -> bool:
"""Return if backlight is turned on."""
return self._is_backlight_on
@property
def supported_features(self):
"""Return supported features."""
return self._supported
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device specific state attributes."""
attributes = {
"is_muted": self.is_muted,
"is_ionization_on": self.is_ionization_on,
"is_backlight_on": self.is_backlight_on,
}
return attributes
async def init_params(self, capabilities: dict):
"""Initialize parameters."""
for capability in capabilities:
parameters = capability["parameters"]
instance = parameters.get("instance")
if instance == "humidity":
self._supported |= SUPPORT_TARGET_HUMIDITY
range_ = parameters["range"]
self._min_humidity = range_["min"]
self._max_humidity = range_["max"]
self._precision = range_["precision"]
async def async_update(self):
"""Update the entity."""
data = await self.quasar.get_device(self.device["id"])
if self._is_on is None:
await self.init_params(data["capabilities"])
for capability in data["capabilities"]:
if not capability["retrievable"]:
continue
instance = capability["state"]["instance"]
value = capability["state"]["value"]
if instance == "on":
self._is_on = value
if instance == "humidity":
self._target_humidity = value
if instance == "mute":
self._is_muted = value
if instance == "ionization":
self._is_ionization_on = value
if instance == "backlight":
self._is_backlight_on = value
async def async_turn_on(self, **kwargs):
"""Turn on."""
await self.quasar.device_action(self.device["id"], on=True)
async def async_turn_off(self, **kwargs):
"""Turn off."""
await self.quasar.device_action(self.device["id"], on=False)
async def async_set_humidity(self, humidity):
"""Set humidity."""
await self.quasar.device_action(self.device["id"], humidity=humidity)
async def mute(self, state):
"""Mute humidifier."""
await self.quasar.device_action(self.device["id"], mute=state)
async def ionization(self, state):
"""Turn on/off ionization."""
await self.quasar.device_action(self.device["id"], ionization=state)
async def backlight(self, state):
"""Turn on/off backlight."""
await self.quasar.device_action(self.device["id"], backlight=state)
@@ -0,0 +1,67 @@
import asyncio
import logging
from typing import Optional
from homeassistant.core import HomeAssistant, Event
from homeassistant.helpers.intent import IntentHandler, Intent, IntentResponse
from . import DOMAIN, DATA_SPEAKERS
_LOGGER = logging.getLogger(__name__)
async def async_setup_intents(hass: HomeAssistant) -> None:
if DOMAIN not in hass.data:
return
handlers = []
for device in hass.data[DOMAIN][DATA_SPEAKERS].values():
platform = device['quasar_info']['platform']
if 'host' in device or platform.startswith('yandex'):
handler = YandexIntentHandler(device['entity'].entity_id)
hass.helpers.intent.async_register(handler)
handlers.append(handler)
if not handlers:
return
async def listener(event: Event):
request_id = event.data['request_id']
for handler in handlers:
if handler.request_id == request_id:
handler.response_text = event.data['text']
handler.response_waiter.set()
return
hass.bus.async_listen('yandex_station_response', listener)
class YandexIntentHandler(IntentHandler):
request_id: str = None
response_text: Optional[str] = None
response_waiter = None
def __init__(self, entity_id: str):
self.intent_type = entity_id
self.response_waiter = asyncio.Event()
_LOGGER.debug(f"Init intent: {self.intent_type}")
async def async_handle(self, intent: Intent) -> IntentResponse:
self.request_id = intent.context.id
self.response_text = None
self.response_waiter.clear()
await intent.hass.services.async_call('media_player', 'play_media', {
'entity_id': self.intent_type,
'media_content_id': intent.text_input,
'media_content_type': 'question:' + self.request_id
})
await asyncio.wait_for(self.response_waiter.wait(), 2.0)
response = intent.create_response()
if self.response_text:
response.async_set_speech(self.response_text)
return response
@@ -0,0 +1,125 @@
from homeassistant.components.light import LightEntity, SUPPORT_BRIGHTNESS, \
ATTR_BRIGHTNESS, SUPPORT_EFFECT, ATTR_EFFECT, ATTR_HS_COLOR
from . import DOMAIN, DATA_CONFIG, CONF_INCLUDE, YandexQuasar
DEVICES = ['devices.types.light']
async def async_setup_entry(hass, entry, async_add_entities):
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
quasar = hass.data[DOMAIN][entry.unique_id]
devices = [
YandexLight(quasar, device)
for device in quasar.devices
if device['name'] in include and device['type'] in DEVICES
]
async_add_entities(devices, True)
# noinspection PyAbstractClass
class YandexLight(LightEntity):
_brightness = None
_is_on = None
_hs_color = None
_supported_features = 0
_effects = None
def __init__(self, quasar: YandexQuasar, device: dict):
self.quasar = quasar
self.device = device
@property
def unique_id(self):
return self.device['id'].replace('-', '')
@property
def name(self):
return self.device['name']
@property
def should_poll(self):
return True
@property
def is_on(self) -> bool:
return self._is_on
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
return self._brightness
@property
def hs_color(self):
"""Return the hue and saturation color value [float, float]."""
return self._hs_color
@property
def effect_list(self):
return list(self._effects.keys())
@property
def supported_features(self):
return self._supported_features
@property
def state_attributes(self):
"""HS color attribute without SUPPORT_COLOR. Yandex don't support color
change."""
if not self.is_on:
return None
data = {}
if self.brightness:
data[ATTR_BRIGHTNESS] = self.brightness
if self.hs_color:
data[ATTR_HS_COLOR] = self.hs_color
return data
async def async_added_to_hass(self):
data = await self.quasar.get_device(self.device['id'])
for capability in data['capabilities']:
instance = capability['parameters'].get('instance')
if instance == 'color':
self._effects = {
p['name']: p['id']
for p in capability['parameters']['palette']
}
self._supported_features |= SUPPORT_EFFECT
elif instance == 'brightness':
self._supported_features |= SUPPORT_BRIGHTNESS
async def async_update(self):
data = await self.quasar.get_device(self.device['id'])
for capability in data['capabilities']:
if not capability['retrievable']:
continue
instance = capability['state']['instance']
if instance == 'on':
self._is_on = capability['state']['value']
elif instance == 'color':
raw = capability['state']['value']['value']
self._hs_color = [raw['h'], raw['s']]
elif instance == 'brightness':
self._brightness = round(capability['state']['value'] * 2.55)
async def async_turn_on(self, **kwargs):
"""Yandex don't support hsv, rgb and temp via this API"""
payload = {}
if ATTR_BRIGHTNESS in kwargs:
payload['brightness'] = round(kwargs[ATTR_BRIGHTNESS] / 2.55)
if ATTR_EFFECT in kwargs:
ef = kwargs[ATTR_EFFECT]
payload['color'] = self._effects[ef]
if not payload:
payload['on'] = True
await self.quasar.device_action(self.device['id'], **payload)
async def async_turn_off(self, **kwargs):
await self.quasar.device_action(self.device['id'], on=False)
@@ -0,0 +1,12 @@
{
"domain": "yandex_station",
"name": "Yandex.Station",
"config_flow": true,
"documentation": "https://github.com/AlexxIT/YandexStation",
"issue_tracker": "https://github.com/AlexxIT/YandexStation/issues",
"codeowners": ["@AlexxIT"],
"dependencies": ["http", "zeroconf"],
"requirements": [],
"version": "3.11.0",
"iot_class": "local_push"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
import voluptuous as vol
from homeassistant.components.media_player import ATTR_MEDIA_CONTENT_TYPE, \
ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_EXTRA, MEDIA_PLAYER_PLAY_MEDIA_SCHEMA
from homeassistant.components.notify import PLATFORM_SCHEMA, ATTR_MESSAGE, \
ATTR_DATA, BaseNotificationService
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.template import Template
from . import DOMAIN
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(ATTR_DATA): cv.make_entity_service_schema({
vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.template,
vol.Optional(ATTR_MEDIA_CONTENT_TYPE, default="text"): cv.string,
vol.Optional(ATTR_MEDIA_EXTRA): dict,
})
})
# noinspection PyUnusedLocal
async def async_get_service(hass, config, discovery_info=None):
"""Get the Yandex Station notification service."""
await async_setup_reload_service(hass, DOMAIN, ["notify"])
return YandexStationNotificationService(hass, config[ATTR_DATA])
# noinspection PyAbstractClass
class YandexStationNotificationService(BaseNotificationService):
"""Implement the notification service for Yandex Station."""
def __init__(self, hass, data: dict):
"""Initialize the service."""
self.data = data
self.template: Template = data.pop(ATTR_MEDIA_CONTENT_ID, None)
if self.template:
self.template.hass = hass
async def async_send_message(self, message: str, **kwargs):
"""Send a TTS message to the speaker."""
if self.template:
kwargs[ATTR_MESSAGE] = message
message = self.template.async_render(kwargs)
service_data = self.data.copy()
service_data[ATTR_MEDIA_CONTENT_ID] = message
if kwargs.get(ATTR_DATA):
service_data.update(kwargs[ATTR_DATA])
service_data = cv.make_entity_service_schema(
MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, extra=vol.REMOVE_EXTRA
)(service_data)
return await self.hass.services.async_call(
"media_player", "play_media", service_data
)
@@ -0,0 +1,79 @@
import asyncio
import logging
from typing import Iterable, Any
from homeassistant.components.remote import RemoteEntity, ATTR_DELAY_SECS, \
ATTR_NUM_REPEATS
from . import DOMAIN, DATA_CONFIG, CONF_INCLUDE, YandexQuasar
_LOGGER = logging.getLogger(__name__)
DEVICES = ['devices.types.other']
async def async_setup_entry(hass, entry, async_add_entities):
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
quasar = hass.data[DOMAIN][entry.unique_id]
devices = [
YandexOther(quasar, device)
for device in quasar.devices
if device['name'] in include and device['type'] in DEVICES
]
async_add_entities(devices, True)
# noinspection PyAbstractClass
class YandexOther(RemoteEntity):
_name = None
def __init__(self, quasar: YandexQuasar, device: dict):
self.quasar = quasar
self.device = device
self.buttons = {}
async def async_added_to_hass(self):
self._name = self.device['name']
data = await self.quasar.get_device(self.device['id'])
for capability in data['capabilities']:
if capability['type'] != 'devices.capabilities.custom.button':
continue
name = capability['parameters']['name']
self.buttons[name] = capability['parameters']['instance']
@property
def unique_id(self):
return self.device['id'].replace('-', '')
@property
def name(self):
return self._name
@property
def should_poll(self) -> bool:
return False
@property
def is_on(self) -> bool:
return True
async def async_send_command(self, command: Iterable[str],
**kwargs: Any) -> None:
num_repeats = kwargs.get(ATTR_NUM_REPEATS)
if num_repeats:
command *= num_repeats
delay = kwargs.get(ATTR_DELAY_SECS, 0)
for i, cmd in enumerate(command):
if cmd not in self.buttons:
_LOGGER.error(f"Неизвестная команда {cmd}")
continue
if delay and i:
await asyncio.sleep(delay)
payload = {self.buttons[cmd]: True}
await self.quasar.device_action(self.device['id'], **payload)
@@ -0,0 +1,115 @@
import logging
from homeassistant.components.select import SelectEntity
from homeassistant.helpers.entity import DeviceInfo
from .core.const import DOMAIN
from .core.yandex_quasar import YandexQuasar
_LOGGER = logging.getLogger(__name__)
PRESETS = [
["off", "Выключен"],
["custom", "Своя настройка"],
["lessbass", "Меньше басов", -6, 0, 0, 0, 0],
["movie", "Кино", -3, -1, 0, 0, 3],
["voice", "Голос", 0, 0, 2, 2, 3],
["pop", "Поп", 0, 1, 2, 1, 0],
["hiphop", "Хип-хоп", 3, 2, 0, 3, 3],
["dance", "Танцы", 5, 3, 0, 3, 0],
["rock", "Рок", 3, 0, -1, 2, 4],
["electro", "Электроника", 3, 1, -1, 1, 2],
["metal", "Метал", 4, -2, -2, -2, 4],
["rnb", "R’n’B", 5, 2, -1, 2, 4],
["classic", "Классика", 0, 0, 0, 0, -3],
["acoustics", "Акустика", 3, 0, 1, 1, 3],
["jazz", "Джаз", 2, 0, 1, 0, 2],
["concert", "Концерт", 1, 0, 0, 0, 1],
["party", "Вечеринка", 4, 1, -2, 1, 4],
["morebass", "Больше басов", 5, 0, 0, 0, 0],
["morehigh", "Больше высоких", 0, 0, 0, 0, 5],
["morebasshigh", "Больше басов и высоких", 5, 0, 0, 0, 5],
["lesshigh", "Меньше высоких", 0, 0, 0, 0, -5]
]
async def async_setup_entry(hass, entry, async_add_entities):
quasar: YandexQuasar = hass.data[DOMAIN][entry.unique_id]
async_add_entities([
YandexEqualizer(quasar, sp) for sp in quasar.speakers
if sp['quasar_info']['platform'] in ('yandexstation', 'yandexstation_2',
'yandexmidi', 'yandexmini', 'yandexmini_2', 'yandexmicro')
], True)
# noinspection PyAbstractClass
class YandexEqualizer(SelectEntity):
_attr_current_option: str = None
def __init__(self, quasar: YandexQuasar, device: dict):
self.quasar = quasar
self.device = device
self._attr_entity_registry_enabled_default = False
self._attr_icon = "mdi:equalizer"
self._attr_name = device['name'] + " Эквалайзер"
self._attr_options = [i[1] for i in PRESETS]
self._attr_unique_id = device['quasar_info']['device_id'] + "_equalizer"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device['quasar_info']['device_id'])},
name=self.device['name'],
)
self.entity_id = "media_player.yandex_station_" + \
self._attr_unique_id.lower()
async def async_update(self):
try:
conf: dict = await self.quasar.get_device_config(self.device)
eq = conf.get("equalizer")
if not eq:
return
if not eq["enabled"]:
eq["active_preset_id"] = "off"
self._attr_current_option = next(
i[1] for i in PRESETS if i[0] == eq["active_preset_id"]
)
except Exception as e:
_LOGGER.warning("Не удалось загрузить эквалайзер", exc_info=e)
async def async_select_option(self, option: str):
try:
conf: dict = await self.quasar.get_device_config(self.device)
eq = conf.get("equalizer")
if not eq:
# init default equalizer
conf["equalizer"] = eq = {
"bands": [
{"gain": 0, "freq": 60, "width": 90},
{"gain": 0, "freq": 230, "width": 340},
{"gain": 0, "freq": 910, "width": 1340},
{"gain": 0, "freq": 3600, "width": 5200},
{"gain": 0, "freq": 14000, "width": 13000}
],
"custom_preset_bands": [0, 0, 0, 0, 0],
"active_preset_id": "custom"
}
preset = next(i for i in PRESETS if i[1] == option)
if preset[0] != "off":
eq["enabled"] = True
eq["active_preset_id"] = preset[0]
bands = eq["custom_preset_bands"] \
if preset[0] == "custom" \
else preset[2:]
for i in range(5):
eq["bands"][i]["gain"] = bands[i]
else:
eq["enabled"] = False
await self.quasar.set_device_config(self.device, conf)
except Exception as e:
_LOGGER.warning("Не удалось изменить эквалайзер", exc_info=e)
@@ -0,0 +1,116 @@
"""Support for Yandex Smart Home sensor."""
from __future__ import annotations
import logging
from typing import Any
from . import CONF_INCLUDE
from . import DATA_CONFIG
from . import DOMAIN
from . import YandexQuasar
from homeassistant.components.sensor import SensorEntity
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT
from homeassistant.const import PERCENTAGE
from homeassistant.const import TEMP_CELSIUS
_LOGGER = logging.getLogger(__name__)
DEVICES = ["devices.types.humidifier"]
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="temperature",
name="Temperature",
native_unit_of_measurement=TEMP_CELSIUS,
state_class=STATE_CLASS_MEASUREMENT,
),
SensorEntityDescription(
key="humidity",
name="Humidity",
native_unit_of_measurement=PERCENTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
)
SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES]
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up sensor from a config entry."""
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
quasar = hass.data[DOMAIN][entry.unique_id]
devices = []
for device in quasar.devices:
if device["name"] in include and device["type"] in DEVICES:
data = await quasar.get_device(device["id"])
for prop in data["properties"]:
for description in SENSOR_TYPES:
if prop["parameters"]["instance"] == description.key:
devices.append(
YandexSensor(
quasar,
device,
prop["parameters"]["name"],
description,
)
)
async_add_entities(devices, True)
# noinspection PyAbstractClass
class YandexSensor(SensorEntity):
"""Yandex Home sensor entity"""
_humidity = None
_temperature = None
def __init__(
self,
quasar: YandexQuasar,
device: dict,
name: str,
description: SensorEntityDescription,
) -> None:
"""Initialize entity."""
self.quasar = quasar
self.device = device
self.sensor_name = name
self.entity_description = description
@property
def unique_id(self):
"""Return entity unique id."""
return f"{self.device['id'].replace('-', '')}: {self.entity_description.name}"
@property
def name(self):
"""Return entity name."""
return f"{self.device['name']}: {self.sensor_name}"
@property
def humidity(self) -> int:
"""Return current humidity."""
return self._humidity
@property
def temperature(self) -> int:
"""Return current temperature."""
return self._temperature
async def async_update(self):
"""Update the entity."""
data = await self.quasar.get_device(self.device["id"])
for prop in data["properties"]:
instance = prop["parameters"]["instance"]
if instance == "humidity":
self._humidity = prop["state"]["value"]
if instance == "temperature":
self._temperature = prop["state"]["value"]
@property
def native_value(self) -> Any:
"""Return the native value of the sensor."""
return getattr(self, self.entity_description.key)
@@ -0,0 +1,70 @@
reload:
name: Reload
description: Reload Yandex Station notify services.
send_command:
description: Send command to Yandex.Station.
fields:
command:
description: "Command: sendText, rewind, play, stop, prev, next, playMusic, setVolume"
example: 'sendText'
text:
description: For sendText command
example: Повтори за мной "Тестовое уведомление"
position:
description: For rewind command
example: 120
id:
description: For playMusic command
example: '2'
type:
description: For playMusic command (artist, track, album or playlist)
example: 'artist'
volume:
description: For setVolume command
example: 0.5
mute:
name: Mute humidifier
description:
target:
entity:
integration: yandex_station
domain: humidifier
fields:
state:
name: State
description: Set state
required: true
selector:
boolean:
ionization:
name: Turn on ionization
description:
target:
entity:
integration: yandex_station
domain: humidifier
fields:
state:
name: State
description: Set state
required: true
selector:
boolean:
backlight:
name: Turn on backlight
description:
target:
entity:
integration: yandex_station
domain: humidifier
fields:
state:
name: State
description: Set state
required: true
selector:
boolean:
@@ -0,0 +1,75 @@
import logging
from homeassistant.components.switch import SwitchEntity
from . import DOMAIN, DATA_CONFIG, CONF_INCLUDE, YandexQuasar
_LOGGER = logging.getLogger(__name__)
DEVICES = ['devices.types.switch', 'devices.types.socket']
async def async_setup_entry(hass, entry, async_add_entities):
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
quasar = hass.data[DOMAIN][entry.unique_id]
devices = [
YandexSwitch(quasar, device)
for device in quasar.devices
if device['name'] in include and device['type'] in DEVICES
]
async_add_entities(devices, True)
# noinspection PyAbstractClass
class YandexSwitch(SwitchEntity):
_attrs = None
_is_on = None
def __init__(self, quasar: YandexQuasar, device: dict):
self.quasar = quasar
self.device = device
@property
def unique_id(self):
return self.device['id'].replace('-', '')
@property
def name(self):
return self.device['name']
@property
def should_poll(self):
return True
@property
def is_on(self) -> bool:
return self._is_on
@property
def extra_state_attributes(self):
return self._attrs
async def async_update(self):
data = await self.quasar.get_device(self.device['id'])
for capability in data['capabilities']:
if not capability['retrievable']:
continue
instance = capability['state']['instance']
if instance == 'on':
self._is_on = capability['state']['value']
try:
self._attrs = {
p['parameters']['instance']: p['state']['value']
for p in data['properties']
if p['state']
}
except:
_LOGGER.warning(f"Can't read properties: {data}")
async def async_turn_on(self, **kwargs):
await self.quasar.device_action(self.device['id'], on=True)
async def async_turn_off(self, **kwargs):
await self.quasar.device_action(self.device['id'], on=False)
@@ -0,0 +1,62 @@
{
"config": {
"abort": {
"account_updated": "Данные обновлены, перезапустите Home Assistant"
},
"error": {
"account.not_found": "Такого аккаунта нет",
"password.not_matched": "Неверный пароль",
"unauthorised": "Авторизация пока не пройдена",
"email.unsupported": "Авторизация по E-mail не поддерживается на этом аккаунте",
"redirect.unsupported": "Авторизация по паролю не может быть пройдена"
},
"step": {
"user": {
"data": {
"method": "Способ авторизации"
}
},
"qr": {
"description": "У вас должен быть включен **Вход без пароля** в [профиле Яндекса](https://passport.yandex.ru/profile).\n\nОтсканируйте код в мобильном приложении **Яндекс**. Если включена двухфакторная авторизация - отсканируйте код в мобильном приложении **Яндекс.Ключ**. И затем нажмите **Подтвердить**.\n\nВ тёмной теме код читается плохо!\n![]({qr_url})\n"
},
"auth": {
"description": "Введите логин и пароль аккаунта на Яндексе. Если включена двухфакторная аутентификация - введите одноразовый пароль из приложения Яндекс.Ключ",
"data": {
"username": "Логин",
"password": "Пароль или одноразовый пароль"
}
},
"email": {
"data": {
"username": "Логин"
}
},
"email2": {
"description": "Ссылка отправлена на ящик `{email}`. Подтвердите её там, и затем нажмите **Подтвердить** здесь."
},
"cookies": {
"description": "1. Установите расширение [Copy Cookies](https://chrome.google.com/webstore/detail/copy-cookies/jcbpglbplpblnagieibnemmkiamekcdg) в Google Chrome, Yandex Browser или Opera\n2. Авторизуйтесь в [Yandex ID](https://passport.yandex.ru/profile) (не используйте режим инкогнито)\n3. Скопируйте сюда Cookies с помощью расширения",
"data": {
"cookies": "Cookies JSON"
}
},
"token": {
"description": "Вы можете перенести токен с другого сервера Home Assistant. Его можно найти в файле `/config/.storage/core.config_entries`. Других способов получить токен - нет",
"data": {
"token": "Токен"
}
},
"captcha": {
"description": "Введите код с картинки\n![]({captcha_url})",
"data": {
"captcha_answer": "Код"
}
},
"captcha2": {
"data": {
"password": "Пароль или одноразовый пароль"
}
}
}
}
}
@@ -0,0 +1,16 @@
{
"state": {
"_": {
"off": "Выключено",
"on": "Включено",
"white_tea": "Белый чай",
"green_tea": "Зеленый чай",
"red_tea": "Красный чай",
"herbal_tea": "Травяной чай",
"flower_tea": "Цветочный чай",
"puerh_tea": "Чай пуэр",
"oolong_tea": "Чай улун",
"black_tea": "Черный чай"
}
}
}
@@ -0,0 +1,130 @@
import logging
from homeassistant.components.vacuum import StateVacuumEntity, SUPPORT_START, \
SUPPORT_STOP, SUPPORT_FAN_SPEED, SUPPORT_PAUSE, STATE_CLEANING, \
SUPPORT_BATTERY
from homeassistant.const import STATE_IDLE, STATE_PAUSED
from . import DOMAIN, CONF_INCLUDE, DATA_CONFIG, YandexQuasar
_LOGGER = logging.getLogger(__name__)
DEVICES = ['devices.types.vacuum_cleaner']
async def async_setup_entry(hass, entry, async_add_entities):
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
quasar = hass.data[DOMAIN][entry.unique_id]
devices = [
YandexVacuum(quasar, device)
for device in quasar.devices
if device['name'] in include and device['type'] in DEVICES
]
async_add_entities(devices)
# noinspection PyAbstractClass
class YandexVacuum(StateVacuumEntity):
_battery = None
_fan_speed = None
_fan_speed_list = None
_state = None
_supported = 0
def __init__(self, quasar: YandexQuasar, device: dict):
self.quasar = quasar
self.device = device
@property
def unique_id(self):
return self.device['id'].replace('-', '')
@property
def name(self):
return self.device['name']
@property
def should_poll(self):
return True
@property
def state(self):
return self._state
@property
def supported_features(self):
return self._supported
@property
def battery_level(self):
return self._battery
@property
def fan_speed(self):
return self._fan_speed
@property
def fan_speed_list(self):
return self._fan_speed_list
async def async_start(self):
await self.quasar.device_action(self.device['id'], on=True)
async def async_stop(self, **kwargs):
await self.quasar.device_action(self.device['id'], on=False)
async def async_pause(self):
await self.quasar.device_action(self.device['id'], pause=True)
async def async_set_fan_speed(self, fan_speed, **kwargs):
await self.quasar.device_action(self.device['id'],
work_speed=fan_speed)
async def async_added_to_hass(self):
data = await self.quasar.get_device(self.device['id'])
for capability in data['capabilities']:
# TODO: type devices.capabilities.on_off don't have property
# parameters instance
instance = capability['parameters'].get('instance', 'on')
if instance == 'on':
self._supported |= SUPPORT_START | SUPPORT_STOP
elif instance == 'work_speed':
self._supported |= SUPPORT_FAN_SPEED
self._fan_speed_list = [
p['value'] for p in capability['parameters']['modes']
]
elif instance == 'pause':
self._supported |= SUPPORT_PAUSE
for property_ in data['properties']:
instance = property_['parameters']['instance']
if instance == 'battery_level':
self._supported |= SUPPORT_BATTERY
await self.async_update(data)
async def async_update(self, data: dict = None):
if not data:
data = await self.quasar.get_device(self.device['id'])
for capability in data['capabilities']:
if not capability['retrievable']:
continue
instance = capability['state']['instance']
if instance == 'on':
self._state = (STATE_CLEANING if capability['state']['value']
else STATE_IDLE)
elif instance == 'work_speed':
self._fan_speed = capability['state']['value']
elif instance == 'pause':
if capability['state']['value']:
self._state = STATE_PAUSED
for property_ in data['properties']:
instance = property_['parameters']['instance']
if instance == 'battery_level':
self._battery = property_['state']['value']
@@ -0,0 +1,120 @@
import logging
from homeassistant.components.water_heater import WaterHeaterEntity, \
SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE
from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE
from . import DOMAIN, CONF_INCLUDE, DATA_CONFIG, YandexQuasar
_LOGGER = logging.getLogger(__name__)
DEVICES = ['devices.types.cooking.kettle']
async def async_setup_entry(hass, entry, async_add_entities):
include = hass.data[DOMAIN][DATA_CONFIG][CONF_INCLUDE]
quasar = hass.data[DOMAIN][entry.unique_id]
devices = [
YandexKettle(quasar, device)
for device in quasar.devices
if device['name'] in include and device['type'] in DEVICES
]
async_add_entities(devices, True)
class YandexKettle(WaterHeaterEntity):
def __init__(self, quasar: YandexQuasar, device: dict):
self.quasar = quasar
self.device = device
self._attr_should_poll = True
self._attr_supported_features = 0
self._attr_temperature_unit = TEMP_CELSIUS
self._attr_name = device['name']
self._attr_unique_id = device['id'].replace('-', '')
async def init_params(self, capabilities: list):
self._attr_operation_list = ["on", "off"]
self._attr_supported_features |= SUPPORT_OPERATION_MODE
for cap in capabilities:
# there is no instance for 'on' capability, but this capability
# doesn't have useful init params
if 'instance' not in cap['parameters']:
continue
inst = cap['parameters']['instance']
if inst == 'temperature':
assert cap['parameters']['unit'] == 'unit.temperature.celsius'
self._attr_min_temp = cap['parameters']['range']['min']
self._attr_max_temp = cap['parameters']['range']['max']
self._attr_precision = cap['parameters']['range']['precision']
self._attr_supported_features |= SUPPORT_TARGET_TEMPERATURE
elif inst == 'tea_mode':
self._attr_operation_list += [
mode['value'] for mode in
cap['parameters']['modes']
]
elif inst == 'mute':
pass
elif inst == 'keep_warm':
self._attr_supported_features |= SUPPORT_AWAY_MODE
async def async_update(self):
data = await self.quasar.get_device(self.device['id'])
try:
if self._attr_current_operation is None:
await self.init_params(data['capabilities'])
self._attr_available = data['state'] == 'online'
for cap in data['capabilities']:
if not cap['retrievable']:
continue
inst = cap['state']['instance']
if inst == 'on':
self._attr_current_operation = \
'on' if cap['state']['value'] else 'off'
elif inst == 'temperature':
self._attr_target_temperature = cap['state']['value']
elif inst == 'tea_mode':
if self._attr_current_operation == 'on':
self._attr_current_operation = cap['state']['value']
elif inst == 'mute':
pass
elif inst == 'keep_warm':
self._attr_is_away_mode_on = cap['state']['value']
for prop in data['properties']:
if not prop['retrievable']:
continue
if prop['parameters']['instance'] == 'temperature':
value = prop['state']['value'] if prop['state'] else None
self._attr_current_temperature = value
except:
_LOGGER.exception(data)
async def async_set_operation_mode(self, operation_mode):
if operation_mode == "on":
kwargs = {"on": True}
elif operation_mode == "off":
kwargs = {"on": False}
else:
kwargs = {"tea_mode": operation_mode}
await self.quasar.device_action(self.device['id'], **kwargs)
async def async_set_temperature(self, **kwargs):
value = round(kwargs[ATTR_TEMPERATURE] / self._attr_precision) * \
self._attr_precision
await self.quasar.device_action(self.device['id'], temperature=value)
async def async_turn_away_mode_on(self):
await self.quasar.device_action(self.device['id'], keep_warm=True)
async def async_turn_away_mode_off(self):
await self.quasar.device_action(self.device['id'], keep_warm=False)