From 59efcba97185cec3bf801fae58521e4c813e1b8e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 22 Oct 2024 17:12:18 +0200 Subject: [PATCH 1/3] Allow configuring WebRTC stun and turn servers --- homeassistant/components/camera/__init__.py | 5 +- homeassistant/components/camera/webrtc.py | 65 +----------------- homeassistant/components/nest/camera.py | 2 +- homeassistant/config.py | 47 ++++++++++++- homeassistant/core.py | 3 + homeassistant/util/webrtc.py | 76 +++++++++++++++++++++ tests/components/camera/test_webrtc.py | 27 ++++++++ 7 files changed, 157 insertions(+), 68 deletions(-) create mode 100644 homeassistant/util/webrtc.py diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 7ae12b36dcde2..3555fad109907 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -63,6 +63,7 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass +from homeassistant.util.webrtc import RTCIceServer, WebRTCClientConfiguration from .const import ( # noqa: F401 _DEPRECATED_STREAM_TYPE_HLS, @@ -86,8 +87,6 @@ from .webrtc import ( DATA_ICE_SERVERS, CameraWebRTCProvider, - RTCIceServer, - WebRTCClientConfiguration, async_get_supported_providers, async_register_ice_servers, async_register_rtsp_to_web_rtc_provider, # noqa: F401 @@ -403,6 +402,8 @@ def unsub_track_time_interval(_event: Event) -> None: @callback def get_ice_servers() -> list[RTCIceServer]: + if hass.config.webrtc.ice_servers: + return hass.config.webrtc.ice_servers return [RTCIceServer(urls="stun:stun.home-assistant.io:80")] async_register_ice_servers(hass, get_ice_servers) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 963fb70594161..7a30e330aec86 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Awaitable, Callable, Iterable -from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Protocol import voluptuous as vol @@ -13,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey +from homeassistant.util.webrtc import RTCIceServer from .const import DATA_COMPONENT, DOMAIN, StreamType from .helper import get_camera_from_entity_id @@ -29,69 +29,6 @@ ) -@dataclass -class RTCIceServer: - """RTC Ice Server. - - See https://www.w3.org/TR/webrtc/#rtciceserver-dictionary - """ - - urls: list[str] | str - username: str | None = None - credential: str | None = None - - def to_frontend_dict(self) -> dict[str, Any]: - """Return a dict that can be used by the frontend.""" - - data = { - "urls": self.urls, - } - if self.username is not None: - data["username"] = self.username - if self.credential is not None: - data["credential"] = self.credential - return data - - -@dataclass -class RTCConfiguration: - """RTC Configuration. - - See https://www.w3.org/TR/webrtc/#rtcconfiguration-dictionary - """ - - ice_servers: list[RTCIceServer] = field(default_factory=list) - - def to_frontend_dict(self) -> dict[str, Any]: - """Return a dict that can be used by the frontend.""" - if not self.ice_servers: - return {} - - return { - "iceServers": [server.to_frontend_dict() for server in self.ice_servers] - } - - -@dataclass(kw_only=True) -class WebRTCClientConfiguration: - """WebRTC configuration for the client. - - Not part of the spec, but required to configure client. - """ - - configuration: RTCConfiguration = field(default_factory=RTCConfiguration) - data_channel: str | None = None - - def to_frontend_dict(self) -> dict[str, Any]: - """Return a dict that can be used by the frontend.""" - data: dict[str, Any] = { - "configuration": self.configuration.to_frontend_dict(), - } - if self.data_channel is not None: - data["dataChannel"] = self.data_channel - return data - - class CameraWebRTCProvider(Protocol): """WebRTC provider.""" diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index e25ff82694f96..c03decb157263 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -21,7 +21,6 @@ from google_nest_sdm.exceptions import ApiException from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType -from homeassistant.components.camera.webrtc import WebRTCClientConfiguration from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -29,6 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow +from homeassistant.util.webrtc import WebRTCClientConfiguration from .const import DATA_DEVICE_MANAGER, DOMAIN from .device_info import NestDeviceInfo diff --git a/homeassistant/config.py b/homeassistant/config.py index 9063429ca9177..a98936e741c5b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -16,7 +16,7 @@ import re import shutil from types import ModuleType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final from urllib.parse import urlparse from awesomeversion import AwesomeVersion @@ -57,6 +57,8 @@ CONF_TIME_ZONE, CONF_TYPE, CONF_UNIT_SYSTEM, + CONF_URL, + CONF_USERNAME, LEGACY_CONF_WHITELIST_EXTERNAL_DIRS, __version__, ) @@ -73,6 +75,7 @@ from .util.hass_dict import HassKey from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system +from .util.webrtc import RTCIceServer from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict from .util.yaml.objects import NodeStrClass @@ -94,6 +97,10 @@ SAFE_MODE_FILENAME = "safe-mode" +CONF_CREDENTIAL: Final = "credential" +CONF_ICE_SERVERS: Final = "ice_servers" +CONF_WEBRTC: Final = "webrtc" + DEFAULT_CONFIG = f""" # Loads default set of integrations. Do not remove. default_config: @@ -301,6 +308,16 @@ def _validate_currency(data: Any) -> Any: raise +def stun_or_turn_url(value: Any) -> str: + """Validate an URL.""" + url_in = str(value) + url = urlparse(url_in) + + if url.scheme not in ("stun", "stuns", "turn", "turns"): + raise vol.Invalid("invalid url") + return url_in + + CORE_CONFIG_SCHEMA = vol.All( CUSTOMIZE_CONFIG_SCHEMA.extend( { @@ -361,6 +378,24 @@ def _validate_currency(data: Any) -> Any: vol.Optional(CONF_COUNTRY): cv.country, vol.Optional(CONF_LANGUAGE): cv.language, vol.Optional(CONF_DEBUG): cv.boolean, + vol.Optional(CONF_WEBRTC): vol.Schema( + { + vol.Required(CONF_ICE_SERVERS): vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_URL): vol.All( + cv.ensure_list, [stun_or_turn_url] + ), + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_CREDENTIAL): cv.string, + } + ) + ], + ) + } + ), } ), _filter_bad_internal_external_urls, @@ -877,6 +912,16 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non if config.get(CONF_DEBUG): hac.debug = True + if CONF_WEBRTC in config: + hac.webrtc.ice_servers = [ + RTCIceServer( + server[CONF_URL], + server.get(CONF_USERNAME), + server.get(CONF_CREDENTIAL), + ) + for server in config[CONF_WEBRTC][CONF_ICE_SERVERS] + ] + _raise_issue_if_historic_currency(hass, hass.config.currency) _raise_issue_if_no_country(hass, hass.config.country) diff --git a/homeassistant/core.py b/homeassistant/core.py index 82ec4956a94d6..791f684f8f9d5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -119,6 +119,7 @@ UnitSystem, get_unit_system, ) +from .util.webrtc import WebRTCCoreConfiguration # Typing imports that create a circular dependency if TYPE_CHECKING: @@ -2966,6 +2967,8 @@ def __init__(self, hass: HomeAssistant, config_dir: str) -> None: # If Home Assistant is running in safe mode self.safe_mode: bool = False + self.webrtc = WebRTCCoreConfiguration() + def async_initialize(self) -> None: """Finish initializing a config object. diff --git a/homeassistant/util/webrtc.py b/homeassistant/util/webrtc.py new file mode 100644 index 0000000000000..0bd28a22a7ae4 --- /dev/null +++ b/homeassistant/util/webrtc.py @@ -0,0 +1,76 @@ +"""WebRTC container classes.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class RTCIceServer: + """RTC Ice Server. + + See https://www.w3.org/TR/webrtc/#rtciceserver-dictionary + """ + + urls: list[str] | str + username: str | None = None + credential: str | None = None + + def to_frontend_dict(self) -> dict[str, Any]: + """Return a dict that can be used by the frontend.""" + + data = { + "urls": self.urls, + } + if self.username is not None: + data["username"] = self.username + if self.credential is not None: + data["credential"] = self.credential + return data + + +@dataclass +class RTCConfiguration: + """RTC Configuration. + + See https://www.w3.org/TR/webrtc/#rtcconfiguration-dictionary + """ + + ice_servers: list[RTCIceServer] = field(default_factory=list) + + def to_frontend_dict(self) -> dict[str, Any]: + """Return a dict that can be used by the frontend.""" + if not self.ice_servers: + return {} + + return { + "iceServers": [server.to_frontend_dict() for server in self.ice_servers] + } + + +@dataclass(kw_only=True) +class WebRTCClientConfiguration: + """WebRTC configuration for the client. + + Not part of the spec, but required to configure client. + """ + + configuration: RTCConfiguration = field(default_factory=RTCConfiguration) + data_channel: str | None = None + + def to_frontend_dict(self) -> dict[str, Any]: + """Return a dict that can be used by the frontend.""" + data: dict[str, Any] = { + "configuration": self.configuration.to_frontend_dict(), + } + if self.data_channel is not None: + data["dataChannel"] = self.data_channel + return data + + +@dataclass +class WebRTCCoreConfiguration: + """Core WebRTC configuration.""" + + ice_servers: list[RTCIceServer] = field(default_factory=list) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index de7eee8c1837f..0cd1b7f11ca16 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -13,6 +13,7 @@ async_register_webrtc_provider, ) from homeassistant.components.websocket_api import TYPE_RESULT +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component @@ -225,6 +226,32 @@ async def test_ws_get_client_config( } +@pytest.mark.usefixtures("mock_camera_web_rtc") +async def test_ws_get_client_config_custom_config( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test get WebRTC client config.""" + await async_process_ha_core_config( + hass, + {"webrtc": {"ice_servers": [{"url": "stun:custom_stun_server:3478"}]}}, + ) + + await async_setup_component(hass, "camera", {}) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} + ) + msg = await client.receive_json() + + # Assert WebSocket response + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == { + "configuration": {"iceServers": [{"urls": ["stun:custom_stun_server:3478"]}]} + } + + @pytest.mark.usefixtures("mock_camera_hls") async def test_ws_get_client_config_no_rtc_camera( hass: HomeAssistant, hass_ws_client: WebSocketGenerator From bff3d28d630c47d859770d44a05b68236f36bfc1 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 23 Oct 2024 12:06:11 +0200 Subject: [PATCH 2/3] Add tests --- homeassistant/config.py | 4 +- tests/test_config.py | 99 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index a98936e741c5b..a0fda7b61614c 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -308,7 +308,7 @@ def _validate_currency(data: Any) -> Any: raise -def stun_or_turn_url(value: Any) -> str: +def _validate_stun_or_turn_url(value: Any) -> str: """Validate an URL.""" url_in = str(value) url = urlparse(url_in) @@ -386,7 +386,7 @@ def stun_or_turn_url(value: Any) -> str: vol.Schema( { vol.Required(CONF_URL): vol.All( - cv.ensure_list, [stun_or_turn_url] + cv.ensure_list, [_validate_stun_or_turn_url] ), vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_CREDENTIAL): cv.string, diff --git a/tests/test_config.py b/tests/test_config.py index 02f8e1fc07836..ecbc15eca1df8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -48,6 +48,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util import webrtc as webrtc_util from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, @@ -525,6 +526,8 @@ def test_core_config_schema() -> None: {"country": "xx"}, {"language": "xx"}, {"radius": -10}, + {"webrtc": "bla"}, + {"webrtc": {}}, ): with pytest.raises(MultipleInvalid): config_util.CORE_CONFIG_SCHEMA(value) @@ -542,6 +545,7 @@ def test_core_config_schema() -> None: "country": "SE", "language": "sv", "radius": "10", + "webrtc": {"ice_servers": [{"url": "stun:custom_stun_server:3478"}]}, } ) @@ -574,6 +578,97 @@ def test_customize_dict_schema() -> None: ) == {ATTR_FRIENDLY_NAME: "2", ATTR_ASSUMED_STATE: False} +def test_webrtc_schema() -> None: + """Test webrtc config validation.""" + invalid_webrtc_configs = ( + "bla", + {}, + {"ice_servers": [], "unknown_key": 123}, + {"ice_servers": [{}]}, + {"ice_servers": [{"invalid_key": 123}]}, + ) + + valid_webrtc_configs = ( + ( + {"ice_servers": []}, + {"ice_servers": []}, + ), + ( + {"ice_servers": {"url": "stun:custom_stun_server:3478"}}, + {"ice_servers": [{"url": ["stun:custom_stun_server:3478"]}]}, + ), + ( + {"ice_servers": [{"url": "stun:custom_stun_server:3478"}]}, + {"ice_servers": [{"url": ["stun:custom_stun_server:3478"]}]}, + ), + ( + {"ice_servers": [{"url": ["stun:custom_stun_server:3478"]}]}, + {"ice_servers": [{"url": ["stun:custom_stun_server:3478"]}]}, + ), + ( + { + "ice_servers": [ + { + "url": ["stun:custom_stun_server:3478"], + "username": "bla", + "credential": "hunter2", + } + ] + }, + { + "ice_servers": [ + { + "url": ["stun:custom_stun_server:3478"], + "username": "bla", + "credential": "hunter2", + } + ] + }, + ), + ) + + for config in invalid_webrtc_configs: + with pytest.raises(MultipleInvalid): + config_util.CORE_CONFIG_SCHEMA({"webrtc": config}) + + for config, validated_webrtc in valid_webrtc_configs: + validated = config_util.CORE_CONFIG_SCHEMA({"webrtc": config}) + assert validated["webrtc"] == validated_webrtc + + +def test_validate_stun_or_turn_url() -> None: + """Test _validate_stun_or_turn_url.""" + invalid_urls = ( + "custom_stun_server", + "custom_stun_server:3478", + "bum:custom_stun_server:3478" "http://blah.com:80", + ) + + valid_urls = ( + "stun:custom_stun_server:3478", + "turn:custom_stun_server:3478", + "stuns:custom_stun_server:3478", + "turns:custom_stun_server:3478", + # The validator does not reject urls with path + "stun:custom_stun_server:3478/path", + "turn:custom_stun_server:3478/path", + "stuns:custom_stun_server:3478/path", + "turns:custom_stun_server:3478/path", + # The validator allows any query + "stun:custom_stun_server:3478?query", + "turn:custom_stun_server:3478?query", + "stuns:custom_stun_server:3478?query", + "turns:custom_stun_server:3478?query", + ) + + for url in invalid_urls: + with pytest.raises(Invalid): + config_util._validate_stun_or_turn_url(url) + + for url in valid_urls: + assert config_util._validate_stun_or_turn_url(url) == url + + def test_customize_glob_is_ordered() -> None: """Test that customize_glob preserves order.""" conf = config_util.CORE_CONFIG_SCHEMA({"customize_glob": OrderedDict()}) @@ -870,6 +965,7 @@ async def test_loading_configuration(hass: HomeAssistant) -> None: "country": "SE", "language": "sv", "radius": 150, + "webrtc": {"ice_servers": [{"url": "stun:custom_stun_server:3478"}]}, }, ) @@ -891,6 +987,9 @@ async def test_loading_configuration(hass: HomeAssistant) -> None: assert hass.config.country == "SE" assert hass.config.language == "sv" assert hass.config.radius == 150 + assert hass.config.webrtc == webrtc_util.WebRTCCoreConfiguration( + [webrtc_util.RTCIceServer(urls=["stun:custom_stun_server:3478"])] + ) @pytest.mark.parametrize( From 0ab72f515bf1d2f3a70b97a27b6fa89679fbfe60 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 23 Oct 2024 12:52:29 +0200 Subject: [PATCH 3/3] Remove class WebRTCCoreConfiguration --- homeassistant/core.py | 4 ++-- homeassistant/util/webrtc.py | 7 ------- tests/test_config.py | 2 +- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 791f684f8f9d5..f03e870f547fe 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -119,7 +119,7 @@ UnitSystem, get_unit_system, ) -from .util.webrtc import WebRTCCoreConfiguration +from .util.webrtc import RTCConfiguration # Typing imports that create a circular dependency if TYPE_CHECKING: @@ -2967,7 +2967,7 @@ def __init__(self, hass: HomeAssistant, config_dir: str) -> None: # If Home Assistant is running in safe mode self.safe_mode: bool = False - self.webrtc = WebRTCCoreConfiguration() + self.webrtc = RTCConfiguration() def async_initialize(self) -> None: """Finish initializing a config object. diff --git a/homeassistant/util/webrtc.py b/homeassistant/util/webrtc.py index 0bd28a22a7ae4..fd5545af492e0 100644 --- a/homeassistant/util/webrtc.py +++ b/homeassistant/util/webrtc.py @@ -67,10 +67,3 @@ def to_frontend_dict(self) -> dict[str, Any]: if self.data_channel is not None: data["dataChannel"] = self.data_channel return data - - -@dataclass -class WebRTCCoreConfiguration: - """Core WebRTC configuration.""" - - ice_servers: list[RTCIceServer] = field(default_factory=list) diff --git a/tests/test_config.py b/tests/test_config.py index ecbc15eca1df8..a07a09e4228b9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -987,7 +987,7 @@ async def test_loading_configuration(hass: HomeAssistant) -> None: assert hass.config.country == "SE" assert hass.config.language == "sv" assert hass.config.radius == 150 - assert hass.config.webrtc == webrtc_util.WebRTCCoreConfiguration( + assert hass.config.webrtc == webrtc_util.RTCConfiguration( [webrtc_util.RTCIceServer(urls=["stun:custom_stun_server:3478"])] )