python
This commit is contained in:
25
custom_components/yandex_station/core/README.md
Normal file
25
custom_components/yandex_station/core/README.md
Normal 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
|
||||
```
|
||||
6
custom_components/yandex_station/core/const.py
Normal file
6
custom_components/yandex_station/core/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
DOMAIN = "yandex_station"
|
||||
|
||||
CONF_MEDIA_PLAYERS = "media_players"
|
||||
|
||||
DATA_CONFIG = "config"
|
||||
DATA_SPEAKERS = "speakers"
|
||||
133
custom_components/yandex_station/core/entity.py
Normal file
133
custom_components/yandex_station/core/entity.py
Normal 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
|
||||
BIN
custom_components/yandex_station/core/fonts/DejaVuSans.ttf
Normal file
BIN
custom_components/yandex_station/core/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
99
custom_components/yandex_station/core/image.py
Normal file
99
custom_components/yandex_station/core/image.py
Normal 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()
|
||||
90
custom_components/yandex_station/core/protobuf.py
Normal file
90
custom_components/yandex_station/core/protobuf.py
Normal 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)
|
||||
48
custom_components/yandex_station/core/quasar_info.py
Normal file
48
custom_components/yandex_station/core/quasar_info.py
Normal 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
|
||||
189
custom_components/yandex_station/core/stream.py
Normal file
189
custom_components/yandex_station/core/stream.py
Normal 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
|
||||
479
custom_components/yandex_station/core/utils.py
Normal file
479
custom_components/yandex_station/core/utils.py
Normal 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
|
||||
316
custom_components/yandex_station/core/yandex_glagol.py
Normal file
316
custom_components/yandex_station/core/yandex_glagol.py
Normal 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))
|
||||
67
custom_components/yandex_station/core/yandex_music.py
Normal file
67
custom_components/yandex_station/core/yandex_music.py
Normal 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()
|
||||
750
custom_components/yandex_station/core/yandex_quasar.py
Normal file
750
custom_components/yandex_station/core/yandex_quasar.py
Normal 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",
|
||||
},
|
||||
},
|
||||
}
|
||||
542
custom_components/yandex_station/core/yandex_session.py
Normal file
542
custom_components/yandex_station/core/yandex_session.py
Normal 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
|
||||
)
|
||||
1167
custom_components/yandex_station/core/yandex_station.py
Normal file
1167
custom_components/yandex_station/core/yandex_station.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user