1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Extract WebRTC integration (#157648)

This commit is contained in:
Paulus Schoutsen
2025-12-04 09:44:24 -05:00
committed by GitHub
parent 837de55ce6
commit 855d7c6e16
16 changed files with 421 additions and 147 deletions

2
CODEOWNERS generated
View File

@@ -1805,6 +1805,8 @@ build.json @home-assistant/supervisor
/tests/components/weatherflow_cloud/ @jeeftor
/homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner
/homeassistant/components/web_rtc/ @home-assistant/core
/tests/components/web_rtc/ @home-assistant/core
/homeassistant/components/webdav/ @jpbede
/tests/components/webdav/ @jpbede
/homeassistant/components/webhook/ @home-assistant/core

View File

@@ -20,7 +20,7 @@ from aiohttp import hdrs, web
import attr
from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit, RTCIceServer
from webrtc_models import RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
@@ -37,6 +37,7 @@ from homeassistant.components.stream import (
Stream,
create_stream,
)
from homeassistant.components.web_rtc import async_get_ice_servers
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -84,7 +85,6 @@ from .prefs import (
get_dynamic_camera_stream_settings,
)
from .webrtc import (
DATA_ICE_SERVERS,
CameraWebRTCProvider,
WebRTCAnswer, # noqa: F401
WebRTCCandidate, # noqa: F401
@@ -93,7 +93,6 @@ from .webrtc import (
WebRTCMessage, # noqa: F401
WebRTCSendMessage,
async_get_supported_provider,
async_register_ice_servers,
async_register_webrtc_provider, # noqa: F401
async_register_ws,
)
@@ -400,20 +399,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service
)
@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:3478",
"stun:stun.home-assistant.io:80",
]
),
]
async_register_ice_servers(hass, get_ice_servers)
return True
@@ -731,11 +716,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
config = self._async_get_webrtc_client_configuration()
ice_servers = [
server
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
for server in servers()
]
ice_servers = async_get_ice_servers(self.hass)
config.configuration.ice_servers.extend(ice_servers)
return config

View File

@@ -3,7 +3,7 @@
"name": "Camera",
"after_dependencies": ["media_player"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["http"],
"dependencies": ["http", "web_rtc"],
"documentation": "https://www.home-assistant.io/integrations/camera",
"integration_type": "entity",
"quality_scale": "internal",

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field
from functools import cache, partial, wraps
import logging
@@ -12,12 +12,7 @@ from typing import TYPE_CHECKING, Any
from mashumaro import MissingField
import voluptuous as vol
from webrtc_models import (
RTCConfiguration,
RTCIceCandidate,
RTCIceCandidateInit,
RTCIceServer,
)
from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
@@ -38,9 +33,6 @@ _LOGGER = logging.getLogger(__name__)
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
"camera_webrtc_providers"
)
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
"camera_webrtc_ice_servers"
)
_WEBRTC = "WebRTC"
@@ -367,21 +359,3 @@ async def async_get_supported_provider(
return provider
return None
@callback
def async_register_ice_servers(
hass: HomeAssistant,
get_ice_server_fn: Callable[[], Iterable[RTCIceServer]],
) -> Callable[[], None]:
"""Register a ICE server.
The registering integration is responsible to implement caching if needed.
"""
servers = hass.data.setdefault(DATA_ICE_SERVERS, [])
def remove() -> None:
servers.remove(get_ice_server_fn)
servers.append(get_ice_server_fn)
return remove

View File

@@ -19,8 +19,8 @@ from homeassistant.components.alexa import (
errors as alexa_errors,
smart_home as alexa_smart_home,
)
from homeassistant.components.camera import async_register_ice_servers
from homeassistant.components.google_assistant import smart_home as ga
from homeassistant.components.web_rtc import async_register_ice_servers
from homeassistant.const import __version__ as HA_VERSION
from homeassistant.core import Context, HassJob, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE

View File

@@ -8,7 +8,7 @@
"google_assistant"
],
"codeowners": ["@home-assistant/cloud"],
"dependencies": ["auth", "http", "repairs", "webhook"],
"dependencies": ["auth", "http", "repairs", "webhook", "web_rtc"],
"documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system",
"iot_class": "cloud_push",

View File

