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

View File

@@ -0,0 +1,178 @@
"""Implement the Yandex Smart Home cloud connection manager for video streaming."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any, AsyncIterable, cast
from aiohttp import (
ClientConnectionError,
ClientResponseError,
ClientSession,
ClientWebSocketResponse,
WSMessage,
WSMsgType,
web,
)
from aiohttp.web_request import Request as AIOWebRequest
from homeassistant.components.stream import Stream
from homeassistant.components.stream.core import StreamView
from homeassistant.components.stream.hls import (
HlsInitView,
HlsMasterPlaylistView,
HlsPartView,
HlsPlaylistView,
HlsSegmentView,
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.http import KEY_HASS
from multidict import MultiDictProxy
from pydantic import BaseModel, ConfigDict
import yarl
from .const import CLOUD_STREAM_BASE_URL
_LOGGER = logging.getLogger(__name__)
RECONNECTION_DELAY = 2
WAIT_FOR_CONNECTION_TIMEOUT = 10
class Request(BaseModel):
"""Request from the cloud."""
view: str
sequence: str = ""
part_num: str = ""
url_query: str | None = None
class ResponseMeta(BaseModel):
"""Response metadata."""
status_code: int
headers: dict[str, str]
class WebRequest:
"""Represent minimal HTTP request to use in HomeAssistantView"""
def __init__(self, hass: HomeAssistant, url: yarl.URL):
"""Initialize web request from url."""
self.app = {KEY_HASS: hass}
self._url = url
@property
def query(self) -> MultiDictProxy[str]:
"""Return parsed query parameters in decoded representation."""
return MultiDictProxy(self._url.query)
class CloudStreamManager:
"""Class to manage cloud connection for streaming."""
def __init__(self, hass: HomeAssistant, stream: Stream, session: ClientSession):
"""Initialize a cloud manager with stream and client session."""
self._hass = hass
self._stream = stream
self._running_stream_id: str | None = None
self._session = session
self._connected = asyncio.Event()
self._ws: ClientWebSocketResponse | None = None
self._unsub_connect: CALLBACK_TYPE | None = None
self._unsub_keepalive: CALLBACK_TYPE | None = None
@property
def stream_url(self) -> str | None:
"""Return URL to stream."""
if not self._running_stream_id:
return None
return f"{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/master_playlist.m3u8"
async def async_start(self) -> None:
"""Start connection."""
if self._ws or not self._stream.access_token:
return
self._running_stream_id = self._stream.access_token
self._hass.loop.create_task(self._async_connect())
await asyncio.wait_for(self._connected.wait(), timeout=WAIT_FOR_CONNECTION_TIMEOUT)
await self._async_keepalive()
async def _async_keepalive(self, *_: Any) -> None:
"""Disconnect if stream is not active anymore."""
if self._stream.access_token != self._running_stream_id:
return await self._async_disconnect()
self._unsub_keepalive = async_call_later(
self._hass, timedelta(seconds=1), HassJob(self._async_keepalive)
)
async def _async_connect(self, *_: Any) -> None:
"""Connect to the cloud."""
if not self._running_stream_id:
return
ws_url = f"{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/connect"
try:
_LOGGER.debug(f"Connecting to {ws_url}")
self._ws = await self._session.ws_connect(ws_url, heartbeat=30)
_LOGGER.debug("Connection to Yandex Smart Home cloud established")
self._connected.set()
async for msg in cast(AsyncIterable[WSMessage], self._ws):
if msg.type == WSMsgType.TEXT:
await self._on_message(msg)
_LOGGER.debug(f"Disconnected: {self._ws.close_code}")
if self._ws.close_code is not None:
self._try_reconnect()
except (ClientConnectionError, ClientResponseError, asyncio.TimeoutError):
_LOGGER.exception("Failed to connect to Yandex Smart Home cloud")
self._try_reconnect()
except Exception:
_LOGGER.exception("Unexpected exception")
self._try_reconnect()
async def _async_disconnect(self, *_: Any) -> None:
"""Disconnect from the cloud."""
self._running_stream_id = None
self._connected.clear()
if self._ws:
await self._ws.close()
self._ws = None
for unsub in [self._unsub_connect, self._unsub_keepalive]:
if unsub:
unsub()
self._unsub_connect = None
self._unsub_keepalive = None
async def _on_message(self, message: WSMessage) -> None:
"""Handle incoming request from the cloud."""
_LOGGER.debug(f"Request: {message.data}")
request = Request.model_validate_json(message.data)
request_url = yarl.URL.build(path=request.view, query=request.url_query or "")
web_request = cast(AIOWebRequest, WebRequest(self._hass, request_url))
views: dict[str, type[StreamView]] = {
"master_playlist": HlsMasterPlaylistView,
"playlist": HlsPlaylistView,
"init": HlsInitView,
"part": HlsPartView,
"segment": HlsSegmentView,
}
view = views[request.view]()
r = cast(
web.Response,
await view.get(web_request, self._stream.access_token or "", request.sequence, request.part_num),
)
assert self._ws is not None
body = r.body if r.body is not None else b""
assert isinstance(body, bytes)
meta = ResponseMeta(status_code=r.status, headers=dict(r.headers))
response = bytes(meta.model_dump_json(exclude_none=True), "utf-8") + b"\r\n" + body
await self._ws.send_bytes(response, compress=False)
def _try_reconnect(self) -> None:
"""Schedule reconnection to the cloud."""
_LOGGER.debug(f"Trying to reconnect in {RECONNECTION_DELAY} seconds")
self._unsub_connect = async_call_later(
self._hass, RECONNECTION_DELAY, HassJob(self._async_connect)
)