This commit is contained in:
Victor Alexandrovich Tsyrenschikov
2026-03-30 20:25:42 +05:00
parent 139f9f1bd2
commit 373ed28445
2449 changed files with 53602 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
Smart speakers types:
```
devices.types.smart_speaker.yandex.station
devices.types.smart_speaker.yandex.station_2
devices.types.smart_speaker.yandex.station.mini
devices.types.smart_speaker.yandex.station.micro
devices.types.smart_speaker.yandex.station.mini_2
devices.types.smart_speaker.yandex.station.mini_2_no_clock
devices.types.smart_speaker.yandex.station.midi
devices.types.smart_speaker.yandex.station.quinglong
devices.types.smart_speaker.yandex.station.chiron
devices.types.smart_speaker.dexp.smartbox
devices.types.smart_speaker.irbis.a
devices.types.smart_speaker.elari.smartbeat
devices.types.smart_speaker.lg.xboom_wk7y
devices.types.smart_speaker.prestigio.smartmate
devices.types.smart_speaker.jbl.link_music
devices.types.smart_speaker.jbl.link_portable
devices.types.media_device.dongle.yandex.module
devices.types.media_device.dongle.yandex.module_2
devices.types.media_device.tv
devices.types.media_device.tv.yandex.goya
devices.types.media_device.tv.yandex.magritte
```

View File

@@ -0,0 +1,6 @@
DOMAIN = "yandex_station"
CONF_MEDIA_PLAYERS = "media_players"
DATA_CONFIG = "config"
DATA_SPEAKERS = "speakers"

View File

@@ -0,0 +1,133 @@
import logging
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import DOMAIN
from .yandex_quasar import YandexQuasar
_LOGGER = logging.getLogger(__name__)
def extract_instance(item: dict) -> str | None:
if item["type"] == "devices.capabilities.on_off":
return "on"
if item["type"] == "devices.capabilities.lock":
return "lock"
if item["type"] == "devices.capabilities.zigbee_node":
return "zigbee"
return item["parameters"].get("instance")
def extract_parameters(items: list[dict]) -> dict:
result = {}
for item in items:
# skip none (unknown) instances
if instance := extract_instance(item):
result[instance] = {"retrievable": item["retrievable"], **item["parameters"]}
return result
def extract_state(items: list[dict]) -> dict:
result = {}
for item in items:
if instance := extract_instance(item):
value = item["state"]["value"] if item["state"] else None
result[instance] = value
return result
class YandexEntity(Entity):
def __init__(self, quasar: YandexQuasar, device: dict, config: dict = None):
self.quasar = quasar
self.device = device
self.config = config
# "online", "unknown" or key not exist
self._attr_available = device.get("state") != "offline"
self._attr_name = device["name"]
self._attr_should_poll = False
self._attr_unique_id = device["id"].replace("-", "")
device_id = i["device_id"] if (i := device.get("quasar_info")) else device["id"]
self._attr_device_info: DeviceInfo = DeviceInfo(
identifiers={(DOMAIN, device_id)},
name=self.device["name"],
suggested_area=self.device.get("room_name"),
)
if device_info := device.get("parameters", {}).get("device_info", {}):
for key in ("manufacturer", "model", "sw_version", "hw_version"):
if value := device_info.get(key):
self._attr_device_info[key] = value
try:
self.internal_init(
extract_parameters(device["capabilities"]),
extract_parameters(device["properties"]),
)
self.internal_update(
extract_state(device["capabilities"]),
extract_state(device["properties"]),
)
except Exception as e:
_LOGGER.error("Device init failed: %s", repr(e))
self.quasar.subscribe_update(device["id"], self.on_update)
def on_update(self, device: dict):
self._attr_available = device["state"] in ("online", "unknown")
self.internal_update(
extract_state(device["capabilities"]) if "capabilities" in device else {},
extract_state(device["properties"]) if "properties" in device else {},
)
if self.hass and self.entity_id:
self._async_write_ha_state()
def internal_init(self, capabilities: dict, properties: dict):
"""Will be called on Entity init. Capabilities and properties will have all
variants.
"""
pass
def internal_update(self, capabilities: dict, properties: dict):
"""Will be called on Entity init and every data update. Variant
- instance with some value (str, float, dict)
- instance with null value
- no instance (if it not upated)
"""
pass
async def async_update(self):
device = await self.quasar.get_device(self.device)
self.quasar.dispatch_update(device["id"], device)
async def device_action(self, instance: str, value, relative=False):
try:
await self.quasar.device_action(self.device, instance, value, relative)
except Exception as e:
raise HomeAssistantError(f"Device action failed: {repr(e)}")
async def device_actions(self, **kwargs):
try:
await self.quasar.device_actions(self.device, **kwargs)
except Exception as e:
raise HomeAssistantError(f"Device action failed: {repr(e)}")
async def device_color(self, **kwargs):
try:
await self.quasar.device_color(self.device, **kwargs)
except Exception as e:
raise HomeAssistantError(f"Device action failed: {repr(e)}")
class YandexCustomEntity(YandexEntity):
def __init__(self, quasar: YandexQuasar, device: dict, config: dict):
self.instance = extract_instance(config)
super().__init__(quasar, device, config)
if name := config["parameters"].get("name"):
self._attr_name += " " + name
self._attr_unique_id += " " + self.instance

View File

@@ -0,0 +1,99 @@
import io
import os
import re
from PIL import Image, ImageDraw, ImageFont
WIDTH = 1280
HEIGHT = 720
WIDTH2 = WIDTH // 2
HEIGHT2 = HEIGHT // 2
HEIGHT6 = HEIGHT // 6
def font_path() -> str:
dirname = os.path.dirname(os.path.realpath(__file__))
return os.path.join(dirname, "fonts", "DejaVuSans.ttf")
def draw_text(
ctx: ImageDraw,
text: str,
box: tuple,
anchor: str,
fill: str | tuple,
font_size: int,
line_width: int = 20,
):
"""Draw multiline text inside box with smart anchor."""
lines = re.findall(r"(.{1,%d})(?:\s|$)" % line_width, text)
if (font_size > 70 and len(lines) > 3) or (font_size <= 70 and len(lines) > 4):
return draw_text(ctx, text, box, anchor, fill, font_size - 10, line_width + 3)
# https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html#text-anchors
if anchor[0] == "l":
x = box[0]
align = "la" # left-ascender
elif anchor[0] == "m":
x = box[0] + (box[2]) // 2
align = "ma" # middle-ascender
elif anchor[0] == "r":
x = box[0] + box[2]
align = "ra" # right-ascender
else:
raise NotImplementedError(anchor)
if anchor[1] == "t":
y = box[1]
elif anchor[1] == "m":
y = box[1] + (box[3] - len(lines) * font_size) // 2
elif anchor[1] == "b":
y = box[1] + (box[3] - len(lines) * font_size)
else:
raise NotImplementedError(anchor)
font = ImageFont.truetype(font_path(), font_size, encoding="UTF-8")
for line in lines:
ctx.text((x, y), line, anchor=align, fill=fill, font=font)
y += font_size
def draw_cover(title: str, artist: str, cover: bytes) -> bytes:
cover_canvas = Image.open(io.BytesIO(cover))
assert cover_canvas.size == (400, 400)
canvas = Image.new("RGB", (WIDTH, HEIGHT))
canvas.paste(cover_canvas, (WIDTH2 - 200, HEIGHT6 * 2 - 200))
ctx = ImageDraw.Draw(canvas)
if title:
draw_text(ctx, title, (0, HEIGHT6 * 4, WIDTH, HEIGHT6), "mb", "white", 60, 35)
if artist:
draw_text(ctx, artist, (0, HEIGHT6 * 5, WIDTH, HEIGHT6), "mt", "grey", 50, 40)
bytes = io.BytesIO()
canvas.save(bytes, format="JPEG", quality=75)
return bytes.getvalue()
def draw_lyrics(first: str | None, second: str | None) -> bytes:
canvas = Image.new("RGB", (WIDTH, HEIGHT))
ctx = ImageDraw.Draw(canvas)
if first:
draw_text(ctx, first, (0, 50, WIDTH, HEIGHT2 - 50), "mm", "white", 100)
if second:
draw_text(ctx, second, (0, HEIGHT2, WIDTH, HEIGHT2 - 50), "mm", "grey", 100)
bytes = io.BytesIO()
canvas.save(bytes, format="JPEG", quality=75)
return bytes.getvalue()
def draw_none() -> bytes:
canvas = Image.new("RGB", (WIDTH, HEIGHT), "grey")
bytes = io.BytesIO()
canvas.save(bytes, format="JPEG", quality=75)
return bytes.getvalue()

View File

@@ -0,0 +1,90 @@
import base64
class Protobuf:
page_size = 0
pos = 0
def __init__(self, raw: str | bytes):
self.raw = base64.b64decode(raw) if isinstance(raw, str) else 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()
typ = b & 0b111
tag = b >> 3
if typ == 0: # VARINT
v = self.read_varint()
elif typ == 1: # I64
v = self.read(8)
elif typ == 2: # LEN
v = self.read_bytes()
try:
v = Protobuf(v).read_dict()
except:
pass
elif typ == 5: # I32
v = self.read(4)
else:
raise NotImplementedError
if tag in res:
if isinstance(res[tag], list):
res[tag] += [v]
else:
res[tag] = [res[tag], v]
else:
res[tag] = v
return res
def append_varint(b: bytearray, i: int):
while i >= 0x80:
b.append(0x80 | (i & 0x7F))
i >>= 7
b.append(i)
def loads(raw: str | bytes) -> dict:
return Protobuf(raw).read_dict()
def dumps(data: dict) -> bytes:
b = bytearray()
for tag, value in data.items():
assert isinstance(tag, int)
if isinstance(value, str):
b.append(tag << 3 | 2)
append_varint(b, len(value))
b.extend(value.encode())
else:
raise NotImplementedError
return bytes(b)

View File

@@ -0,0 +1,48 @@
# Thanks to: https://github.com/iswitch/ha-yandex-icons
QUASAR_INFO: dict[str, list] = {
# колонки Яндекса
"yandexstation": ["yandex:station", "Яндекс", "Станция (2018)"],
"yandexstation_2": ["yandex:station-max", "Яндекс", "Станция Макс (2020)"],
"yandexmini": ["yandex:station-mini", "Яндекс", "Станция Мини (2019)"],
"yandexmini_2": ["yandex:station-mini-2", "Яндекс", "Станция Мини 2 (2021)"],
"bergamot": ["yandex:station-mini-3", "Яндекс", "Станция Мини 3 (2024)"],
"yandexmicro": ["yandex:station-lite", "Яндекс", "Станция Лайт (2021)"],
"plum": ["yandex:station-lite-2", "Яндекс", "Станция Лайт 2 (2024)"],
"yandexmidi": ["yandex:station-2", "Яндекс", "Станция 2 (2022)"], # zigbee
"cucumber": ["yandex:station-midi", "Яндекс", "Станция Миди (2023)"], # zigbee
"chiron": ["yandex:station-duo-max", "Яндекс", "Станция Дуо Макс (2023)"], # zigbee
"orion": ["yandex:station-max", "Яндекс", "Станция 3 (2025)"],
# платформа Яндекс.ТВ (без облачного управления!)
"yandexmodule": ["yandex:module", "Яндекс", "Модуль (2019)"],
"yandexmodule_2": ["yandex:module-2", "Яндекс", "Модуль 2 (2021)"],
"yandex_tv": ["mdi:television-classic", "Unknown", "ТВ с Алисой"],
# ТВ с Алисой
"goya": ["mdi:television-classic", "Яндекс", "ТВ (2022)"],
"magritte": ["mdi:television-classic", "Яндекс", "ТВ Станция (2023)"],
"monet": ["mdi:television-classic", "Яндекс", "ТВ Станция Бейсик (2024)"],
# колонки НЕ Яндекса
"lightcomm": ["yandex:dexp-smartbox", "DEXP", "Smartbox"],
"elari_a98": ["yandex:elari-smartbeat", "Elari", "SmartBeat"],
"linkplay_a98": ["yandex:irbis-a", "IRBIS", "A"],
"wk7y": ["yandex:lg-xboom-wk7y", "LG", "XBOOM AI ThinQ WK7Y"],
"prestigio_smart_mate": ["yandex:prestigio-smartmate", "Prestigio", "Smartmate"],
"jbl_link_music": ["yandex:jbl-link-music", "JBL", "Link Music"],
"jbl_link_portable": ["yandex:jbl-link-portable", "JBL", "Link Portable"],
# экран с Алисой
"quinglong": ["yandex:display-xiaomi", "Xiaomi", "Smart Display 10R X10G (2023)"],
# не колонки
"saturn": ["yandex:hub", "Яндекс", "Хаб (2023)"],
"mike": ["yandex:lg-xboom-wk7y", "Яндекс", "IP камера (2025)"],
}
def has_quasar(device: dict) -> bool:
if device.get("sharing_info"):
return False # skip shared devices
if info := device.get("quasar_info"):
if info["platform"] in {"saturn", "mike"}:
return False # skip non speakers
return True
return False

View File

@@ -0,0 +1,189 @@
import asyncio
import logging
import secrets
import time
from contextlib import suppress
from urllib.parse import urljoin, urlparse
import jwt
from aiohttp import ClientError, ClientSession, ClientTimeout, hdrs, web
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import async_process_play_media_url
from homeassistant.core import HomeAssistant
from homeassistant.helpers import network
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
MIME_TYPES = {
"flac": "audio/x-flac",
"aac": "audio/aac",
"he.aac": "audio/aac",
"mp3": "audio/mpeg",
# https://www.rfc-editor.org/rfc/rfc4337.txt
"flac.mp4": "audio/mp4",
"aac.mp4": "audio/mp4",
"he.aac.mp4": "audio/mp4",
# application/vnd.apple.mpegurl
"m3u8": "application/x-mpegURL",
"ts": "video/MP2T",
"gif": "image/gif",
"mp4": "video/mp4",
}
def get_ext(url: str) -> str:
return urlparse(url).path.split(".")[-1]
def get_url(url: str, ext: str = None, expires: int = 3600) -> str:
assert StreamView.hass_url
assert url.startswith(("http://", "https://", "/")), url
ext = ext.replace("-", ".") if ext else get_ext(url)
assert ext in MIME_TYPES, ext
# using token for security reason
payload: dict[str, str | int] = {"url": url}
if expires:
payload["exp"] = int(time.time()) + expires
token = jwt.encode(payload, StreamView.key, "HS256")
return f"{StreamView.hass_url}/api/yandex_station/{token}.{ext}"
async def get_hls(session: ClientSession, url: str) -> str:
async with session.get(url) as r:
lines = (await r.text()).splitlines()
for i, item in enumerate(lines):
item = item.strip()
if not item or item.startswith("#"):
continue
# should use r.url, not url, because redirects
item = urljoin(str(r.url), item)
lines[i] = get_url(item)
return "\n".join(lines)
def copy_headers(headers: dict, names: tuple) -> dict:
return {k: v for k in names if (v := headers.get(k))}
CONTENT_TYPES = {
"audio/aac": "aac",
"audio/mpeg": "mp3",
"audio/x-flac": "flac",
"application/vnd.apple.mpegurl": "m3u8",
"application/x-mpegURL": "m3u8",
}
REQUEST_HEADERS = (hdrs.RANGE,)
RESPONSE_HEADERS = (hdrs.ACCEPT_RANGES, hdrs.CONTENT_LENGTH, hdrs.CONTENT_RANGE)
STREAM_TIMEOUT = ClientTimeout(sock_connect=10, sock_read=10)
async def get_content_type(session: ClientSession, url: str) -> str | None:
try:
async with session.head(url) as r:
if r.content_type.startswith("text/html"):
# fix Icecast bug - return text/html on HEAD
# https://github.com/AlexxIT/YandexStation/issues/696
async with session.get(url) as r2:
return CONTENT_TYPES.get(r2.content_type)
return CONTENT_TYPES.get(r.content_type)
except Exception as e:
_LOGGER.debug(f"Can't get content type: {repr(e)}")
return None
class StreamView(HomeAssistantView):
requires_auth = False
url = "/api/yandex_station/{token:[\\w-]+.[\\w-]+.[\\w-]+}.{ext}"
name = "api:yandex_station"
hass: HomeAssistant = None
hass_url: str = None
key: str = None
def __init__(self, hass: HomeAssistant):
self.session = async_get_clientsession(hass)
StreamView.hass = hass
StreamView.key = secrets.token_hex()
try:
StreamView.hass_url = network.get_url(hass, allow_external=False)
_LOGGER.debug(f"Локальный адрес Home Assistant: {StreamView.hass_url}")
except Exception as e:
_LOGGER.warning(f"Ошибка получения локального адреса Home Assistant: {e}")
def get_url(self, url: str) -> str:
if url[0] != "/":
return url
return async_process_play_media_url(self.hass, url)
async def head(self, request: web.Request, token: str, ext: str):
try:
data = jwt.decode(token, StreamView.key, "HS256")
except jwt.InvalidTokenError:
return web.HTTPNotFound()
_LOGGER.debug(f"Stream.{ext} HEAD {data}")
url = self.get_url(data["url"])
headers = copy_headers(request.headers, REQUEST_HEADERS)
async with self.session.head(url, headers=headers) as r:
headers = copy_headers(r.headers, RESPONSE_HEADERS)
headers[hdrs.CONTENT_TYPE] = MIME_TYPES[ext]
return web.Response(status=r.status, headers=headers)
async def get(self, request: web.Request, token: str, ext: str):
try:
data = jwt.decode(token, StreamView.key, "HS256")
except jwt.InvalidTokenError:
return web.HTTPNotFound()
_LOGGER.debug(f"Stream.{ext} GET {data}")
url = self.get_url(data["url"])
try:
if ext == "m3u8":
body = await get_hls(self.session, url)
return web.Response(
body=body,
headers={
hdrs.ACCESS_CONTROL_ALLOW_HEADERS: "*",
hdrs.ACCESS_CONTROL_ALLOW_ORIGIN: "*",
hdrs.CONTENT_TYPE: MIME_TYPES[ext],
},
)
headers = copy_headers(request.headers, REQUEST_HEADERS)
async with self.session.get(
url, headers=headers, timeout=STREAM_TIMEOUT
) as r:
headers = copy_headers(r.headers, RESPONSE_HEADERS)
headers[hdrs.CONTENT_TYPE] = MIME_TYPES[ext]
if ext == "ts":
headers[hdrs.ACCESS_CONTROL_ALLOW_HEADERS] = "*"
headers[hdrs.ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
response = web.StreamResponse(status=r.status, headers=headers)
response.force_close()
await response.prepare(request)
try:
while data := await r.content.readany():
await response.write(data)
except ClientError as e:
_LOGGER.debug(f"Streaming client error: {repr(e)}")
except TimeoutError as e:
_LOGGER.debug(f"Streaming timeout: {repr(e)}")
return response
except:
pass

View File

@@ -0,0 +1,479 @@
import base64
import json
import logging
import os
import re
import uuid
from datetime import datetime
from logging import Logger
from typing import Callable, List
from aiohttp import web
from homeassistant.components import frontend
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import MediaPlayerEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import (
TrackTemplate,
TrackTemplateResult,
async_track_template_result,
)
from homeassistant.helpers.template import Template
from yarl import URL
from . import protobuf, stream
from .const import CONF_MEDIA_PLAYERS, DATA_CONFIG, DOMAIN
from .yandex_session import YandexSession
_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: HomeAssistant, 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 fix_dialog_text(text: str) -> str:
# known problem words: запа, таблетк, трусы
return re.sub("[а-яё]+", lambda m: m.group(0).upper(), text)
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, 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: HomeAssistant, 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: HomeAssistant):
lovelace = hass.data.get("lovelace")
# GUI off mode
if not lovelace:
return False
resources = (
lovelace.resources if hasattr(lovelace, "resources") else lovelace["resources"]
)
await resources.async_get_info()
return any(
"/yandex-icons.js" in resource["url"] for resource in resources.async_items()
)
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_]+)"),
"bookmate": re.compile(r"https://books\.yandex\.ru/audiobooks/(\w+)"),
}
async def get_media_payload(session: YandexSession, media_id: str) -> dict | None:
for k, v in RE_MEDIA.items():
if m := v.search(media_id):
if k in ("youtube", "kinopoisk", "strm", "yavideo"):
return play_video_by_descriptor(k, m[1])
elif k == "vk":
url = f"https://vk.com/{m[1]}"
return play_video_by_descriptor("yavideo", url)
elif k == "music.yandex.playlist":
if uid := await get_playlist_uid(session, m[1], m[2]):
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
elif k == "bookmate":
try:
r = await session.post(
"https://api-gateway-rest.bookmate.yandex.net/audiobook/album",
json={"audiobook_uuid": m[1]},
)
resp = await r.json()
return {
"command": "playMusic",
"type": "album",
"id": resp["album_id"],
}
except:
return None
if ext := await stream.get_content_type(session._session, media_id):
return get_stream_url(media_id, "stream." + ext)
return None
def get_stream_url(
media_id: str, media_type: str, metadata: dict = None
) -> dict | None:
if media_type.startswith("stream."):
ext = media_type[7:] # manual file extension
else:
ext = stream.get_ext(media_id) # auto detect extension
if ext in ("aac", "flac", "m3u8", "mp3", "mp4"):
# station can't handle links without extension
payload = {
"streamUrl": stream.get_url(media_id, ext, 3),
"force_restart_player": True,
}
if metadata:
if title := metadata.get("title"):
payload["title"] = title
if (url := metadata.get("imageUrl")) and url.startswith("https://"):
payload["imageUrl"] = url[8:]
return external_command("radio_play", payload)
if ext == "gif":
# maximum link size ~250 symbols
if media_id[0] == "/":
media_id = stream.get_url(media_id, ext, 0)
payload = {"animation_sequence": [{"frontal_led_image": media_id}]}
return external_command("draw_led_screen", payload)
return None
def external_command(name: str, payload: dict | str = None) -> dict:
data = {1: name}
if payload:
data[2] = json.dumps(payload) if isinstance(payload, dict) else payload
return {
"command": "externalCommandBypass",
"data": base64.b64encode(protobuf.dumps(data)).decode(),
}
def draw_animation_command(data: str) -> dict:
payload = {
"animation_stop_policy": "PlayOnce",
"animations": [
{"base64_encoded_value": base64.b64encode(bytes.fromhex(data)).decode()}
],
}
return external_command("draw_scled_animations", payload)
def get_radio_info(data: dict) -> dict:
state = protobuf.loads(data["extra"]["appState"])
metaw = json.loads(state[6][3][7])
item = protobuf.loads(metaw["scenario_meta"]["queue_item"])
return {"url": item[7][1].decode(), "codec": "m3u8"}
async def get_zeroconf_singleton(hass: HomeAssistant):
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()
# noinspection PyProtectedMember
def fix_recognition_lang(hass: HomeAssistant, 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(f"/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
def fix_cloud_text(text: str) -> str:
# на июнь 2023 единственное ограничение - 100 символов
text = re.sub(r" +", " ", text)
return text.strip()[:100]
async def get_playlist_uid(
session: YandexSession, username: str, playlist_id: str
) -> int | None:
try:
r = await session.get(
f"https://api.music.yandex.net/users/{username}/playlists/{playlist_id}",
)
resp = await r.json()
return resp["result"]["owner"]["uid"]
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, speaker_id: str) -> 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:
if conf := hass.data[DOMAIN][DATA_CONFIG].get(CONF_MEDIA_PLAYERS):
if isinstance(conf, dict):
return [{"entity_id": k, "name": v} for k, v in conf.items()]
if isinstance(conf, list):
# conf item should have entity_id and name
# conf item may have speaker_id filter
return [
item
for item in conf
if "entity_id" in item
and "name" in item
and speaker_id in item.get("speaker_id", speaker_id)
]
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
or entity.entity_id
),
}
for entity in ec.entities
if (
entity.platform.platform_name != DOMAIN
and entity.supported_features & MediaPlayerEntityFeature.PLAY_MEDIA
)
]
except Exception as e:
_LOGGER.warning("Can't get media_players", exc_info=e)
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 f"{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
query = dict(url.query)
query.pop("", None) # remove empty key in new python versions
return query
def track_template(hass: HomeAssistant, template: str, update: Callable) -> Callable:
template = Template(template, hass)
update(template.async_render())
# important to use async because from sync action will be problems with update state
async def action(event, updates: list[TrackTemplateResult]):
update(next(i.result for i in updates))
track = async_track_template_result(
hass, [TrackTemplate(template=template, variables=None)], action
)
return track.async_remove
def get_entity(hass: HomeAssistant, entity_id: str) -> Entity | None:
try:
ec: EntityComponent = hass.data["entity_components"]["media_player"]
return next(e for e in ec.entities if e.entity_id == entity_id)
except:
pass
return None

View File

@@ -0,0 +1,316 @@
import asyncio
import ipaddress
import json
import logging
import time
import uuid
from asyncio import Future
from typing import Callable, Dict, Optional
from aiohttp import ClientConnectorError, ClientWebSocketResponse, ServerTimeoutError
from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf
from .yandex_session import YandexSession
_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
)
# @dext0r: fix bug with wrong content-type
resp = json.loads(await r.text())
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("Локальное подключение")
fails += 1 # will be reset with first msg from station
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.keep_task or self.keep_task.done():
# self.keep_task = self.loop.create_task(self._keep_connection())
async for msg in self.ws:
# Большая станция в режиме idle шлёт статус раз в 5 секунд,
# в режиме playing шлёт чаще раза в 1 секунду
# self.next_ping_ts = time.time() + 6
if isinstance(msg.data, ServerTimeoutError):
raise msg.data
data = json.loads(msg.data)
fails = 0 # any message - reset fails
# debug(msg.data)
request_id = data.get("requestId")
if request_id in self.waiters:
result = {"status": data["status"]}
if vinsResponse := data.get("vinsResponse"):
try:
# payload only in yandex module
if payload := vinsResponse.get("payload"):
response = payload["response"]
else:
response = vinsResponse["response"]
if card := response.get("card"):
result.update(card)
elif cards := response.get("cards"):
result.update(cards[0])
elif is_streaming := response.get("is_streaming"):
result["is_streaming"] = is_streaming
elif output_speech := response.get("output_speech"):
result.update(output_speech)
except Exception as e:
_LOGGER.debug(f"Response error: {e}")
self.waiters[request_id].set_result(result)
self.update_handler(data)
# TODO: find better place
self.device_token = None
except (ClientConnectorError, ConnectionResetError, ServerTimeoutError) as e:
self.debug(f"Ошибка подключения: {repr(e)}")
except (asyncio.CancelledError, RuntimeError) as e:
# сюда попадаем при остановке HA
if isinstance(e, RuntimeError):
assert e.args[0] == "Session is closed", repr(e)
self.debug(f"Останавливаем подключение: {repr(e)}")
if self.ws and not self.ws.closed:
await self.ws.close()
return
except Exception as e:
_LOGGER.error(f"{self.name} => local | {repr(e)}")
# возвращаемся в облачный режим
self.update_handler(None)
# останавливаем попытки
if not self.url:
return
if fails:
# 0s, 30s, 60s, ... 5 min
delay = 30 * min(fails - 1, 10)
self.debug(f"Таймаут до следующего подключения {delay}")
await asyncio.sleep(delay)
_ = 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 as e:
_ = self.waiters.pop(request_id, None)
return {"error": repr(e)}
except Exception as e:
_LOGGER.error(f"{self.name} => local | {repr(e)}")
return {"error": repr(e)}
async def reset_session(self):
payload = {
"command": "serverAction",
"serverActionEventPayload": {
"type": "server_action",
"name": "on_reset_session",
},
}
await self.send(payload)
prev_msg = None
def debug_msg(self, data: dict):
data.pop("id")
data.pop("sentTime")
data["state"].pop("timeSinceLastVoiceActivity")
if player := data["state"].get("playerState"):
player.pop("progress")
if data == self.prev_msg:
return
for k in sorted(data.keys()):
if self.prev_msg and k in self.prev_msg and data[k] == self.prev_msg[k]:
continue
self.debug(f"{k}: {data[k]}")
if vins := data.get("vinsResponse"):
with open(f"{time.time()}.json", "w") as f:
json.dump(vins, f, ensure_ascii=False, indent=2)
self.prev_msg = data
class YandexIOListener:
add_handler = None
browser = None
def __init__(self, add_handler: Callable):
self.add_handler = add_handler
def start(self, zeroconf: Zeroconf):
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)
if not info:
return
properties = {
k.decode(): v.decode() if isinstance(v, bytes) else v
for k, v in info.properties.items()
}
self.add_handler(
{
"device_id": properties["deviceId"],
"platform": properties["platform"],
"host": str(ipaddress.ip_address(info.addresses[0])),
"port": info.port,
}
)
except Exception as e:
_LOGGER.debug("Can't get zeroconf info", exc_info=e)
def debug(data: bytes):
data: dict = json.loads(data)
if experiments := data.get("experiments"):
data["experiments"] = len(experiments)
if extra := data.get("extra"):
data["extra"] = {k: len(v) for k, v in extra.items()}
if features := data.get("supported_features"):
data["supported_features"] = len(features)
_LOGGER.debug(json.dumps(data, ensure_ascii=False))

View File

@@ -0,0 +1,67 @@
import base64
import hashlib
import hmac
from datetime import datetime
from .yandex_session import YandexSession
HEADERS = {"X-Yandex-Music-Client": "YandexMusicAndroid/24023621"}
async def get_file_info(
session: YandexSession, track_id: int, quality: str, codecs: str
) -> dict[str, str]:
# lossless + mp3 = 320 kbps
# nq + mp3 = 192 kbps
# lossless + aac = 256 kbps
# nq + aac = 192 kbps
timestamp = int(datetime.now().timestamp())
params = {
"ts": timestamp,
"trackId": track_id,
"quality": quality, # lossless,nq,lq
"codecs": codecs, # flac,aac,he-aac,mp3
"transports": "raw",
}
params["sign"] = sign(*params.values())[:-1]
r = await session.get(
"https://api.music.yandex.net/get-file-info",
headers=HEADERS,
params=params,
timeout=5,
)
raw = await r.json()
return raw["result"]["downloadInfo"]
async def get_lyrics(session: YandexSession, track_id: int | str) -> str | None:
# thanks to https://github.com/MarshalX/yandex-music-api
r = await session.post(
"https://api.music.yandex.net/tracks", data={"track-ids": [track_id]}, timeout=5
)
raw = await r.json()
if not raw["result"][0]["lyricsInfo"]["hasAvailableSyncLyrics"]:
return None
timestamp = int(datetime.now().timestamp())
params = {"timeStamp": timestamp, "sign": sign(track_id, timestamp)}
r = await session.get(
f"https://api.music.yandex.net/tracks/{track_id}/lyrics",
headers=HEADERS,
params=params,
timeout=5,
)
raw = await r.json()
url = raw["result"]["downloadUrl"]
r = await session.get(url, timeout=5)
raw = await r.read()
return raw.decode("utf-8")
def sign(*args) -> str:
msg = "".join(str(i) for i in args).replace(",", "").encode()
hmac_hash = hmac.new(b"p93jhgh689SBReK6ghtw62", msg, hashlib.sha256).digest()
return base64.b64encode(hmac_hash).decode()

View File

@@ -0,0 +1,750 @@
import asyncio
import json
import logging
from datetime import datetime
from aiohttp import WSMsgType
from .quasar_info import has_quasar
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",
"program": "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",
# climate
"swing": "devices.capabilities.mode",
# kettle:
"keep_warm": "devices.capabilities.toggle",
"tea_mode": "devices.capabilities.mode",
# cover
"open": "devices.capabilities.range",
# camera
"camera_pan": "devices.capabilities.range",
"camera_tilt": "devices.capabilities.range",
"get_stream": "devices.capabilities.video_stream",
# devices.types.remote_car.seat
"heating_mode": "devices.capabilities.range",
# devices.types.smart_speaker.yandex.station.orion
"led_array": "devices.capabilities.led_mask",
# don't work
"hsv": "devices.capabilities.color_setting",
"rgb": "devices.capabilities.color_setting",
"scene": "devices.capabilities.color_setting",
"temperature_k": "devices.capabilities.color_setting",
}
MASK_EN = "0123456789abcdef-"
MASK_RU = "оеаинтсрвлкмдпуяы"
def encode(uid: str) -> str:
"""Кодируем UID в рус. буквы. Яндекс привередливый."""
return "".join([MASK_RU[MASK_EN.index(s)] for s in uid])
def parse_scenario(data: dict) -> dict:
result = {
k: v
for k, v in data.items()
if k in ("name", "icon", "steps", "effective_time", "settings")
}
result["triggers"] = [parse_trigger(i) for i in data["triggers"]]
return result
def parse_trigger(data: dict) -> dict:
result = {k: v for k, v in data.items() if k == "filters"}
value = data["trigger"]["value"]
if isinstance(value, dict):
value = {
k: v
for k, v in value.items()
if k in ("instance", "property_type", "condition")
}
value["device_id"] = data["trigger"]["value"]["device"]["id"]
result["trigger"] = {"type": data["trigger"]["type"], "value": value}
return result
def parse_device(data: dict) -> dict:
return {
"id": data["id"],
"capabilities": [
{"type": i["type"], "state": i["state"]} for i in data["capabilities"]
],
"directives": data["directives"],
}
def scenario_speaker_tts(name: str, trigger: str, device_id: str, text: str) -> dict:
return {
"name": name,
"icon": "home",
"triggers": [
{
"trigger": {"type": "scenario.trigger.voice", "value": trigger},
}
],
"steps": [
{
"type": "scenarios.steps.actions.v2",
"parameters": {
"items": [
{
"id": device_id,
"type": "step.action.item.device",
"value": {
"id": device_id,
"item_type": "device",
"capabilities": [
{
"type": "devices.capabilities.quasar",
"state": {
"instance": "tts",
"value": {"text": text},
},
}
],
},
}
]
},
}
],
}
def scenario_speaker_action(
name: str, trigger: str, device_id: str, action: str
) -> dict:
return {
"name": name,
"icon": "home",
"triggers": [
{
"trigger": {"type": "scenario.trigger.voice", "value": trigger},
}
],
"steps": [
{
"type": "scenarios.steps.actions.v2",
"parameters": {
"items": [
{
"id": device_id,
"type": "step.action.item.device",
"value": {
"id": device_id,
"item_type": "device",
"capabilities": [
{
"type": "devices.capabilities.quasar.server_action",
"state": {
"instance": "text_action",
"value": action,
},
}
],
},
}
]
},
}
],
}
class Dispatcher:
dispatcher: dict[str, list] = None
def __init__(self):
self.dispatcher = {}
def subscribe_update(self, signal: str, target):
targets = self.dispatcher.setdefault(signal, [])
if target not in targets:
targets.append(target)
return lambda: targets.remove(target)
def dispatch_update(self, signal: str, message: dict):
if signal not in self.dispatcher:
return
for target in self.dispatcher[signal]:
target(message)
class YandexQuasar(Dispatcher):
# all devices
devices: list[dict] = None
scenarios: list[dict] = None
online_updated: asyncio.Event = None
updates_task: asyncio.Task = None
def __init__(self, session: YandexSession):
super().__init__()
self.session = session
self.online_updated = asyncio.Event()
self.online_updated.set()
async def init(self):
"""Основная функция. Возвращает список колонок."""
_LOGGER.debug("Получение списка устройств.")
r = await self.session.get(
f"https://iot.quasar.yandex.ru/m/v3/user/devices", timeout=15
)
resp = await r.json()
assert resp["status"] == "ok", resp
self.devices = []
for house in resp["households"]:
self.devices.extend(
{**device, "house_name": house["name"]} for device in house["all"]
)
await self.load_scenarios()
await self.load_speakers()
@property
def speakers(self):
return [i for i in self.devices if has_quasar(i) and i.get("capabilities")]
@property
def modules(self):
# modules don't have cloud scenarios
return [i for i in self.devices if has_quasar(i) and not i.get("capabilities")]
async def load_speakers(self):
hashes = {}
for scenario in self.scenarios:
try:
hash = scenario["triggers"][0]["value"]
hashes[hash] = scenario["id"]
except Exception:
pass
for speaker in self.speakers:
device_id: str = speaker["id"]
hash = encode(device_id)
speaker["scenario_id"] = (
hashes[hash]
if hash in hashes
else await self.add_scenario(device_id, hash)
)
async def load_speaker_config(self, device: dict):
"""Загружаем device_id и platform для колонок. Они не приходят с полным
списком устройств.
"""
r = await self.session.get(
f"https://iot.quasar.yandex.ru/m/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):
"""Получает список сценариев, которые мы ранее создали."""
r = await self.session.get(f"https://iot.quasar.yandex.ru/m/user/scenarios")
resp = await r.json()
assert resp["status"] == "ok", resp
self.scenarios = resp["scenarios"]
async def update_scenario(self, name: str):
# check if we known scenario name
sid = next((i["id"] for i in self.scenarios if i["name"] == name), None)
if sid is None:
# reload scenarios list
await self.load_scenarios()
sid = next(i["id"] for i in self.scenarios if i["name"] == name)
# load scenario info
r = await self.session.get(
f"https://iot.quasar.yandex.ru/m/v4/user/scenarios/{sid}/edit"
)
resp = await r.json()
assert resp["status"] == "ok"
# convert to scenario patch
payload = parse_scenario(resp["scenario"])
r = await self.session.put(
f"https://iot.quasar.yandex.ru/m/v3/user/scenarios/{sid}", json=payload
)
resp = await r.json()
assert resp["status"] == "ok", resp
async def add_scenario(self, device_id: str, hash: str) -> str:
"""Добавляет сценарий-пустышку."""
payload = scenario_speaker_tts("ХА " + device_id, hash, device_id, "пустышка")
r = await self.session.post(
f"https://iot.quasar.yandex.ru/m/v4/user/scenarios", json=payload
)
resp = await r.json()
assert resp["status"] == "ok", resp
return resp["scenario_id"]
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}")
device_id = device["id"]
name = "ХА " + device_id
trigger = encode(device_id)
payload = (
scenario_speaker_tts(name, trigger, device_id, text)
if is_tts
else scenario_speaker_action(name, trigger, device_id, text)
)
sid = device["scenario_id"]
r = await self.session.put(
f"https://iot.quasar.yandex.ru/m/v4/user/scenarios/{sid}", json=payload
)
resp = await r.json()
assert resp["status"] == "ok", resp
r = await self.session.post(
f"https://iot.quasar.yandex.ru/m/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, str):
did = device["id"]
r = await self.session.get(
f"https://iot.quasar.yandex.ru/m/v2/user/devices/{did}/configuration"
)
resp = await r.json()
assert resp["status"] == "ok", resp
return resp["quasar_config"], resp["quasar_config_version"]
async def set_device_config(self, device: dict, config: dict, version: str):
_LOGGER.debug(f"Меняем конфиг станции: {config}")
did = device["id"]
r = await self.session.post(
f"https://iot.quasar.yandex.ru/m/v3/user/devices/{did}/configuration/quasar",
json={"config": config, "version": version},
)
resp = await r.json()
assert resp["status"] == "ok", resp
async def get_device(self, device: dict):
r = await self.session.get(
f"https://iot.quasar.yandex.ru/m/user/{device['item_type']}s/{device['id']}"
)
resp = await r.json()
assert resp["status"] == "ok", resp
return resp
async def device_action(self, device: dict, instance: str, value, relative=False):
action = {
"state": {"instance": instance, "value": value},
"type": IOT_TYPES.get(instance, "devices.capabilities.custom.button"),
}
if relative:
action["state"]["relative"] = True
r = await self.session.post(
f"https://iot.quasar.yandex.ru/m/user/{device['item_type']}s/{device['id']}/actions",
json={"actions": [action]},
)
resp = await r.json()
assert resp["status"] == "ok", resp
await asyncio.sleep(1)
device = await self.get_device(device)
self.dispatch_update(device["id"], device)
async def get_device_action(self, device: dict, instance: str, value) -> list[dict]:
_LOGGER.debug(f"Device action: {instance}={value}")
action = {
"state": {"instance": instance, "value": value},
"type": IOT_TYPES[instance],
}
url = f"https://iot.quasar.yandex.ru/m/user/{device['item_type']}s/{device['id']}/actions"
r = await self.session.post(url, json={"actions": [action]})
resp = await r.json()
assert resp["status"] == "ok", resp
return resp["devices"]
async def device_actions(self, device: dict, **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"https://iot.quasar.yandex.ru/m/user/{device['item_type']}s/{device['id']}/actions",
json={"actions": actions},
)
resp = await r.json()
assert resp["status"] == "ok", resp
# update device state
device = await self.get_device(device)
self.dispatch_update(device["id"], device)
async def device_color(self, device: dict, **kwargs):
_LOGGER.debug(f"Device color: {kwargs}")
r = await self.session.post(
f"https://iot.quasar.yandex.ru/m/v3/user/custom/group/color/apply",
json={"device_ids": [device['id']], **kwargs},
)
resp = await r.json()
assert resp["status"] == "ok", resp
# update device state
device = await self.get_device(device)
self.dispatch_update(device["id"], device)
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 connect(self):
r = await self.session.get("https://iot.quasar.yandex.ru/m/v3/user/devices")
resp = await r.json()
assert resp["status"] == "ok", resp
for house in resp["households"]:
if "sharing_info" in house:
continue
for device in house["all"]:
self.dispatch_update(device["id"], device)
ws = await self.session.ws_connect(resp["updates_url"], heartbeat=60)
async for msg in ws:
if msg.type != WSMsgType.TEXT:
break
resp = msg.json()
# "ping", "update_scenario_list"
operation = resp.get("operation")
if operation == "update_states":
try:
resp = json.loads(resp["message"])
for device in resp["updated_devices"]:
self.dispatch_update(device["id"], device)
except Exception as e:
_LOGGER.debug(f"Parse quasar update error: {msg.data}", exc_info=e)
elif operation == "update_scenario_list":
if '"source":"create_scenario_launch"' in resp["message"]:
_ = asyncio.create_task(self.get_voice_trigger(1))
async def devices_passive_update(self, *args):
try:
r = await self.session.get(
f"https://iot.quasar.yandex.ru/m/v3/user/devices", timeout=15
)
resp = await r.json()
assert resp["status"] == "ok", resp
for house in resp["households"]:
if "sharing_info" in house:
continue
for device in house["all"]:
self.dispatch_update(device["id"], device)
except Exception as e:
_LOGGER.debug(f"Devices forceupdate problem: {repr(e)}")
async def get_voice_trigger(self, retries: int = 0):
try:
# 1. Get all scenarios history
r = await self.session.get(
"https://iot.quasar.yandex.ru/m/user/scenarios/history"
)
raw = await r.json()
# 2. Search latest scenario with voice trigger
for scenario in raw["scenarios"]:
if scenario["trigger_type"] == "scenario.trigger.voice":
break
else:
return
# 3. Check if scenario too old
d1 = datetime.strptime(r.headers["Date"], "%a, %d %b %Y %H:%M:%S %Z")
d2 = datetime.strptime(scenario["launch_time"], "%Y-%m-%dT%H:%M:%SZ")
dt = (d1 - d2).total_seconds()
if dt > 5:
# try to get history once more
if retries:
await self.get_voice_trigger(retries - 1)
return
# 4. Get speakers from launch devices
r = await self.session.get(
f"https://iot.quasar.yandex.ru/m/v4/user/scenarios/launches/{scenario['id']}"
)
raw = await r.json()
for step in raw["launch"]["steps"]:
for item in step["parameters"]["items"]:
if item["type"] != "step.action.item.device":
continue
device = item["value"]
# 5. Check if speaker device
if "quasar_info" not in device:
continue
device["scenario_name"] = raw["launch"]["name"]
self.dispatch_update(device["id"], device)
except Exception as e:
_LOGGER.debug("Can't get voice scenario", exc_info=e)
async def run_forever(self):
while not self.session.closed:
try:
await self.connect()
except Exception as e:
_LOGGER.debug("Quasar update error", exc_info=e)
await asyncio.sleep(30)
def start(self):
self.updates_task = asyncio.create_task(self.run_forever())
def stop(self):
if self.updates_task:
self.updates_task.cancel()
self.dispatcher.clear()
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(
f"https://iot.quasar.yandex.ru/m/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
async def get_alarms(self, device: dict):
r = await self.session.post(
"https://rpc.alice.yandex.ru/gproxy/get_alarms",
json={"device_ids": [device["quasar_info"]["device_id"]]},
headers=ALARM_HEADERS,
)
resp = await r.json()
return resp["alarms"]
async def create_alarm(self, device: dict, alarm: dict) -> bool:
alarm["device_id"] = device["quasar_info"]["device_id"]
resp = await self.session.post(
"https://rpc.alice.yandex.ru/gproxy/create_alarm",
json={"alarm": alarm, "device_type": device["type"]},
headers=ALARM_HEADERS,
)
return resp.ok
async def change_alarm(self, device: dict, alarm: dict) -> bool:
alarm["device_id"] = device["quasar_info"]["device_id"]
resp = await self.session.post(
"https://rpc.alice.yandex.ru/gproxy/change_alarm",
json={"alarm": alarm, "device_type": device["type"]},
headers=ALARM_HEADERS,
)
return resp.ok
async def cancel_alarms(self, device: dict, alarm_id: str) -> bool:
resp = await self.session.post(
"https://rpc.alice.yandex.ru/gproxy/cancel_alarms",
json={
"device_alarm_ids": [
{
"alarm_id": alarm_id,
"device_id": device["quasar_info"]["device_id"],
}
],
},
headers=ALARM_HEADERS,
)
return resp.ok
ALARM_HEADERS = {
"accept": "application/json",
"origin": "https://yandex.ru",
"x-ya-app-type": "iot-app",
"x-ya-application": '{"app_id":"unknown","uuid":"unknown","lang":"ru"}',
}
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,
},
"анонсировать треки": {
"api": "user/settings",
"key": "music",
"values": {
"да": {"announce_tracks": True},
"нет": {"announce_tracks": False},
},
},
"скрывать названия товаров": {
"api": "user/settings",
"key": "order",
"values": {
"да": {"hide_item_names": True},
"нет": {"hide_item_names": False},
},
},
"звук активации": {"key": "jingle", "values": BOOL_CONFIG}, # /get_account_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": "aliceAdaptiveVolume", # /get_account_config
"values": {
"да": {"enabled": True},
"нет": {"enabled": False},
},
},
"кроссфейд": {
"key": "audio_player", # /get_account_config
"values": {
"да": {"crossfadeEnabled": True},
"нет": {"crossfadeEnabled": False},
},
},
"взрослый голос": {
"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",
},
},
}

View File

@@ -0,0 +1,542 @@
"""
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 asyncio
import base64
import json
import logging
import pickle
import re
import time
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
class BasicSession:
_session: ClientSession
domain: str = None
proxy: str = None
ssl: bool = None
def _request(self, method: str, url: str, **kwargs):
"""Internal request function with global support proxy ans ssl options."""
if self.domain:
url = url.replace("yandex.ru", self.domain)
kwargs["proxy"] = self.proxy
kwargs["ssl"] = self.ssl
kwargs.setdefault("timeout", 5.0)
return getattr(self._session, method)(url, **kwargs)
def _get(self, url: str, **kwargs):
return self._request("get", url, **kwargs)
def _post(self, url: str, **kwargs):
return self._request("post", url, **kwargs)
@property
def closed(self):
return self._session.closed
@property
def client_session(self):
return self._session
# noinspection PyPep8
class YandexSession(BasicSession):
"""Class for login in yandex via username, token, capcha."""
auth_payload: dict = None
csrf_token = None
last_ts: float = 0
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
# fix bug with wrong CSRF token response
setattr(session.cookie_jar, "_quote_cookie", False)
self.x_token = x_token
self.music_token = music_token
if cookie:
cookie_jar = session.cookie_jar
# https://github.com/aio-libs/aiohttp/issues/7216
_cookies = cookie_jar._cookies
try:
raw = base64.b64decode(cookie)
cookie_jar._cookies = pickle.loads(raw)
# same as CookieJar._do_expiration()
cookie_jar.clear(lambda x: False)
except:
cookie_jar._cookies = _cookies
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._get("https://passport.yandex.ru/am?app_platform=android")
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._post(
"https://passport.yandex.ru/registration-validations/auth/multi_step/start",
data={**self.auth_payload, "login": username},
)
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._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",
},
)
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._get("https://passport.yandex.ru/am?app_platform=android")
resp = await r.text()
m = re.search(r'"csrf_token" value="([^"]+)"', resp)
assert m, resp
# step 2: track_id
r = await self._post(
"https://passport.yandex.ru/registration-validations/auth/password/submit",
data={
"csrf_token": m[1],
"retpath": "https://passport.yandex.ru/profile",
"with_code": 1,
},
)
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._post(
"https://passport.yandex.ru/auth/new/magic/status/", data=self.auth_payload
)
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._post(
"https://passport.yandex.ru/registration-validations/phone-confirm-code-submit",
data={**self.auth_payload, "mode": "tracked"},
)
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._post(
"https://passport.yandex.ru/registration-validations/phone-confirm-code",
data={**self.auth_payload, "mode": "tracked", "code": code},
)
resp = await r.json()
assert resp["status"] == "ok"
r = await self._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",
},
)
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._post(
"https://passport.yandex.ru/registration-validations/auth/send_magic_letter",
data=self.auth_payload,
)
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._post(
"https://passport.yandex.ru/auth/letter/status/", data=self.auth_payload
)
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._post(
"https://passport.yandex.ru/registration-validations/textcaptcha",
data=self.auth_payload,
headers={"X-Requested-With": "XMLHttpRequest"},
)
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._post(
"https://passport.yandex.ru/registration-validations/checkHuman",
data={**self.auth_payload, "answer": captcha_answer},
headers={"X-Requested-With": "XMLHttpRequest"},
)
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] == "[":
# @dext0r: fix cookies auth
raw = json.loads(cookies)
host = next(p["domain"] for p in raw if p["domain"].startswith(".yandex."))
cookies = "; ".join([f"{p['name']}={p['value']}" for p in raw])
r = await self._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},
)
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._get(
"https://mobileproxy.passport.yandex.net/1/bundle/account/short_info/?avatar_size=islands-300",
headers={"Authorization": f"OAuth {x_token}"},
)
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._post(
"https://mobileproxy.passport.yandex.net/1/bundle/auth/x_token/",
data=payload,
headers=headers,
)
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._get(
f"{host}/auth/session/", params=payload, 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._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._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: str, **kwargs):
if url.startswith(
("https://quasar.yandex.net/glagol/", "https://api.music.yandex.net/")
):
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):
if "ssl" not in kwargs:
kwargs.setdefault("proxy", self.proxy)
kwargs.setdefault("ssl", self.ssl)
return await self._session.ws_connect(*args, **kwargs)
async def request(self, method: str, url: str, retry: int = 2, **kwargs):
"""Public request function"""
# DDoS protection for Yandex servers
while (delay := self.last_ts + 0.2 - time.time()) > 0:
await asyncio.sleep(delay)
self.last_ts = time.time()
# all except GET should contain CSRF token
if method != "get" and not url.startswith("https://rpc.alice.yandex.ru"):
if self.csrf_token is None:
_LOGGER.debug(f"Обновление CSRF-токена, proxy: {self.proxy}")
r = await self._get(
"https://yandex.ru/quasar", proxy=self.proxy, ssl=self.ssl
)
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 self._request(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
elif not url.endswith("/get_alarms"):
_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 = kwargs.setdefault("headers", {})
headers["Authorization"] = f"OAuth {self.music_token}"
r = await self._get(url, **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):
raw = pickle.dumps(
getattr(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
)

File diff suppressed because it is too large Load Diff