@@ -0,0 +1,138 @@
"""The WebRTC integration."""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import Any
import voluptuous as vol
from webrtc_models import RTCIceServer
from homeassistant.components import websocket_api
from homeassistant.const import CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.core_config import (
CONF_CREDENTIAL,
CONF_ICE_SERVERS,
validate_stun_or_turn_url,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
__all__ = [
"async_get_ice_servers",
"async_register_ice_servers",
]
DOMAIN = "web_rtc"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: 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,
}
)
],
)
}
)
},
extra=vol.ALLOW_EXTRA,
)
DATA_ICE_SERVERS_USER: HassKey[Iterable[RTCIceServer]] = HassKey(
"web_rtc_ice_servers_user"
)
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
"web_rtc_ice_servers"
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the WebRTC integration."""
servers = [
RTCIceServer(
server[CONF_URL],
server.get(CONF_USERNAME),
server.get(CONF_CREDENTIAL),
)
for server in config.get(DOMAIN, {}).get(CONF_ICE_SERVERS, [])
]
if servers:
hass.data[DATA_ICE_SERVERS_USER] = servers
hass.data[DATA_ICE_SERVERS] = []
websocket_api.async_register_command(hass, ws_ice_servers)
return True
@callback
def async_register_ice_servers(
hass: HomeAssistant,
get_ice_server_fn: Callable[[], Iterable[RTCIceServer]],
) -> Callable[[], None]:
"""Register an ICE server.
The registering integration is responsible to implement caching if needed.
"""
servers = hass.data[DATA_ICE_SERVERS]
def remove() -> None:
servers.remove(get_ice_server_fn)
servers.append(get_ice_server_fn)
return remove
@callback
def async_get_ice_servers(hass: HomeAssistant) -> list[RTCIceServer]:
"""Return all registered ICE servers."""
servers: list[RTCIceServer] = []
if hass.config.webrtc.ice_servers:
servers.extend(hass.config.webrtc.ice_servers)
if DATA_ICE_SERVERS_USER in hass.data:
servers.extend(hass.data[DATA_ICE_SERVERS_USER])
if not servers:
servers = [
RTCIceServer(
urls=[
"stun:stun.home-assistant.io:3478",
"stun:stun.home-assistant.io:80",
]
),
]
for gen_servers in hass.data[DATA_ICE_SERVERS]:
servers.extend(gen_servers())
return servers
@websocket_api.websocket_command(
{
"type": "web_rtc/ice_servers",
}
)
@callback
def ws_ice_servers(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Handle get WebRTC ICE servers websocket command."""
ice_servers = [server.to_dict() for server in async_get_ice_servers(hass)]
connection.send_result(msg["id"], ice_servers)

View File

@@ -0,0 +1,8 @@
{
"domain": "web_rtc",
"name": "WebRTC",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/web_rtc",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -249,7 +249,7 @@ def _validate_currency(data: Any) -> Any:
raise
def _validate_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)
@@ -331,7 +331,7 @@ CORE_CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(CONF_URL): vol.All(
cv.ensure_list, [_validate_stun_or_turn_url]
cv.ensure_list, [validate_stun_or_turn_url]
),
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_CREDENTIAL): cv.string,

View File

