"""Manage ICE servers.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging import random import time from typing import TYPE_CHECKING, Any from aiohttp import ClientResponseError from webrtc_models import RTCIceServer from .api import ApiBase, CloudApiError, api_exception_handler if TYPE_CHECKING: from . import Cloud, _ClientT _LOGGER = logging.getLogger(__name__) class IceServersApiError(CloudApiError): """Exception raised when handling ICE servers API.""" @dataclass class NabucasaIceServer(RTCIceServer): """ICE server for Nabucasa.""" expiration_timestamp: int | None = None def __init__(self, data: dict[str, Any]) -> None: """Initialize Nabucasa ICE server.""" super().__init__( urls=data["urls"], username=data.get("username"), credential=data.get("credential"), ) if (ttl := data.get("ttl")) is not None: self.expiration_timestamp = int(time.time()) + ttl class IceServers(ApiBase): """Class to manage ICE servers.""" def __init__(self, cloud: Cloud[_ClientT]) -> None: """Initialize ICE Servers.""" super().__init__(cloud) self._refresh_task: asyncio.Task | None = None self._nabucasa_ice_servers: list[NabucasaIceServer] = [] self._ice_servers_listener: Callable[[], Awaitable[None]] | None = None self._ice_servers_listener_unregister: Callable[[], None] | None = None @api_exception_handler(IceServersApiError) async def _async_fetch_ice_servers(self) -> list[NabucasaIceServer]: """Fetch ICE servers.""" if self._cloud.subscription_expired: return [] response: list[dict] = await self._call_cloud_api( action="webrtc_ice_servers", ) return [NabucasaIceServer(item) for item in response] def _get_refresh_sleep_time(self) -> int: """Get the sleep time for refreshing ICE servers.""" timestamps = [ server.expiration_timestamp for server in self._nabucasa_ice_servers if server.expiration_timestamp is not None ] if not timestamps: return random.randint(3600, 3600 * 12) # 1-12 hours if (expiration := min(timestamps) - int(time.time()) - 3600) < 0: return random.randint(100, 300) # 1 hour before the earliest expiration return expiration async def _async_refresh_nabucasa_ice_servers(self) -> None: """Handle Nabucasa ICE server refresh.""" while True: try: self._nabucasa_ice_servers = await self._async_fetch_ice_servers() except IceServersApiError as err: _LOGGER.error("Can't refresh ICE servers: %s", err) # We should not keep the existing ICE servers with old timestamps # as that will retrigger a refresh almost immediately. if ( isinstance(err.orig_exc, CloudApiError) and isinstance(err.orig_exc.orig_exc, ClientResponseError) and err.orig_exc.orig_exc.status in (401, 403) ): self._nabucasa_ice_servers = [] except asyncio.CancelledError: # Task is canceled, stop it. break if self._ice_servers_listener is not None: await self._ice_servers_listener() sleep_time = self._get_refresh_sleep_time() await asyncio.sleep(sleep_time) def _on_add_listener(self) -> None: """When the instance is connected.""" self._refresh_task = asyncio.create_task( self._async_refresh_nabucasa_ice_servers() ) def _on_remove_listener(self) -> None: """When the instance is disconnected.""" if self._refresh_task is not None: self._refresh_task.cancel() self._refresh_task = None def _get_ice_servers(self) -> list[RTCIceServer]: """Get ICE servers.""" if self._cloud.subscription_expired: return [] return [ RTCIceServer( urls=server.urls, username=server.username, credential=server.credential, ) for server in self._nabucasa_ice_servers ] async def async_register_ice_servers_listener( self, register_ice_server_fn: Callable[ [list[RTCIceServer]], Awaitable[Callable[[], None]], ], ) -> Callable[[], None]: """Register a listener for ICE servers and return unregister function.""" _LOGGER.debug("Registering ICE servers listener") async def perform_ice_server_update() -> None: """Perform ICE server update by unregistering and registering servers.""" _LOGGER.debug("Updating ICE servers") if self._ice_servers_listener_unregister is not None: self._ice_servers_listener_unregister() self._ice_servers_listener_unregister = None ice_servers = self._get_ice_servers() self._ice_servers_listener_unregister = await register_ice_server_fn( ice_servers, ) _LOGGER.debug("ICE servers updated") def remove_listener() -> None: """Remove listener.""" if self._ice_servers_listener_unregister is not None: self._ice_servers_listener_unregister() self._ice_servers_listener_unregister = None self._nabucasa_ice_servers = [] self._ice_servers_listener = None self._on_remove_listener() self._ice_servers_listener = perform_ice_server_update self._on_add_listener() return remove_listener