252 lines
7.5 KiB
Python
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)
|