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 <marhje52@gmail.com>

* Improve tests based on PR reviews

* Improve tests

* Use set_cloud_prefs fixture

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Robert Resch <robert@resch.dev>
pull/129454/head^2
Krisjanis Lejejs 2024-10-29 21:35:52 +02:00 committed by GitHub
parent 96ba5c3983
commit a1e2d79613
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 148 additions and 4 deletions

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable
from datetime import datetime from datetime import datetime
from http import HTTPStatus from http import HTTPStatus
import logging import logging
@ -11,12 +12,14 @@ from typing import Any, Literal
import aiohttp import aiohttp
from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed 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 import google_assistant, persistent_notification, webhook
from homeassistant.components.alexa import ( from homeassistant.components.alexa import (
errors as alexa_errors, errors as alexa_errors,
smart_home as alexa_smart_home, 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.components.google_assistant import smart_home as ga
from homeassistant.const import __version__ as HA_VERSION from homeassistant.const import __version__ as HA_VERSION
from homeassistant.core import Context, HassJob, HomeAssistant, callback 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 homeassistant.util.aiohttp import MockRequest, serialize_response
from . import alexa_config, google_config 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 from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -60,6 +63,7 @@ class CloudClient(Interface):
self._alexa_config_init_lock = asyncio.Lock() self._alexa_config_init_lock = asyncio.Lock()
self._google_config_init_lock = asyncio.Lock() self._google_config_init_lock = asyncio.Lock()
self._relayer_region: str | None = None self._relayer_region: str | None = None
self._cloud_ice_servers_listener: Callable[[], None] | None = None
@property @property
def base_path(self) -> Path: def base_path(self) -> Path:
@ -187,6 +191,49 @@ class CloudClient(Interface):
if is_new_user: if is_new_user:
await gconf.async_sync_entities(gconf.agent_user_id) 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 = [] tasks = []
if self._prefs.alexa_enabled and self._prefs.alexa_report_state: if self._prefs.alexa_enabled and self._prefs.alexa_report_state:
@ -195,6 +242,8 @@ class CloudClient(Interface):
if self._prefs.google_enabled: if self._prefs.google_enabled:
tasks.append(enable_google) tasks.append(enable_google)
tasks.append(setup_cloud_ice_servers)
if tasks: if tasks:
await asyncio.gather(*(task(None) for task in 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.async_deinitialize()
self._google_config = None self._google_config = None
if self._cloud_ice_servers_listener:
self._cloud_ice_servers_listener()
self._cloud_ice_servers_listener = None
@callback @callback
def user_message(self, identifier: str, title: str, message: str) -> None: def user_message(self, identifier: str, title: str, message: str) -> None:
"""Create a message for user to UI.""" """Create a message for user to UI."""

View File

@ -43,6 +43,7 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
PREF_GOOGLE_CONNECTED = "google_connected" PREF_GOOGLE_CONNECTED = "google_connected"
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" 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_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural")
DEFAULT_DISABLE_2FA = False DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = True DEFAULT_ALEXA_REPORT_STATE = True

View File

@ -42,6 +42,7 @@ from .const import (
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
PREF_DISABLE_2FA, PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA, PREF_ENABLE_ALEXA,
PREF_ENABLE_CLOUD_ICE_SERVERS,
PREF_ENABLE_GOOGLE, PREF_ENABLE_GOOGLE,
PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN, 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.Coerce(tuple), validate_language_voice
), ),
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool,
} }
) )
@websocket_api.async_response @websocket_api.async_response

View File

@ -32,6 +32,7 @@ from .const import (
PREF_CLOUD_USER, PREF_CLOUD_USER,
PREF_CLOUDHOOKS, PREF_CLOUDHOOKS,
PREF_ENABLE_ALEXA, PREF_ENABLE_ALEXA,
PREF_ENABLE_CLOUD_ICE_SERVERS,
PREF_ENABLE_GOOGLE, PREF_ENABLE_GOOGLE,
PREF_ENABLE_REMOTE, PREF_ENABLE_REMOTE,
PREF_GOOGLE_CONNECTED, PREF_GOOGLE_CONNECTED,
@ -176,6 +177,7 @@ class CloudPreferences:
google_settings_version: int | UndefinedType = UNDEFINED, google_settings_version: int | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED, google_connected: bool | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED,
) -> None: ) -> None:
"""Update user preferences.""" """Update user preferences."""
prefs = {**self._prefs} prefs = {**self._prefs}
@ -198,6 +200,7 @@ class CloudPreferences:
(PREF_REMOTE_DOMAIN, remote_domain), (PREF_REMOTE_DOMAIN, remote_domain),
(PREF_GOOGLE_CONNECTED, google_connected), (PREF_GOOGLE_CONNECTED, google_connected),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
(PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled),
) )
if value is not UNDEFINED if value is not UNDEFINED
} }
@ -246,6 +249,7 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
} }
@property @property
@ -362,6 +366,14 @@ class CloudPreferences:
""" """
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return] 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: async def get_cloud_user(self) -> str:
"""Return ID of Home Assistant Cloud system user.""" """Return ID of Home Assistant Cloud system user."""
user = await self._load_cloud_user() user = await self._load_cloud_user()
@ -409,6 +421,7 @@ class CloudPreferences:
PREF_ENABLE_ALEXA: True, PREF_ENABLE_ALEXA: True,
PREF_ENABLE_GOOGLE: True, PREF_ENABLE_GOOGLE: True,
PREF_ENABLE_REMOTE: False, PREF_ENABLE_REMOTE: False,
PREF_ENABLE_CLOUD_ICE_SERVERS: True,
PREF_GOOGLE_CONNECTED: False, PREF_GOOGLE_CONNECTED: False,
PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_GOOGLE_ENTITY_CONFIGS: {},

