Files
2024-01-11 15:10:51 +03:00

168 lines
5.7 KiB
Python

"""Implement the Yandex Smart Home cloud connection manager."""
from __future__ import annotations
from asyncio import TimeoutError
from dataclasses import dataclass
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
from typing import Any
from aiohttp import (
ClientConnectorError,
ClientResponseError,
ClientSession,
ClientWebSocketResponse,
WSMessage,
WSMsgType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HassJob, HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.json import JSONEncoder
from homeassistant.util import dt
from . import const
from .const import CLOUD_BASE_URL, CONFIG, DOMAIN
from .helpers import Config, RequestData
from .smart_home import async_handle_message
_LOGGER = logging.getLogger(__name__)
DEFAULT_RECONNECTION_DELAY = 2
MAX_RECONNECTION_DELAY = 180
FAST_RECONNECTION_TIME = timedelta(seconds=6)
FAST_RECONNECTION_THRESHOLD = 5
BASE_API_URL = f'{CLOUD_BASE_URL}/api/home_assistant/v1'
@dataclass
class CloudInstanceData:
id: str
password: str
connection_token: str
@dataclass
class CloudRequest:
request_id: str
action: str
message: dict[str, Any]
@classmethod
def from_dict(cls, data: dict[str, Any]):
if 'message' in data:
if isinstance(data['message'], str):
data['message'] = json.loads(data['message'])
else:
data['message'] = {}
return cls(**data)
class CloudManager:
def __init__(self, hass: HomeAssistant, config: Config, session: ClientSession):
self._hass = hass
self._instance_id = config.cloud_instance_id
self._token = config.cloud_connection_token
self._user_id = config.user_id
self._session = session
self._last_connection_at: datetime | None = None
self._fast_reconnection_count = 0
self._ws: ClientWebSocketResponse | None = None
self._ws_reconnect_delay = DEFAULT_RECONNECTION_DELAY
self._ws_active = True
self._url = f'{BASE_API_URL}/connect'
async def connect(self, *_):
if not self._ws_active:
return
# noinspection PyBroadException
try:
_LOGGER.debug(f'Connecting to {self._url}')
self._ws = await self._session.ws_connect(self._url, heartbeat=45, compress=15, headers={
'Authorization': f'Bearer {self._token}'
})
_LOGGER.debug('Connection to Yandex Smart Home cloud established')
self._ws_reconnect_delay = DEFAULT_RECONNECTION_DELAY
self._last_connection_at = dt.utcnow()
async for msg in self._ws: # type: WSMessage
if msg.type == WSMsgType.TEXT:
await self._on_message(msg.json())
_LOGGER.debug(f'Disconnected: {self._ws.close_code}')
if self._ws.close_code is not None:
self._try_reconnect()
except (ClientConnectorError, 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._ws_active = False
if self._ws:
await self._ws.close()
async def _on_message(self, payload: dict[Any, Any]):
request = CloudRequest.from_dict(payload)
_LOGGER.debug('Request: %s (message: %s)' % (request.action, request.message))
data = RequestData(
config=self._hass.data[DOMAIN][CONFIG],
request_user_id=self._instance_id,
request_id=request.request_id,
user_id=self._user_id
)
result = await async_handle_message(self._hass, data, request.action, request.message)
response = json.dumps(result, cls=JSONEncoder)
_LOGGER.debug(f'Response: {response}')
await self._ws.send_str(response)
def _try_reconnect(self):
self._ws_reconnect_delay = min(2 * self._ws_reconnect_delay, MAX_RECONNECTION_DELAY)
if self._last_connection_at and self._last_connection_at + FAST_RECONNECTION_TIME > dt.utcnow():
self._fast_reconnection_count += 1
else:
self._fast_reconnection_count = 0
if self._fast_reconnection_count >= FAST_RECONNECTION_THRESHOLD:
self._ws_reconnect_delay = MAX_RECONNECTION_DELAY
_LOGGER.warning(f'Reconnecting too fast, next reconnection in {self._ws_reconnect_delay} seconds')
_LOGGER.debug(f'Trying to reconnect in {self._ws_reconnect_delay} seconds')
async_call_later(self._hass, self._ws_reconnect_delay, HassJob(self.connect))
async def register_cloud_instance(hass: HomeAssistant) -> CloudInstanceData:
session = async_create_clientsession(hass)
response = await session.post(f'{BASE_API_URL}/instance/register')
response.raise_for_status()
return CloudInstanceData(**await response.json())
async def delete_cloud_instance(hass: HomeAssistant, entry: ConfigEntry):
session = async_create_clientsession(hass)
instance_id = entry.data[const.CONF_CLOUD_INSTANCE][const.CONF_CLOUD_INSTANCE_ID]
token = entry.data[const.CONF_CLOUD_INSTANCE][const.CONF_CLOUD_INSTANCE_CONNECTION_TOKEN]
response = await session.delete(f'{BASE_API_URL}/instance/{instance_id}', headers={
'Authorization': f'Bearer {token}'
})
if response.status != HTTPStatus.OK:
_LOGGER.error(f'Failed to delete cloud instance, status code: {response.status}')