core/homeassistant/components/camera/webrtc.py

252 lines
7.5 KiB
Python

"""Helper for WebRTC support."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.util.hass_dict import HassKey
from .const import DATA_COMPONENT, DOMAIN, StreamType
from .helper import get_camera_from_entity_id
if TYPE_CHECKING:
from . import Camera
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
"camera_web_rtc_providers"
)
DATA_ICE_SERVERS: HassKey[list[Callable[[], Coroutine[Any, Any, RTCIceServer]]]] = (
HassKey("camera_web_rtc_ice_servers")
)
@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."""
async def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
async def async_handle_web_rtc_offer(
self, camera: Camera, offer_sdp: str
) -> str | None:
"""Handle the WebRTC offer and return an answer."""
def async_register_webrtc_provider(
hass: HomeAssistant,
provider: CameraWebRTCProvider,
) -> Callable[[], None]:
"""Register a WebRTC provider.
The first provider to satisfy the offer will be used.
"""
if DOMAIN not in hass.data:
raise ValueError("Unexpected state, camera not loaded")
providers: set[CameraWebRTCProvider] = hass.data.setdefault(
DATA_WEBRTC_PROVIDERS, set()
)
@callback
def remove_provider() -> None:
providers.remove(provider)
hass.async_create_task(_async_refresh_providers(hass))
if provider in providers:
raise ValueError("Provider already registered")
providers.add(provider)
hass.async_create_task(_async_refresh_providers(hass))
return remove_provider
async def _async_refresh_providers(hass: HomeAssistant) -> None:
"""Check all cameras for any state changes for registered providers."""
component = hass.data[DATA_COMPONENT]
await asyncio.gather(
*(camera.async_refresh_providers() for camera in component.entities)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/get_client_config",
vol.Required("entity_id"): cv.entity_id,
}
)
@websocket_api.async_response
async def ws_get_client_config(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle get WebRTC client config websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"web_rtc_offer_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
config = (await camera.async_get_webrtc_client_configuration()).to_frontend_dict()
connection.send_result(
msg["id"],
config,
)
async def async_get_supported_providers(
hass: HomeAssistant, camera: Camera
) -> list[CameraWebRTCProvider]:
"""Return a list of supported providers for the camera."""
providers = hass.data.get(DATA_WEBRTC_PROVIDERS)
if not providers or not (stream_source := await camera.stream_source()):
return []
return [
provider
for provider in providers
if await provider.async_is_supported(stream_source)
]
@callback
def register_ice_server(
hass: HomeAssistant,
get_ice_server_fn: Callable[[], Coroutine[Any, Any, 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
# The following code is legacy code that was introduced with rtsp_to_webrtc and will be deprecated/removed in the future.
# Left it so custom integrations can still use it.
_RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
# An RtspToWebRtcProvider accepts these inputs:
# stream_source: The RTSP url
# offer_sdp: The WebRTC SDP offer
# stream_id: A unique id for the stream, used to update an existing source
# The output is the SDP answer, or None if the source or offer is not eligible.
# The Callable may throw HomeAssistantError on failure.
type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]]
class _CameraRtspToWebRTCProvider(CameraWebRTCProvider):
def __init__(self, fn: RtspToWebRtcProviderType) -> None:
"""Initialize the RTSP to WebRTC provider."""
self._fn = fn
async def async_is_supported(self, stream_source: str) -> bool:
"""Return if this provider is supports the Camera as source."""
return any(stream_source.startswith(prefix) for prefix in _RTSP_PREFIXES)
async def async_handle_web_rtc_offer(
self, camera: Camera, offer_sdp: str
) -> str | None:
"""Handle the WebRTC offer and return an answer."""
if not (stream_source := await camera.stream_source()):
return None
return await self._fn(stream_source, offer_sdp, camera.entity_id)
def async_register_rtsp_to_web_rtc_provider(
hass: HomeAssistant,
domain: str,
provider: RtspToWebRtcProviderType,
) -> Callable[[], None]:
"""Register an RTSP to WebRTC provider.
The first provider to satisfy the offer will be used.
"""
provider_instance = _CameraRtspToWebRTCProvider(provider)
return async_register_webrtc_provider(hass, provider_instance)