View File

@ -33,6 +33,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
data["remote_connected"] = cloud.remote.is_connected data["remote_connected"] = cloud.remote.is_connected
data["alexa_enabled"] = client.prefs.alexa_enabled data["alexa_enabled"] = client.prefs.alexa_enabled
data["google_enabled"] = client.prefs.google_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["remote_server"] = cloud.remote.snitun_server
data["certificate_status"] = cloud.remote.certificate_status data["certificate_status"] = cloud.remote.certificate_status
data["instance_id"] = client.prefs.instance_id data["instance_id"] = client.prefs.instance_id

View File

@ -3,13 +3,14 @@
from collections.abc import AsyncGenerator, Callable, Coroutine, Generator from collections.abc import AsyncGenerator, Callable, Coroutine, Generator
from pathlib import Path from pathlib import Path
from typing import Any 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 import Cloud
from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.auth import CognitoAuth
from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.cloudhooks import Cloudhooks
from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED
from hass_nabucasa.google_report_state import GoogleReportState from hass_nabucasa.google_report_state import GoogleReportState
from hass_nabucasa.ice_servers import IceServers
from hass_nabucasa.iot import CloudIoT from hass_nabucasa.iot import CloudIoT
from hass_nabucasa.remote import RemoteUI from hass_nabucasa.remote import RemoteUI
from hass_nabucasa.voice import Voice from hass_nabucasa.voice import Voice
@ -68,6 +69,12 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]:
) )
mock_cloud.voice = MagicMock(spec=Voice) mock_cloud.voice = MagicMock(spec=Voice)
mock_cloud.started = None 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( def set_up_mock_cloud(
cloud_client: CloudClient, mode: str, **kwargs: Any cloud_client: CloudClient, mode: str, **kwargs: Any

View File

@ -1,5 +1,6 @@
"""Test the cloud.iot module.""" """Test the cloud.iot module."""
from collections.abc import Callable, Coroutine
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch 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 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( async def test_webhook_msg(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@ -475,13 +529,16 @@ async def test_logged_out(
await cloud.client.cloud_connected() await cloud.client.cloud_connected()
await hass.async_block_till_done() await hass.async_block_till_done()
assert cloud.client._cloud_ice_servers_listener is not None
# Simulate logged out # Simulate logged out
await cloud.logout() await cloud.logout()
await hass.async_block_till_done() 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._alexa_config is None
assert cloud.client._google_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() google_config_mock.async_deinitialize.assert_called_once_with()
alexa_config_mock.async_deinitialize.assert_called_once_with() alexa_config_mock.async_deinitialize.assert_called_once_with()

View File

@ -784,6 +784,7 @@ async def test_websocket_status(
"google_report_state": True, "google_report_state": True,
"remote_allow_remote_enable": True, "remote_allow_remote_enable": True,
"remote_enabled": False, "remote_enabled": False,
"cloud_ice_servers_enabled": True,
"tts_default_voice": ["en-US", "JennyNeural"], "tts_default_voice": ["en-US", "JennyNeural"],
}, },
"alexa_entities": { "alexa_entities": {
@ -903,6 +904,7 @@ async def test_websocket_update_preferences(
assert cloud.client.prefs.alexa_enabled assert cloud.client.prefs.alexa_enabled
assert cloud.client.prefs.google_secure_devices_pin is None assert cloud.client.prefs.google_secure_devices_pin is None
assert cloud.client.prefs.remote_allow_remote_enable is True 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) client = await hass_ws_client(hass)
@ -914,6 +916,7 @@ async def test_websocket_update_preferences(
"google_secure_devices_pin": "1234", "google_secure_devices_pin": "1234",
"tts_default_voice": ["en-GB", "RyanNeural"], "tts_default_voice": ["en-GB", "RyanNeural"],
"remote_allow_remote_enable": False, "remote_allow_remote_enable": False,
"cloud_ice_servers_enabled": False,
} }
) )
response = await client.receive_json() response = await client.receive_json()
@ -923,6 +926,7 @@ async def test_websocket_update_preferences(
assert not cloud.client.prefs.alexa_enabled assert not cloud.client.prefs.alexa_enabled
assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.google_secure_devices_pin == "1234"
assert cloud.client.prefs.remote_allow_remote_enable is False 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") assert cloud.client.prefs.tts_default_voice == ("en-GB", "RyanNeural")

View File

@ -50,7 +50,12 @@ async def test_cloud_system_health(
await cloud.client.async_system_message({"region": "xx-earth-616"}) await cloud.client.async_system_message({"region": "xx-earth-616"})
await set_cloud_prefs( 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") info = await get_system_health_info(hass, "cloud")
@ -70,6 +75,7 @@ async def test_cloud_system_health(
"remote_server": "us-west-1", "remote_server": "us-west-1",
"alexa_enabled": True, "alexa_enabled": True,
"google_enabled": False, "google_enabled": False,
"cloud_ice_servers_enabled": True,
"can_reach_cert_server": "ok", "can_reach_cert_server": "ok",
"can_reach_cloud_auth": {"type": "failed", "error": "unreachable"}, "can_reach_cloud_auth": {"type": "failed", "error": "unreachable"},
"can_reach_cloud": "ok", "can_reach_cloud": "ok",