"""Base class to keep a websocket connection open to a server.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable import dataclasses import logging import pprint import random from socket import gaierror from typing import TYPE_CHECKING, Any from aiohttp import ( ClientError, ClientWebSocketResponse, WSMessage, WSMsgType, WSServerHandshakeError, client_exceptions, hdrs, ) from .const import ( MESSAGE_EXPIRATION, STATE_CONNECTED, STATE_CONNECTING, STATE_DISCONNECTED, SubscriptionReconnectionReason, ) from .exceptions import CloudError from .utils import gather_callbacks if TYPE_CHECKING: from . import Cloud, _ClientT @dataclasses.dataclass class DisconnectReason: """Disconnect reason.""" clean: bool reason: str class NotConnected(Exception): """Exception raised when trying to handle unknown handler.""" class BaseIoT: """Class to manage the IoT connection.""" mark_connected_after_first_message = False def __init__(self, cloud: Cloud[_ClientT]) -> None: """Initialize the CloudIoT class.""" self.cloud = cloud # The WebSocket client self.client: ClientWebSocketResponse | None = None # Scheduled sleep task till next connection retry self.retry_task: asyncio.Task | None = None # Boolean to indicate if we wanted the connection to close self.close_requested: bool = False # The current number of attempts to connect, impacts wait time self.tries: int = 0 # Current state of the connection self.state: str = STATE_DISCONNECTED self._on_connect: list[Callable[[], Awaitable[None]]] = [] self._on_disconnect: list[Callable[[], Awaitable[None]]] = [] self._logger = logging.getLogger(self.package_name) self._disconnect_event: asyncio.Event | None = None self.last_disconnect_reason: DisconnectReason | None = None @property def package_name(self) -> str: """Return package name for logging.""" raise NotImplementedError @property def ws_heartbeat(self) -> float | None: """Server to connect to.""" return None @property def ws_server_url(self) -> str: """Server to connect to.""" raise NotImplementedError @property def require_subscription(self) -> bool: """If the server requires a valid subscription.""" return True def async_handle_message(self, msg: dict[str, Any]) -> None: """Handle incoming message. Run all async tasks in a wrapper to log appropriately. """ raise NotImplementedError # --- Do not override after this line --- def register_on_connect(self, on_connect_cb: Callable[[], Awaitable[None]]) -> None: """Register an async on_connect callback.""" self._on_connect.append(on_connect_cb) def register_on_disconnect( self, on_disconnect_cb: Callable[[], Awaitable[None]], ) -> None: """Register an async on_disconnect callback.""" self._on_disconnect.append(on_disconnect_cb) @property def connected(self) -> bool: """Return if we're currently connected.""" return self.state == STATE_CONNECTED async def async_send_json_message(self, message: dict[str, Any]) -> None: """Send a message. Raises NotConnected if client not connected. """ if self.state != STATE_CONNECTED or self.client is None: raise NotConnected if self._logger.isEnabledFor(logging.DEBUG): self._logger.debug("Publishing message:\n%s\n", pprint.pformat(message)) await self.client.send_json(message) async def connect(self) -> None: """Connect to the IoT broker.""" if self.state != STATE_DISCONNECTED: raise RuntimeError("Connect called while not disconnected") self.close_requested = False self.state = STATE_CONNECTING self.tries = 0 self._disconnect_event = asyncio.Event() while True: try: self._logger.debug("Trying to connect") await self._handle_connection() except Exception: # pylint: disable=broad-except # Safety net. This should never hit. # Still adding it here to make sure we can always reconnect self._logger.exception("Unexpected error in connection handler") if self.state == STATE_CONNECTED: await self._disconnected() if self.close_requested: break if self.require_subscription and self.cloud.subscription_expired: self.cloud.async_initialize_subscription_reconnection_handler( SubscriptionReconnectionReason.SUBSCRIPTION_EXPIRED, ) break self.state = STATE_CONNECTING self.tries += 1 try: await self._wait_retry() except asyncio.CancelledError: # Happens if disconnect called break self.state = STATE_DISCONNECTED self._disconnect_event.set() self._disconnect_event = None async def _wait_retry(self) -> None: """Wait until it's time till the next retry.""" # Sleep 2^tries + 0…tries*3 seconds between retries self.retry_task = asyncio.create_task( asyncio.sleep(2 ** min(9, self.tries) + random.randint(0, self.tries * 3)), ) await self.retry_task self.retry_task = None async def _handle_connection(self) -> None: """Connect to the IoT broker.""" try: await self.cloud.auth.async_check_token() except CloudError as err: self._logger.warning( "Unable to connect due to token refresh failure: %s", err, ) return if self.require_subscription and self.cloud.subscription_expired: self._logger.debug("Cloud subscription expired, cancelling connection") self.cloud.client.user_message( "cloud_subscription_expired", "Home Assistant Cloud", MESSAGE_EXPIRATION, ) self.close_requested = True return disconnect_clean: bool = False disconnect_reason: ( str | WSServerHandshakeError | ClientError | ConnectionResetError | gaierror | None ) = None try: self.client = await self.cloud.websession.ws_connect( self.ws_server_url, heartbeat=self.ws_heartbeat, headers={ hdrs.AUTHORIZATION: f"Bearer {self.cloud.id_token}", hdrs.USER_AGENT: self.cloud.client.client_name, }, ) if not self.mark_connected_after_first_message: await self._connected() while not self.client.closed: msg: WSMessage | None | str = None try: msg = await self.client.receive(55) except TimeoutError: # This is logged as info instead of warning because when # this hits there is not really much that can be done about it. # But the context is still valuable to have while # troubleshooting. self._logger.info("Timeout while waiting to receive message") await self.client.ping() continue if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): disconnect_clean = self.state == STATE_CONNECTED disconnect_reason = f"Closed by server. {msg.extra} ({msg.data})" break # Do this inside the loop because if 2 clients are connected, # it can happen that we get connected with valid auth, # but then server decides to drop our connection. if self.state != STATE_CONNECTED: await self._connected() if msg.type == WSMsgType.ERROR: disconnect_reason = "Connection error" break if msg.type != WSMsgType.TEXT: disconnect_reason = f"Received non-Text message: {msg.type}" break try: msg_content: dict[str, Any] = msg.json() except ValueError: disconnect_reason = "Received invalid JSON." break if self._logger.isEnabledFor(logging.DEBUG): self._logger.debug( "Received message:\n%s\n", pprint.pformat(msg_content), ) try: self.async_handle_message(msg_content) except Exception: # pylint: disable=broad-except self._logger.exception( "Unexpected error handling message: %s", msg_content ) if self.client.closed: if self.close_requested: disconnect_clean = True disconnect_reason = "Close requested" elif disconnect_reason is None: disconnect_reason = "Closed by server. Unknown reason" except client_exceptions.WSServerHandshakeError as err: if err.status == 401: disconnect_reason = "Invalid auth." self.close_requested = True # Should we notify user? else: disconnect_reason = err except (client_exceptions.ClientError, ConnectionResetError, gaierror) as err: disconnect_reason = err except asyncio.CancelledError: disconnect_clean = True disconnect_reason = "Connection Cancelled" finally: if self.client: base_msg = "Connection closed" await self.client.close() self.client = None else: base_msg = "Unable to connect" msg = f"{base_msg}: {disconnect_reason}" self.last_disconnect_reason = DisconnectReason(disconnect_clean, msg) if self.close_requested or disconnect_clean: self._logger.info(msg) else: self._logger.warning(msg) async def disconnect(self) -> None: """Disconnect the client.""" self.close_requested = True if self.client is not None: await self.client.close() elif self.retry_task is not None: self.retry_task.cancel() if self._disconnect_event is not None: await self._disconnect_event.wait() async def _connected(self) -> None: """Handle connected.""" self.last_disconnect_reason = None self.tries = 0 self.state = STATE_CONNECTED self._logger.info("Connected") if self._on_connect: await gather_callbacks(self._logger, "on_connect", self._on_connect) async def _disconnected(self) -> None: """Handle connected.""" if self._on_disconnect: await gather_callbacks(self._logger, "on_disconnect", self._on_disconnect)