Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow configuring WebRTC stun and turn servers #128984

Merged
merged 3 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions homeassistant/components/camera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 1 addition & 64 deletions homeassistant/components/camera/webrtc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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."""

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/nest/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@
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
from homeassistant.exceptions import HomeAssistantError
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
Expand Down
47 changes: 46 additions & 1 deletion homeassistant/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +57,8 @@
CONF_TIME_ZONE,
CONF_TYPE,
CONF_UNIT_SYSTEM,
CONF_URL,
CONF_USERNAME,
LEGACY_CONF_WHITELIST_EXTERNAL_DIRS,
__version__,
)
Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -301,6 +308,16 @@ def _validate_currency(data: Any) -> Any:
raise


def _validate_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(
{
Expand Down Expand Up @@ -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, [_validate_stun_or_turn_url]
),
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_CREDENTIAL): cv.string,
}
)
],
)
}
),
}
),
_filter_bad_internal_external_urls,
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions homeassistant/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
UnitSystem,
get_unit_system,
)
from .util.webrtc import WebRTCCoreConfiguration

# Typing imports that create a circular dependency
if TYPE_CHECKING:
Expand Down Expand Up @@ -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.

Expand Down
76 changes: 76 additions & 0 deletions homeassistant/util/webrtc.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 27 in homeassistant/util/webrtc.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/util/webrtc.py#L27

Added line #L27 was not covered by tests
if self.credential is not None:
data["credential"] = self.credential

Check warning on line 29 in homeassistant/util/webrtc.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/util/webrtc.py#L29

Added line #L29 was not covered by tests
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 {}

Check warning on line 45 in homeassistant/util/webrtc.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/util/webrtc.py#L45

Added line #L45 was not covered by tests

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

Check warning on line 68 in homeassistant/util/webrtc.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/util/webrtc.py#L68

Added line #L68 was not covered by tests
return data


@dataclass
class WebRTCCoreConfiguration:
emontnemery marked this conversation as resolved.
Show resolved Hide resolved
"""Core WebRTC configuration."""

ice_servers: list[RTCIceServer] = field(default_factory=list)
27 changes: 27 additions & 0 deletions tests/components/camera/test_webrtc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading