first commit
This commit is contained in:
@@ -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\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",
|
||||
"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)
|
||||
Reference in New Issue
Block a user