163 lines
5.0 KiB
Python
163 lines
5.0 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from asyncio import TimeoutError
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import timedelta
|
|
import json
|
|
import logging
|
|
from typing import Any, cast
|
|
|
|
from aiohttp import (
|
|
ClientConnectionError,
|
|
ClientResponseError,
|
|
ClientSession,
|
|
ClientWebSocketResponse,
|
|
WSMessage,
|
|
WSMsgType,
|
|
)
|
|
from aiohttp.web_request import Request as AIOWebRequest
|
|
from homeassistant.components.stream import Stream
|
|
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 multidict import MultiDictProxy
|
|
import yarl
|
|
|
|
from .const import CLOUD_STREAM_BASE_URL
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
RECONNECTION_DELAY = 2
|
|
WAIT_FOR_CONNECTION_TIMEOUT = 10
|
|
|
|
|
|
@dataclass
|
|
class Request:
|
|
view: str
|
|
sequence: str = None
|
|
part_num: str = None
|
|
url_query: str = None
|
|
|
|
|
|
@dataclass
|
|
class ResponseMeta:
|
|
status_code: int
|
|
headers: dict[str, Any] | None = None
|
|
|
|
|
|
class WebRequest:
|
|
def __init__(self, hass: HomeAssistant, url: yarl.URL):
|
|
self.app = {'hass': hass}
|
|
self._url = url
|
|
|
|
@property
|
|
def query(self) -> MultiDictProxy[str]:
|
|
return MultiDictProxy(self._url.query)
|
|
|
|
|
|
class CloudStream:
|
|
def __init__(self, hass: HomeAssistant, stream: Stream, session: ClientSession):
|
|
self._hass = hass
|
|
self._stream = stream
|
|
self._running_stream_id: str | None = None
|
|
self._session = session
|
|
self._unsub_reconnect: CALLBACK_TYPE | None = None
|
|
self._connected = asyncio.Event()
|
|
self._ws: ClientWebSocketResponse | None = None
|
|
|
|
@property
|
|
def stream_url(self) -> str | None:
|
|
if not self._running_stream_id:
|
|
return None
|
|
|
|
return f'{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/master_playlist.m3u8'
|
|
|
|
async def start(self):
|
|
if self._ws or not self._stream.access_token:
|
|
return
|
|
|
|
self._running_stream_id = self._stream.access_token
|
|
self._hass.loop.create_task(self._connect())
|
|
|
|
await asyncio.wait_for(self._connected.wait(), timeout=WAIT_FOR_CONNECTION_TIMEOUT)
|
|
await self._keepalive()
|
|
|
|
async def _keepalive(self, *_):
|
|
if self._stream.access_token != self._running_stream_id:
|
|
return await self._disconnect()
|
|
|
|
async_call_later(self._hass, timedelta(seconds=1), HassJob(self._keepalive))
|
|
|
|
async def _connect(self, *_):
|
|
if not self._running_stream_id:
|
|
return
|
|
|
|
ws_url = f'{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/connect'
|
|
|
|
# noinspection PyBroadException
|
|
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 self._ws: # type: WSMessage
|
|
if msg.type == WSMsgType.TEXT:
|
|
await self._handle_request(msg.json())
|
|
|
|
_LOGGER.debug(f'Disconnected: {self._ws.close_code}')
|
|
if self._ws.close_code is not None:
|
|
self._try_reconnect()
|
|
except (ClientConnectionError, ClientResponseError, 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 _disconnect(self, *_):
|
|
self._running_stream_id = None
|
|
self._connected.clear()
|
|
|
|
if self._unsub_reconnect:
|
|
self._unsub_reconnect()
|
|
self._unsub_reconnect = None
|
|
|
|
if self._ws:
|
|
await self._ws.close()
|
|
self._ws = None
|
|
|
|
async def _handle_request(self, payload: dict):
|
|
_LOGGER.debug(f'Request: {payload}')
|
|
|
|
request = Request(**payload)
|
|
request_url = yarl.URL.build(path=f'{request.view}', query=request.url_query)
|
|
web_request = cast(AIOWebRequest, WebRequest(self._hass, request_url))
|
|
|
|
views = {
|
|
'master_playlist': HlsMasterPlaylistView,
|
|
'playlist': HlsPlaylistView,
|
|
'init': HlsInitView,
|
|
'part': HlsPartView,
|
|
'segment': HlsSegmentView
|
|
}
|
|
|
|
view = views[request.view]()
|
|
|
|
r = await view.get(web_request, self._stream.access_token, request.sequence, request.part_num)
|
|
meta = ResponseMeta(status_code=r.status, headers=dict(r.headers))
|
|
response = bytes(json.dumps(asdict(meta)), 'utf-8') + b'\r\n' + r.body
|
|
await self._ws.send_bytes(response, compress=False)
|
|
|
|
def _try_reconnect(self):
|
|
_LOGGER.debug(f'Trying to reconnect in {RECONNECTION_DELAY} seconds')
|
|
self._unsub_reconnect = async_call_later(self._hass, RECONNECTION_DELAY, HassJob(self._connect))
|