@@ -115,6 +115,7 @@ NO_IOT_CLASS = [
"tag",
"timer",
"trace",
"web_rtc",
"webhook",
"websocket_api",
"zone",

View File

@@ -2196,6 +2196,7 @@ NO_QUALITY_SCALE = [
"timer",
"trace",
"usage_prediction",
"web_rtc",
"webhook",
"websocket_api",
"zone",

View File

@@ -8,7 +8,6 @@ import pytest
from webrtc_models import RTCIceCandidate, RTCIceCandidateInit, RTCIceServer
from homeassistant.components.camera import (
DATA_ICE_SERVERS,
Camera,
CameraWebRTCProvider,
StreamType,
@@ -17,10 +16,10 @@ from homeassistant.components.camera import (
WebRTCError,
WebRTCMessage,
WebRTCSendMessage,
async_register_ice_servers,
async_register_webrtc_provider,
get_camera_from_entity_id,
)
from homeassistant.components.web_rtc import async_register_ice_servers
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.core import HomeAssistant, callback
from homeassistant.core_config import async_process_ha_core_config
@@ -101,89 +100,6 @@ async def test_async_register_webrtc_provider_camera_not_loaded(
async_register_webrtc_provider(hass, SomeTestProvider())
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_async_register_ice_server(
hass: HomeAssistant,
) -> None:
"""Test registering an ICE server."""
# Clear any existing ICE servers
hass.data[DATA_ICE_SERVERS].clear()
called = 0
@callback
def get_ice_servers() -> list[RTCIceServer]:
nonlocal called
called += 1
return [
RTCIceServer(urls="stun:example.com"),
RTCIceServer(urls="turn:example.com"),
]
unregister = async_register_ice_servers(hass, get_ice_servers)
assert not called
camera = get_camera_from_entity_id(hass, "camera.async")
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [
RTCIceServer(urls="stun:example.com"),
RTCIceServer(urls="turn:example.com"),
]
assert called == 1
# register another ICE server
called_2 = 0
@callback
def get_ice_servers_2() -> list[RTCIceServer]:
nonlocal called_2
called_2 += 1
return [
RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
)
]
unregister_2 = async_register_ice_servers(hass, get_ice_servers_2)
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [
RTCIceServer(urls="stun:example.com"),
RTCIceServer(urls="turn:example.com"),
RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
),
]
assert called == 2
assert called_2 == 1
# unregister the first ICE server
unregister()
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [
RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
),
]
assert called == 2
assert called_2 == 2
# unregister the second ICE server
unregister_2()
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == []
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator

View File

@@ -21,7 +21,7 @@
## Active Integrations
Built-in integrations: 21
Built-in integrations: 22
Custom integrations: 1
<details><summary>Built-in integrations</summary>
@@ -48,6 +48,7 @@
stt | Speech-to-text (STT)
system_health | System Health
tts | Text-to-speech (TTS)
web_rtc | WebRTC
webhook | Webhook
</details>
@@ -122,7 +123,7 @@
## Active Integrations
Built-in integrations: 21
Built-in integrations: 22
Custom integrations: 0
<details><summary>Built-in integrations</summary>
@@ -149,6 +150,7 @@
stt | Speech-to-text (STT)
system_health | System Health
tts | Text-to-speech (TTS)
web_rtc | WebRTC
webhook | Webhook
</details>

View File

@@ -0,0 +1 @@
"""Tests for the WebRTC integration."""

View File

@@ -0,0 +1,250 @@
"""Test the WebRTC integration."""
from webrtc_models import RTCIceServer
from homeassistant.components.web_rtc import (
async_get_ice_servers,
async_register_ice_servers,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core_config import async_process_ha_core_config
from homeassistant.setup import async_setup_component
from tests.typing import WebSocketGenerator
async def test_async_setup(hass: HomeAssistant) -> None:
"""Test setting up the web_rtc integration."""
assert await async_setup_component(hass, "web_rtc", {})
await hass.async_block_till_done()
# Verify default ICE servers are registered
ice_servers = async_get_ice_servers(hass)
assert len(ice_servers) == 1
assert ice_servers[0].urls == [
"stun:stun.home-assistant.io:3478",
"stun:stun.home-assistant.io:80",
]
async def test_async_setup_custom_ice_servers_core(hass: HomeAssistant) -> None:
"""Test setting up web_rtc with custom ICE servers in config."""
await async_process_ha_core_config(
hass,
{"webrtc": {"ice_servers": [{"url": "stun:custom_stun_server:3478"}]}},
)
assert await async_setup_component(hass, "web_rtc", {})
await hass.async_block_till_done()
ice_servers = async_get_ice_servers(hass)
assert len(ice_servers) == 1
assert ice_servers[0].urls == ["stun:custom_stun_server:3478"]
async def test_async_setup_custom_ice_servers_integration(hass: HomeAssistant) -> None:
"""Test setting up web_rtc with custom ICE servers in config."""
assert await async_setup_component(
hass,
"web_rtc",
{
"web_rtc": {
"ice_servers": [
{"url": "stun:custom_stun_server:3478"},
{
"url": "stun:custom_stun_server:3478",
"credential": "mock-credential",
},
{
"url": "stun:custom_stun_server:3478",
"username": "mock-username",
},
{
"url": "stun:custom_stun_server:3478",
"credential": "mock-credential",
"username": "mock-username",
},
]
}
},
)
await hass.async_block_till_done()
ice_servers = async_get_ice_servers(hass)
assert ice_servers == [
RTCIceServer(
urls=["stun:custom_stun_server:3478"],
),
RTCIceServer(
urls=["stun:custom_stun_server:3478"],
credential="mock-credential",
),
RTCIceServer(
urls=["stun:custom_stun_server:3478"],
username="mock-username",
),
RTCIceServer(
urls=["stun:custom_stun_server:3478"],
username="mock-username",
credential="mock-credential",
),
]
async def test_async_setup_custom_ice_servers_core_and_integration(
hass: HomeAssistant,
) -> None:
"""Test setting up web_rtc with custom ICE servers in config."""
await async_process_ha_core_config(
hass,
{"webrtc": {"ice_servers": [{"url": "stun:custom_stun_server_core:3478"}]}},
)
assert await async_setup_component(
hass,
"web_rtc",
{
"web_rtc": {
"ice_servers": [{"url": "stun:custom_stun_server_integration:3478"}]
}
},
)
await hass.async_block_till_done()
ice_servers = async_get_ice_servers(hass)
assert ice_servers == [
RTCIceServer(
urls=["stun:custom_stun_server_core:3478"],
),
RTCIceServer(
urls=["stun:custom_stun_server_integration:3478"],
),
]
async def test_async_register_ice_servers(hass: HomeAssistant) -> None:
"""Test registering ICE servers."""
assert await async_setup_component(hass, "web_rtc", {})
await hass.async_block_till_done()
default_servers = async_get_ice_servers(hass)
called = 0
@callback
def get_ice_servers() -> list[RTCIceServer]:
nonlocal called
called += 1
return [
RTCIceServer(urls="stun:example.com"),
RTCIceServer(urls="turn:example.com"),
]
unregister = async_register_ice_servers(hass, get_ice_servers)
assert called == 0
# Getting ice servers should call the callback
ice_servers = async_get_ice_servers(hass)
assert called == 1
assert ice_servers == [
*default_servers,
RTCIceServer(urls="stun:example.com"),
RTCIceServer(urls="turn:example.com"),
]
# Unregister and verify servers are removed
unregister()
ice_servers = async_get_ice_servers(hass)
assert ice_servers == default_servers
async def test_multiple_ice_server_registrations(hass: HomeAssistant) -> None:
"""Test registering multiple ICE server providers."""
assert await async_setup_component(hass, "web_rtc", {})
await hass.async_block_till_done()
default_servers = async_get_ice_servers(hass)
@callback
def get_ice_servers_1() -> list[RTCIceServer]:
return [RTCIceServer(urls="stun:server1.com")]
@callback
def get_ice_servers_2() -> list[RTCIceServer]:
return [
RTCIceServer(
urls=["stun:server2.com", "turn:server2.com"],
username="user",
credential="pass",
)
]
unregister_1 = async_register_ice_servers(hass, get_ice_servers_1)
unregister_2 = async_register_ice_servers(hass, get_ice_servers_2)
ice_servers = async_get_ice_servers(hass)
assert ice_servers == [
*default_servers,
RTCIceServer(urls="stun:server1.com"),
RTCIceServer(
urls=["stun:server2.com", "turn:server2.com"],
username="user",
credential="pass",
),
]
# Unregister first provider
unregister_1()
ice_servers = async_get_ice_servers(hass)
assert ice_servers == [
*default_servers,
RTCIceServer(
urls=["stun:server2.com", "turn:server2.com"],
username="user",
credential="pass",
),
]
# Unregister second provider
unregister_2()
ice_servers = async_get_ice_servers(hass)
assert ice_servers == default_servers
async def test_ws_ice_servers_with_registered_servers(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test WebSocket ICE servers endpoint with registered servers."""
assert await async_setup_component(hass, "web_rtc", {})
await hass.async_block_till_done()
@callback
def get_ice_server() -> list[RTCIceServer]:
return [
RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
)
]
async_register_ice_servers(hass, get_ice_server)
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "web_rtc/ice_servers"})
msg = await client.receive_json()
# Assert WebSocket response includes registered ICE servers
assert msg["type"] == "result"
assert msg["success"]
assert msg["result"] == [
{
"urls": [
"stun:stun.home-assistant.io:3478",
"stun:stun.home-assistant.io:80",
]
},
{
"urls": ["stun:example2.com", "turn:example2.com"],
"username": "user",
"credential": "pass",
},
]

View File

@@ -34,8 +34,8 @@ from homeassistant.core_config import (
DATA_CUSTOMIZE,
Config,
ConfigSource,
_validate_stun_or_turn_url,
async_process_ha_core_config,
validate_stun_or_turn_url,
)
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.entity import Entity, EntityPlatformState
@@ -175,8 +175,8 @@ def test_webrtc_schema() -> None:
assert validated["webrtc"] == validated_webrtc
def test_validate_stun_or_turn_url() -> None:
"""Test _validate_stun_or_turn_url."""
def testvalidate_stun_or_turn_url() -> None:
"""Test validate_stun_or_turn_url."""
invalid_urls = (
"custom_stun_server",
"custom_stun_server:3478",
@@ -203,10 +203,10 @@ def test_validate_stun_or_turn_url() -> None:
for url in invalid_urls:
with pytest.raises(Invalid):
_validate_stun_or_turn_url(url)
validate_stun_or_turn_url(url)
for url in valid_urls:
assert _validate_stun_or_turn_url(url) == url
assert validate_stun_or_turn_url(url) == url
def test_customize_glob_is_ordered() -> None: