From a1e2d79613943c1690e93a7c22c9c5a9c856f0bb Mon Sep 17 00:00:00 2001 From: Krisjanis Lejejs Date: Tue, 29 Oct 2024 21:35:52 +0200 Subject: [PATCH] Add cloud ICE server registration (#128942) * Add cloud ICE server registration * Add ice_servers to prefs, fix registration flow * Add support for list of ICE servers * Add ICE server cleanup on cloud logout, create tests * Fix RTCIceServer types * Update homeassistant/components/cloud/client.py Co-authored-by: Martin Hjelmare * Improve tests based on PR reviews * Improve tests * Use set_cloud_prefs fixture --------- Co-authored-by: Martin Hjelmare Co-authored-by: Robert Resch --- homeassistant/components/cloud/client.py | 55 ++++++++++++++++- homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/http_api.py | 2 + homeassistant/components/cloud/prefs.py | 13 ++++ .../components/cloud/system_health.py | 1 + tests/components/cloud/conftest.py | 9 ++- tests/components/cloud/test_client.py | 59 ++++++++++++++++++- tests/components/cloud/test_http_api.py | 4 ++ tests/components/cloud/test_system_health.py | 8 ++- 9 files changed, 148 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 01c8de77156..ee46fa42125 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import datetime from http import HTTPStatus import logging @@ -11,12 +12,14 @@ from typing import Any, Literal import aiohttp from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed +from webrtc_models import RTCIceServer from homeassistant.components import google_assistant, persistent_notification, webhook from homeassistant.components.alexa import ( errors as alexa_errors, smart_home as alexa_smart_home, ) +from homeassistant.components.camera.webrtc import async_register_ice_servers from homeassistant.components.google_assistant import smart_home as ga from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import Context, HassJob, HomeAssistant, callback @@ -27,7 +30,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.util.aiohttp import MockRequest, serialize_response from . import alexa_config, google_config -from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN +from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN, PREF_ENABLE_CLOUD_ICE_SERVERS from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -60,6 +63,7 @@ class CloudClient(Interface): self._alexa_config_init_lock = asyncio.Lock() self._google_config_init_lock = asyncio.Lock() self._relayer_region: str | None = None + self._cloud_ice_servers_listener: Callable[[], None] | None = None @property def base_path(self) -> Path: @@ -187,6 +191,49 @@ class CloudClient(Interface): if is_new_user: await gconf.async_sync_entities(gconf.agent_user_id) + async def setup_cloud_ice_servers(_: datetime) -> None: + async def register_cloud_ice_server( + ice_servers: list[RTCIceServer], + ) -> Callable[[], None]: + """Register cloud ice server.""" + + def get_ice_servers() -> list[RTCIceServer]: + return ice_servers + + return async_register_ice_servers(self._hass, get_ice_servers) + + async def async_register_cloud_ice_servers_listener( + prefs: CloudPreferences, + ) -> None: + is_cloud_ice_servers_enabled = ( + self.cloud.is_logged_in + and not self.cloud.subscription_expired + and prefs.cloud_ice_servers_enabled + ) + if is_cloud_ice_servers_enabled: + if self._cloud_ice_servers_listener is None: + self._cloud_ice_servers_listener = await self.cloud.ice_servers.async_register_ice_servers_listener( + register_cloud_ice_server + ) + elif self._cloud_ice_servers_listener: + self._cloud_ice_servers_listener() + self._cloud_ice_servers_listener = None + + async def async_prefs_updated(prefs: CloudPreferences) -> None: + updated_prefs = prefs.last_updated + + if ( + updated_prefs is None + or PREF_ENABLE_CLOUD_ICE_SERVERS not in updated_prefs + ): + return + + await async_register_cloud_ice_servers_listener(prefs) + + await async_register_cloud_ice_servers_listener(self._prefs) + + self._prefs.async_listen_updates(async_prefs_updated) + tasks = [] if self._prefs.alexa_enabled and self._prefs.alexa_report_state: @@ -195,6 +242,8 @@ class CloudClient(Interface): if self._prefs.google_enabled: tasks.append(enable_google) + tasks.append(setup_cloud_ice_servers) + if tasks: await asyncio.gather(*(task(None) for task in tasks)) @@ -222,6 +271,10 @@ class CloudClient(Interface): self._google_config.async_deinitialize() self._google_config = None + if self._cloud_ice_servers_listener: + self._cloud_ice_servers_listener() + self._cloud_ice_servers_listener = None + @callback def user_message(self, identifier: str, title: str, message: str) -> None: """Create a message for user to UI.""" diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 5e9fb2e9dc7..4392bf94827 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -43,6 +43,7 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_GOOGLE_CONNECTED = "google_connected" PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" +PREF_ENABLE_CLOUD_ICE_SERVERS = "cloud_ice_servers_enabled" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index b1931515745..844f0e9f11d 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -42,6 +42,7 @@ from .const import ( PREF_ALEXA_REPORT_STATE, PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, + PREF_ENABLE_CLOUD_ICE_SERVERS, PREF_ENABLE_GOOGLE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, @@ -448,6 +449,7 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: vol.Coerce(tuple), validate_language_voice ), vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, + vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool, } ) @websocket_api.async_response diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 9f76c16a113..a0811393097 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -32,6 +32,7 @@ from .const import ( PREF_CLOUD_USER, PREF_CLOUDHOOKS, PREF_ENABLE_ALEXA, + PREF_ENABLE_CLOUD_ICE_SERVERS, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, PREF_GOOGLE_CONNECTED, @@ -176,6 +177,7 @@ class CloudPreferences: google_settings_version: int | UndefinedType = UNDEFINED, google_connected: bool | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, + cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -198,6 +200,7 @@ class CloudPreferences: (PREF_REMOTE_DOMAIN, remote_domain), (PREF_GOOGLE_CONNECTED, google_connected), (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), + (PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled), ) if value is not UNDEFINED } @@ -246,6 +249,7 @@ class CloudPreferences: PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, + PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled, } @property @@ -362,6 +366,14 @@ class CloudPreferences: """ return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return] + @property + def cloud_ice_servers_enabled(self) -> bool: + """Return if cloud ICE servers are enabled.""" + cloud_ice_servers_enabled: bool = self._prefs.get( + PREF_ENABLE_CLOUD_ICE_SERVERS, True + ) + return cloud_ice_servers_enabled + async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" user = await self._load_cloud_user() @@ -409,6 +421,7 @@ class CloudPreferences: PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, + PREF_ENABLE_CLOUD_ICE_SERVERS: True, PREF_GOOGLE_CONNECTED: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 0e65aa93eaf..ac50c2fb49b 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -33,6 +33,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: data["remote_connected"] = cloud.remote.is_connected data["alexa_enabled"] = client.prefs.alexa_enabled data["google_enabled"] = client.prefs.google_enabled + data["cloud_ice_servers_enabled"] = client.prefs.cloud_ice_servers_enabled data["remote_server"] = cloud.remote.snitun_server data["certificate_status"] = cloud.remote.certificate_status data["instance_id"] = client.prefs.instance_id diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 2edd9571bdd..7002f7c39ec 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -3,13 +3,14 @@ from collections.abc import AsyncGenerator, Callable, Coroutine, Generator from pathlib import Path from typing import Any -from unittest.mock import DEFAULT, MagicMock, PropertyMock, patch +from unittest.mock import DEFAULT, AsyncMock, MagicMock, PropertyMock, patch from hass_nabucasa import Cloud from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED from hass_nabucasa.google_report_state import GoogleReportState +from hass_nabucasa.ice_servers import IceServers from hass_nabucasa.iot import CloudIoT from hass_nabucasa.remote import RemoteUI from hass_nabucasa.voice import Voice @@ -68,6 +69,12 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: ) mock_cloud.voice = MagicMock(spec=Voice) mock_cloud.started = None + mock_cloud.ice_servers = MagicMock( + spec=IceServers, + async_register_ice_servers_listener=AsyncMock( + return_value=lambda: "mock-unregister" + ), + ) def set_up_mock_cloud( cloud_client: CloudClient, mode: str, **kwargs: Any diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 7af163cc49d..43eccc5ef9c 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -1,5 +1,6 @@ """Test the cloud.iot module.""" +from collections.abc import Callable, Coroutine from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch @@ -183,6 +184,59 @@ async def test_handler_google_actions_disabled( assert resp["payload"] == response_payload +async def test_handler_ice_servers( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], +) -> None: + """Test handler ICE servers.""" + assert await async_setup_component(hass, "cloud", {"cloud": {}}) + await hass.async_block_till_done() + # make sure that preferences will not be reset + await cloud.client.prefs.async_set_username(cloud.username) + await set_cloud_prefs( + { + "alexa_enabled": False, + "google_enabled": False, + } + ) + + await cloud.login("test-user", "test-pass") + await cloud.client.cloud_connected() + + assert cloud.client._cloud_ice_servers_listener is not None + assert cloud.client._cloud_ice_servers_listener() == "mock-unregister" + + +async def test_handler_ice_servers_disabled( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], +) -> None: + """Test handler ICE servers when user has disabled it.""" + assert await async_setup_component(hass, "cloud", {"cloud": {}}) + await hass.async_block_till_done() + # make sure that preferences will not be reset + await cloud.client.prefs.async_set_username(cloud.username) + await set_cloud_prefs( + { + "alexa_enabled": False, + "google_enabled": False, + } + ) + + await cloud.login("test-user", "test-pass") + await cloud.client.cloud_connected() + + await set_cloud_prefs( + { + "cloud_ice_servers_enabled": False, + } + ) + + assert cloud.client._cloud_ice_servers_listener is None + + async def test_webhook_msg( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -475,13 +529,16 @@ async def test_logged_out( await cloud.client.cloud_connected() await hass.async_block_till_done() + assert cloud.client._cloud_ice_servers_listener is not None + # Simulate logged out await cloud.logout() await hass.async_block_till_done() - # Check we clean up Alexa and Google + # Check we clean up Alexa, Google and ICE servers assert cloud.client._alexa_config is None assert cloud.client._google_config is None + assert cloud.client._cloud_ice_servers_listener is None google_config_mock.async_deinitialize.assert_called_once_with() alexa_config_mock.async_deinitialize.assert_called_once_with() diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 15339f43dae..216fc77db48 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -784,6 +784,7 @@ async def test_websocket_status( "google_report_state": True, "remote_allow_remote_enable": True, "remote_enabled": False, + "cloud_ice_servers_enabled": True, "tts_default_voice": ["en-US", "JennyNeural"], }, "alexa_entities": { @@ -903,6 +904,7 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.alexa_enabled assert cloud.client.prefs.google_secure_devices_pin is None assert cloud.client.prefs.remote_allow_remote_enable is True + assert cloud.client.prefs.cloud_ice_servers_enabled is True client = await hass_ws_client(hass) @@ -914,6 +916,7 @@ async def test_websocket_update_preferences( "google_secure_devices_pin": "1234", "tts_default_voice": ["en-GB", "RyanNeural"], "remote_allow_remote_enable": False, + "cloud_ice_servers_enabled": False, } ) response = await client.receive_json() @@ -923,6 +926,7 @@ async def test_websocket_update_preferences( assert not cloud.client.prefs.alexa_enabled assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.remote_allow_remote_enable is False + assert cloud.client.prefs.cloud_ice_servers_enabled is False assert cloud.client.prefs.tts_default_voice == ("en-GB", "RyanNeural") diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 60b23e47fec..6293f44067d 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -50,7 +50,12 @@ async def test_cloud_system_health( await cloud.client.async_system_message({"region": "xx-earth-616"}) await set_cloud_prefs( - {"alexa_enabled": True, "google_enabled": False, "remote_enabled": True} + { + "alexa_enabled": True, + "google_enabled": False, + "remote_enabled": True, + "cloud_ice_servers_enabled": True, + } ) info = await get_system_health_info(hass, "cloud") @@ -70,6 +75,7 @@ async def test_cloud_system_health( "remote_server": "us-west-1", "alexa_enabled": True, "google_enabled": False, + "cloud_ice_servers_enabled": True, "can_reach_cert_server": "ok", "can_reach_cloud_auth": {"type": "failed", "error": "unreachable"}, "can_reach_cloud": "ok",