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
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."""

View File

@ -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

View File

@ -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

View File

@ -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: {},

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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")

View File

@ -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",