"""Define an object to interact with the Websocket API.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable import logging from typing import Any from aiohttp.client_exceptions import ClientConnectionError from socketio import AsyncClient from socketio.exceptions import SocketIOError from .const import DEFAULT_API_VERSION, LOGGER from .errors import WebsocketError DEFAULT_WATCHDOG_TIMEOUT = 900 WEBSOCKET_API_BASE = "https://rt2.ambientweather.net" class WebsocketWatchdog: """Define a watchdog to kick the websocket connection at intervals.""" def __init__( self, logger: logging.Logger, action: Callable[..., Awaitable], *, timeout_seconds: int = DEFAULT_WATCHDOG_TIMEOUT, ) -> None: """Initialize. Args: ---- logger: The logger to use. action: The coroutine function to call when the watchdog expires. timeout_seconds: The number of seconds before the watchdog times out. """ self._action = action self._logger = logger self._loop = asyncio.get_event_loop() self._timeout = timeout_seconds self._timer_task: asyncio.TimerHandle | None = None def cancel(self) -> None: """Cancel the watchdog.""" if self._timer_task: self._timer_task.cancel() self._timer_task = None async def on_expire(self) -> None: """Log and act when the watchdog expires.""" self._logger.info("Watchdog expired - calling %s", self._action.__name__) await self._action() async def trigger(self) -> None: """Trigger the watchdog.""" self._logger.info("Watchdog triggered - sleeping for %s seconds", self._timeout) if self._timer_task: self._timer_task.cancel() self._timer_task = self._loop.call_later( self._timeout, lambda: asyncio.create_task(self.on_expire()) ) class Websocket: """Define the websocket.""" def __init__( self, application_key: str, api_key: str | list[str], *, api_version: int = DEFAULT_API_VERSION, logger: logging.Logger = LOGGER, ) -> None: """Initialize. Args: ---- application_key: An Ambient Weather application key. api_key: An Ambient Weather API key. api_version: The version of the API to query. logger: The logger to use. """ if isinstance(api_key, str): api_key = [api_key] self._api_key = api_key self._api_version = api_version self._app_key = application_key self._async_user_connect_handler: Callable[..., Awaitable[None]] | None = None self._logger = logger self._sio = AsyncClient(logger=logger, engineio_logger=logger) self._user_connect_handler: Callable[..., None] | None = None self._watchdog = WebsocketWatchdog(logger, self.reconnect) async def _init_connection(self) -> None: """Perform automatic initialization upon connecting.""" await self._sio.emit("subscribe", {"apiKeys": self._api_key}) await self._watchdog.trigger() if self._async_user_connect_handler: await self._async_user_connect_handler() elif self._user_connect_handler: self._user_connect_handler() def async_on_connect(self, target: Callable[..., Awaitable[None]]) -> None: """Define a coroutine to be called when connecting. Args: ---- target: The coroutine function to call upon websocket connect. """ self._async_user_connect_handler = target self._user_connect_handler = None def on_connect(self, target: Callable[..., None]) -> None: """Define a method to be called when connecting. Args: ---- target: The function to call upon websocket connect. """ self._async_user_connect_handler = None self._user_connect_handler = target def async_on_data( self, target: Callable[[dict[str, Any]], Awaitable[None]] ) -> None: """Define a coroutine to be called when data is received. Args: ---- target: The coroutine function to call when receiving websocket data. """ async def _async_on_data(data: dict[str, Any]) -> None: """Act on the data. Args: ---- data: The websocket data received. """ await self._watchdog.trigger() await target(data) self._sio.on("data", _async_on_data) def on_data(self, target: Callable[[dict[str, Any]], None]) -> None: """Define a method to be called when data is received. Args: ---- target: The function to call when receiving websocket data. """ async def _async_on_data(data: dict[str, Any]) -> None: """Act on the data. Args: ---- data: The websocket data received. """ await self._watchdog.trigger() target(data) self._sio.on("data", _async_on_data) def async_on_disconnect(self, target: Callable[..., Awaitable[None]]) -> None: """Define a coroutine to be called when disconnecting. Args: ---- target: The coroutine function to call upon websocket connect. """ self._sio.on("disconnect", target) def on_disconnect(self, target: Callable[..., None]) -> None: """Define a method to be called when disconnecting. Args: ---- target: The function to call upon websocket connect. """ self._sio.on("disconnect", target) def async_on_subscribed( self, target: Callable[[dict[str, Any]], Awaitable[None]] ) -> None: """Define a coroutine to be called when subscribed. Args: ---- target: The coroutine function to call when receiving websocket data. """ async def _async_on_subscribed(data: dict[str, Any]) -> None: """Act on subscribe. Args: ---- data: The websocket data received. """ await self._watchdog.trigger() await target(data) self._sio.on("subscribed", _async_on_subscribed) def on_subscribed(self, target: Callable[[dict[str, Any]], None]) -> None: """Define a method to be called when subscribed. Args: ---- target: The function to call when receiving websocket data. """ async def _async_on_subscribed(data: dict[str, Any]) -> None: """Act on subscribe. Args: ---- data: The websocket data received. """ await self._watchdog.trigger() target(data) self._sio.on("subscribed", _async_on_subscribed) async def connect(self) -> None: """Connect to the socket. Raises ------ WebsocketError: Raised upon any issue with the websocket. """ try: self._sio.on("connect", self._init_connection) await self._sio.connect( ( f"{WEBSOCKET_API_BASE}/?api={self._api_version}" f"&applicationKey={self._app_key}" ), transports=["websocket"], ) except (ClientConnectionError, SocketIOError) as err: raise WebsocketError(err) from err async def disconnect(self) -> None: """Disconnect from the socket.""" await self._sio.disconnect() self._watchdog.cancel() async def reconnect(self) -> None: """Reconnect the websocket connection.""" await self.disconnect() await asyncio.sleep(1) await self.connect()