Add go2rtc and extend camera integration for better WebRTC support (#124410)

This commit is contained in:
Robert Resch 2024-10-03 09:20:03 +02:00 committed by GitHub
parent a0a90f03a8
commit 04860ae1d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1476 additions and 225 deletions

View File

@ -544,6 +544,8 @@ build.json @home-assistant/supervisor
/tests/components/github/ @timmo001 @ludeeus
/homeassistant/components/glances/ @engrbm87
/tests/components/glances/ @engrbm87
/homeassistant/components/go2rtc/ @home-assistant/core
/tests/components/go2rtc/ @home-assistant/core
/homeassistant/components/goalzero/ @tkdrob
/tests/components/goalzero/ @tkdrob
/homeassistant/components/gogogate2/ @vangorra

View File

@ -44,4 +44,19 @@ RUN \
# Home Assistant S6-Overlay
COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.4/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
WORKDIR /config

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import collections
from collections.abc import Awaitable, Callable, Iterable
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import asdict
from datetime import datetime, timedelta
@ -14,7 +14,7 @@ import logging
import os
from random import SystemRandom
import time
from typing import Any, Final, cast, final
from typing import Any, Final, final
from aiohttp import hdrs, web
import attr
@ -72,7 +72,6 @@ from .const import ( # noqa: F401
CONF_LOOKBACK,
DATA_CAMERA_PREFS,
DATA_COMPONENT,
DATA_RTSP_TO_WEB_RTC,
DOMAIN,
PREF_ORIENTATION,
PREF_PRELOAD_STREAM,
@ -80,11 +79,23 @@ from .const import ( # noqa: F401
CameraState,
StreamType,
)
from .helper import get_camera_from_entity_id
from .img_util import scale_jpeg_camera_image
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
from .webrtc import (
DATA_ICE_SERVERS,
CameraWebRTCProvider,
RTCIceServer,
WebRTCClientConfiguration,
async_get_supported_providers,
async_register_rtsp_to_web_rtc_provider, # noqa: F401
register_ice_server,
ws_get_client_config,
)
_LOGGER = logging.getLogger(__name__)
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
@ -122,7 +133,6 @@ _DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum(
CameraEntityFeature.STREAM, "2025.1"
)
RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}"
@ -161,7 +171,7 @@ class Image:
@bind_hass
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
"""Request a stream for a camera entity."""
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
return await _async_stream_endpoint_url(hass, camera, fmt)
@ -219,7 +229,7 @@ async def async_get_image(
width and height will be passed to the underlying camera.
"""
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
return await _async_get_image(camera, timeout, width, height)
@ -241,7 +251,7 @@ async def _async_get_stream_image(
@bind_hass
async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None:
"""Fetch the stream source for a camera entity."""
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
return await camera.stream_source()
@ -250,7 +260,7 @@ async def async_get_mjpeg_stream(
hass: HomeAssistant, request: web.Request, entity_id: str
) -> web.StreamResponse | None:
"""Fetch an mjpeg stream from a camera entity."""
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
try:
stream = await camera.handle_async_mjpeg_stream(request)
@ -317,69 +327,6 @@ async def async_get_still_stream(
return response
def _get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera:
"""Get camera component from entity_id."""
if (component := hass.data.get(DOMAIN)) is None:
raise HomeAssistantError("Camera integration not set up")
if (camera := component.get_entity(entity_id)) is None:
raise HomeAssistantError("Camera not found")
if not camera.is_on:
raise HomeAssistantError("Camera is off")
return cast(Camera, camera)
# 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]]
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.
"""
if DOMAIN not in hass.data:
raise ValueError("Unexpected state, camera not loaded")
def remove_provider() -> None:
if domain in hass.data[DATA_RTSP_TO_WEB_RTC]:
del hass.data[DATA_RTSP_TO_WEB_RTC]
hass.async_create_task(_async_refresh_providers(hass))
hass.data.setdefault(DATA_RTSP_TO_WEB_RTC, {})
hass.data[DATA_RTSP_TO_WEB_RTC][domain] = 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)
)
def _async_get_rtsp_to_web_rtc_providers(
hass: HomeAssistant,
) -> Iterable[RtspToWebRtcProviderType]:
"""Return registered RTSP to WebRTC providers."""
providers = hass.data.get(DATA_RTSP_TO_WEB_RTC, {})
return providers.values()
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the camera component."""
component = hass.data[DATA_COMPONENT] = EntityComponent[Camera](
@ -397,6 +344,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
websocket_api.async_register_command(hass, ws_camera_web_rtc_offer)
websocket_api.async_register_command(hass, websocket_get_prefs)
websocket_api.async_register_command(hass, websocket_update_prefs)
websocket_api.async_register_command(hass, ws_get_client_config)
await component.async_setup(config)
@ -452,6 +400,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service
)
async def get_ice_server() -> RTCIceServer:
# The following servers will replaced before the next stable release with
# STUN server provided by Home Assistant. Used Google ones for testing purposes.
return RTCIceServer(urls="stun:stun.l.google.com:19302")
register_ice_server(hass, get_ice_server)
return True
@ -507,7 +461,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self._warned_old_signature = False
self.async_update_token()
self._create_stream_lock: asyncio.Lock | None = None
self._rtsp_to_webrtc = False
self._webrtc_providers: list[CameraWebRTCProvider] = []
@cached_property
def entity_picture(self) -> str:
@ -581,7 +535,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return self._attr_frontend_stream_type
if CameraEntityFeature.STREAM not in self.supported_features_compat:
return None
if self._rtsp_to_webrtc:
if self._webrtc_providers:
return StreamType.WEB_RTC
return StreamType.HLS
@ -631,14 +585,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Integrations can override with a native WebRTC implementation.
"""
stream_source = await self.stream_source()
if not stream_source:
return None
for provider in _async_get_rtsp_to_web_rtc_providers(self.hass):
answer_sdp = await provider(stream_source, offer_sdp, self.entity_id)
if answer_sdp:
return answer_sdp
raise HomeAssistantError("WebRTC offer was not accepted by any providers")
for provider in self._webrtc_providers:
if answer := await provider.async_handle_web_rtc_offer(self, offer_sdp):
return answer
raise HomeAssistantError(
"WebRTC offer was not accepted by the supported providers"
)
def camera_image(
self, width: int | None = None, height: int | None = None
@ -751,7 +703,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# Avoid calling async_refresh_providers() in here because it
# it will write state a second time since state is always
# written when an entity is added to hass.
self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc()
self._webrtc_providers = await self._async_get_supported_webrtc_providers()
async def async_refresh_providers(self) -> None:
"""Determine if any of the registered providers are suitable for this entity.
@ -761,22 +713,41 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Returns True if any state was updated (and needs to be written)
"""
old_state = self._rtsp_to_webrtc
self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc()
if old_state != self._rtsp_to_webrtc:
old_providers = self._webrtc_providers
new_providers = await self._async_get_supported_webrtc_providers()
self._webrtc_providers = new_providers
if old_providers != new_providers:
self.async_write_ha_state()
async def _async_use_rtsp_to_webrtc(self) -> bool:
"""Determine if a WebRTC provider can be used for the camera."""
async def _async_get_supported_webrtc_providers(
self,
) -> list[CameraWebRTCProvider]:
"""Get the all providers that supports this camera."""
if CameraEntityFeature.STREAM not in self.supported_features_compat:
return False
if DATA_RTSP_TO_WEB_RTC not in self.hass.data:
return False
stream_source = await self.stream_source()
return any(
stream_source and stream_source.startswith(prefix)
for prefix in RTSP_PREFIXES
return []
return await async_get_supported_providers(self.hass, self)
@property
def webrtc_providers(self) -> list[CameraWebRTCProvider]:
"""Return the WebRTC providers."""
return self._webrtc_providers
async def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
"""Return the WebRTC client configuration adjustable per integration."""
return WebRTCClientConfiguration()
@final
async def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
config = await self._async_get_webrtc_client_configuration()
ice_servers = await asyncio.gather(
*[server() for server in self.hass.data.get(DATA_ICE_SERVERS, [])]
)
config.configuration.ice_servers.extend(ice_servers)
return config
class CameraView(HomeAssistantView):
@ -885,7 +856,7 @@ async def ws_camera_stream(
"""
try:
entity_id = msg["entity_id"]
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
url = await _async_stream_endpoint_url(hass, camera, fmt=msg["format"])
connection.send_result(msg["id"], {"url": url})
except HomeAssistantError as ex:
@ -920,7 +891,7 @@ async def ws_camera_web_rtc_offer(
"""
entity_id = msg["entity_id"]
offer = msg["offer"]
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],

View File

@ -17,16 +17,13 @@ from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from homeassistant.helpers.entity_component import EntityComponent
from . import Camera, RtspToWebRtcProviderType
from . import Camera
from .prefs import CameraPreferences
DOMAIN: Final = "camera"
DATA_COMPONENT: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN)
DATA_CAMERA_PREFS: HassKey[CameraPreferences] = HassKey("camera_prefs")
DATA_RTSP_TO_WEB_RTC: HassKey[dict[str, RtspToWebRtcProviderType]] = HassKey(
"rtsp_to_web_rtc"
)
PREF_PRELOAD_STREAM: Final = "preload_stream"
PREF_ORIENTATION: Final = "orientation"

View File

@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import _get_camera_from_entity_id
from .const import DOMAIN
from .helper import get_camera_from_entity_id
async def async_get_config_entry_diagnostics(
@ -22,7 +22,7 @@ async def async_get_config_entry_diagnostics(
if entity.domain != DOMAIN:
continue
try:
camera = _get_camera_from_entity_id(hass, entity.entity_id)
camera = get_camera_from_entity_id(hass, entity.entity_id)
except HomeAssistantError:
continue
diagnostics[entity.entity_id] = (

View File

@ -0,0 +1,28 @@
"""Camera helper functions."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import DATA_COMPONENT
if TYPE_CHECKING:
from . import Camera
def get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera:
"""Get camera component from entity_id."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError("Camera integration not set up")
if (camera := component.get_entity(entity_id)) is None:
raise HomeAssistantError("Camera not found")
if not camera.is_on:
raise HomeAssistantError("Camera is off")
return camera

View File

@ -0,0 +1,239 @@
"""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
from mashumaro import field_options
from mashumaro.config import BaseConfig
from mashumaro.mixins.dict import DataClassDictMixin
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")
)
class _RTCBaseModel(DataClassDictMixin):
"""Base class for RTC models."""
class Config(BaseConfig):
"""Mashumaro config."""
# Serialize to spec conform names and omit default values
omit_default = True
serialize_by_alias = True
@dataclass
class RTCIceServer(_RTCBaseModel):
"""RTC Ice Server.
See https://www.w3.org/TR/webrtc/#rtciceserver-dictionary
"""
urls: list[str] | str
username: str | None = None
credential: str | None = None
@dataclass
class RTCConfiguration(_RTCBaseModel):
"""RTC Configuration.
See https://www.w3.org/TR/webrtc/#rtcconfiguration-dictionary
"""
ice_servers: list[RTCIceServer] = field(
metadata=field_options(alias="iceServers"), default_factory=list
)
@dataclass(kw_only=True)
class WebRTCClientConfiguration(_RTCBaseModel):
"""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 = field(
metadata=field_options(alias="dataChannel"), default=None
)
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_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)

View File

@ -0,0 +1,91 @@
"""The go2rtc component."""
from go2rtc_client import Go2RtcClient, WebRTCSdpOffer
from homeassistant.components.camera import Camera
from homeassistant.components.camera.webrtc import (
CameraWebRTCProvider,
async_register_webrtc_provider,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_BINARY
from .server import Server
_SUPPORTED_STREAMS = (
"bubble",
"dvrip",
"expr",
"ffmpeg",
"gopro",
"homekit",
"http",
"https",
"httpx",
"isapi",
"ivideon",
"kasa",
"nest",
"onvif",
"roborock",
"rtmp",
"rtmps",
"rtmpx",
"rtsp",
"rtsps",
"rtspx",
"tapo",
"tcp",
"webrtc",
"webtorrent",
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up WebRTC from a config entry."""
if binary := entry.data.get(CONF_BINARY):
# HA will manage the binary
server = Server(binary)
entry.async_on_unload(server.stop)
server.start()
client = Go2RtcClient(async_get_clientsession(hass), entry.data[CONF_HOST])
provider = WebRTCProvider(client)
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
return True
class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider."""
def __init__(self, client: Go2RtcClient) -> None:
"""Initialize the WebRTC provider."""
self._client = client
async def async_is_supported(self, stream_source: str) -> bool:
"""Return if this provider is supports the Camera as source."""
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS
async def async_handle_web_rtc_offer(
self, camera: Camera, offer_sdp: str
) -> str | None:
"""Handle the WebRTC offer and return an answer."""
streams = await self._client.streams.list()
if camera.entity_id not in streams:
if not (stream_source := await camera.stream_source()):
return None
await self._client.streams.add(camera.entity_id, stream_source)
answer = await self._client.webrtc.forward_whep_sdp_offer(
camera.entity_id, WebRTCSdpOffer(offer_sdp)
)
return answer.sdp
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True

View File

@ -0,0 +1,90 @@
"""Config flow for WebRTC."""
from __future__ import annotations
import shutil
from typing import Any
from urllib.parse import urlparse
from go2rtc_client import Go2RtcClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.package import is_docker_env
from .const import CONF_BINARY, DOMAIN
_VALID_URL_SCHEMA = {"http", "https"}
async def _validate_url(
hass: HomeAssistant,
value: str,
) -> str | None:
"""Validate the URL and return error or None if it's valid."""
if urlparse(value).scheme not in _VALID_URL_SCHEMA:
return "invalid_url_schema"
try:
vol.Schema(vol.Url())(value)
except vol.Invalid:
return "invalid_url"
try:
client = Go2RtcClient(async_get_clientsession(hass), value)
await client.streams.list()
except Exception: # noqa: BLE001
return "cannot_connect"
return None
class Go2RTCConfigFlow(ConfigFlow, domain=DOMAIN):
"""go2rtc config flow."""
def _get_binary(self) -> str | None:
"""Return the binary path if found."""
return shutil.which(DOMAIN)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Init step."""
if is_docker_env() and (binary := self._get_binary()):
return self.async_create_entry(
title=DOMAIN,
data={CONF_BINARY: binary, CONF_HOST: "http://localhost:1984/"},
)
return await self.async_step_host()
async def async_step_host(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step to use selfhosted go2rtc server."""
errors = {}
if user_input is not None:
if error := await _validate_url(self.hass, user_input[CONF_HOST]):
errors[CONF_HOST] = error
else:
return self.async_create_entry(title=DOMAIN, data=user_input)
return self.async_show_form(
step_id="host",
data_schema=self.add_suggested_values_to_schema(
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.URL
)
),
}
),
suggested_values=user_input,
),
errors=errors,
last_step=True,
)

View File

@ -0,0 +1,5 @@
"""Go2rtc constants."""
DOMAIN = "go2rtc"
CONF_BINARY = "binary"

View File

@ -0,0 +1,11 @@
{
"domain": "go2rtc",
"name": "go2rtc",
"codeowners": ["@home-assistant/core"],
"config_flow": true,
"dependencies": ["camera"],
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
"iot_class": "local_polling",
"requirements": ["go2rtc-client==0.0.1b0"],
"single_config_entry": true
}

View File

@ -0,0 +1,56 @@
"""Go2rtc server."""
from __future__ import annotations
import logging
import subprocess
from tempfile import NamedTemporaryFile
from threading import Thread
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class Server(Thread):
"""Server thread."""
def __init__(self, binary: str) -> None:
"""Initialize the server."""
super().__init__(name=DOMAIN, daemon=True)
self._binary = binary
self._stop_requested = False
def run(self) -> None:
"""Run the server."""
_LOGGER.debug("Starting go2rtc server")
self._stop_requested = False
with (
NamedTemporaryFile(prefix="go2rtc", suffix=".yaml") as file,
subprocess.Popen(
[self._binary, "-c", "webrtc.ice_servers=[]", "-c", file.name],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
) as process,
):
while not self._stop_requested and process.poll() is None:
assert process.stdout
line = process.stdout.readline()
if line == b"":
break
_LOGGER.debug(line[:-1].decode())
_LOGGER.debug("Terminating go2rtc server")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
_LOGGER.warning("Go2rtc server didn't terminate gracefully.Killing it")
process.kill()
_LOGGER.debug("Go2rtc server has been stopped")
def stop(self) -> None:
"""Stop the server."""
self._stop_requested = True
if self.is_alive():
self.join()

View File

@ -0,0 +1,19 @@
{
"config": {
"step": {
"host": {
"data": {
"host": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"host": "The URL of your go2rtc instance."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_url": "Invalid URL",
"invalid_url_schema": "Invalid URL scheme.\nThe URL should start with `http://` or `https://`."
}
}
}

View File

@ -21,6 +21,7 @@ from google_nest_sdm.device_manager import DeviceManager
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
@ -210,3 +211,7 @@ class NestCamera(Camera):
except ApiException as err:
raise HomeAssistantError(f"Nest API error: {err}") from err
return stream.answer_sdp
async def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
"""Return the WebRTC client configuration adjustable per integration."""
return WebRTCClientConfiguration(data_channel="dataSendChannel")

View File

@ -12,7 +12,7 @@ the offer/answer SDP protocol, other than as a signal path pass through.
Other integrations may use this integration with these steps:
- Check if this integration is loaded
- Call is_suported_stream_source for compatibility
- Call is_supported_stream_source for compatibility
- Call async_offer_for_stream_source to get back an answer for a client offer
"""
@ -20,16 +20,15 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
from rtsp_to_webrtc.client import get_adaptive_client
from rtsp_to_webrtc.exceptions import ClientError, ResponseError
from rtsp_to_webrtc.interface import WebRTCClientInterface
import voluptuous as vol
from homeassistant.components import camera, websocket_api
from homeassistant.components import camera
from homeassistant.components.camera.webrtc import RTCIceServer, register_ice_server
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -57,7 +56,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except (TimeoutError, ClientError) as err:
raise ConfigEntryNotReady from err
hass.data[DOMAIN][CONF_STUN_SERVER] = entry.options.get(CONF_STUN_SERVER, "")
hass.data[DOMAIN][CONF_STUN_SERVER] = entry.options.get(CONF_STUN_SERVER)
if server := entry.options.get(CONF_STUN_SERVER):
async def get_server() -> RTCIceServer:
return RTCIceServer(urls=[server])
entry.async_on_unload(register_ice_server(hass, get_server))
async def async_offer_for_stream_source(
stream_source: str,
@ -85,8 +90,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
websocket_api.async_register_command(hass, ws_get_settings)
return True
@ -99,21 +102,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry when options change."""
if hass.data[DOMAIN][CONF_STUN_SERVER] != entry.options.get(CONF_STUN_SERVER, ""):
if hass.data[DOMAIN][CONF_STUN_SERVER] != entry.options.get(CONF_STUN_SERVER):
await hass.config_entries.async_reload(entry.entry_id)
@websocket_api.websocket_command(
{
vol.Required("type"): "rtsp_to_webrtc/get_settings",
}
)
@callback
def ws_get_settings(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle the websocket command."""
connection.send_result(
msg["id"],
{CONF_STUN_SERVER: hass.data.get(DOMAIN, {}).get(CONF_STUN_SERVER, "")},
)

View File

@ -221,6 +221,7 @@ FLOWS = {
"gios",
"github",
"glances",
"go2rtc",
"goalzero",
"gogogate2",
"goodwe",

View File

@ -2247,6 +2247,13 @@
}
}
},
"go2rtc": {
"name": "go2rtc",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"single_config_entry": true
},
"goalzero": {
"name": "Goal Zero Yeti",
"integration_type": "device",

View File

@ -38,6 +38,7 @@ httpx==0.27.2
ifaddr==0.2.0
Jinja2==3.1.4
lru-dict==1.3.0
mashumaro==3.13.1
mutagen==1.47.0
orjson==3.10.7
packaging>=23.1
@ -121,9 +122,6 @@ backoff>=2.0
# v2 has breaking changes (#99218).
pydantic==1.10.18
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
# Breaks asyncio
# https://github.com/pubnub/python/issues/130
pubnub!=6.4.0

View File

@ -50,6 +50,7 @@ dependencies = [
"ifaddr==0.2.0",
"Jinja2==3.1.4",
"lru-dict==1.3.0",
"mashumaro==3.13.1",
"PyJWT==2.9.0",
# PyJWT has loose dependency. We want the latest one.
"cryptography==43.0.1",

View File

@ -24,6 +24,7 @@ home-assistant-bluetooth==1.12.2
ifaddr==0.2.0
Jinja2==3.1.4
lru-dict==1.3.0
mashumaro==3.13.1
PyJWT==2.9.0
cryptography==43.0.1
Pillow==10.4.0

View File

@ -981,6 +981,9 @@ gitterpy==0.1.7
# homeassistant.components.glances
glances-api==0.8.0
# homeassistant.components.go2rtc
go2rtc-client==0.0.1b0
# homeassistant.components.goalzero
goalzero==0.2.2

View File

@ -831,6 +831,9 @@ gios==4.0.0
# homeassistant.components.glances
glances-api==0.8.0
# homeassistant.components.go2rtc
go2rtc-client==0.0.1b0
# homeassistant.components.goalzero
goalzero==0.2.2

View File

@ -140,9 +140,6 @@ backoff>=2.0
# v2 has breaking changes (#99218).
pydantic==1.10.18
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
# Breaks asyncio
# https://github.com/pubnub/python/issues/130
pubnub!=6.4.0

View File

@ -57,6 +57,21 @@ RUN \
# Home Assistant S6-Overlay
COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${{BUILD_ARCH}}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${{BUILD_ARCH}} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
WORKDIR /config
"""
@ -96,6 +111,8 @@ LABEL "com.github.actions.icon"="terminal"
LABEL "com.github.actions.color"="gray-dark"
"""
_GO2RTC_VERSION = "1.9.4"
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
package_versions: dict[str, str] = {}
@ -176,7 +193,11 @@ def _generate_files(config: Config) -> list[File]:
return [
File(
DOCKERFILE_TEMPLATE.format(timeout=timeout, **package_versions),
DOCKERFILE_TEMPLATE.format(
timeout=timeout,
**package_versions,
go2rtc=_GO2RTC_VERSION,
),
config.root / "Dockerfile",
),
_generate_hassfest_dockerimage(config, timeout, package_versions),

View File

@ -59,7 +59,7 @@ async def test_camera(
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
entity_id = f"{CAMERA_DOMAIN}.{NAME}"
camera_entity = camera._get_camera_from_entity_id(hass, entity_id)
camera_entity = camera.helper.get_camera_from_entity_id(hass, entity_id)
assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi"
assert (
camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi"

View File

@ -8,6 +8,7 @@ from unittest.mock import Mock
EMPTY_8_6_JPEG = b"empty_8_6"
WEBRTC_ANSWER = "a=sendonly"
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
def mock_turbo_jpeg(

View File

@ -1,7 +1,7 @@
"""Test helpers for camera."""
from collections.abc import AsyncGenerator, Generator
from unittest.mock import PropertyMock, patch
from unittest.mock import AsyncMock, PropertyMock, patch
import pytest
@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.setup import async_setup_component
from .common import WEBRTC_ANSWER
from .common import STREAM_SOURCE, WEBRTC_ANSWER
@pytest.fixture(autouse=True)
@ -111,3 +111,19 @@ def mock_camera_with_no_name_fixture(mock_camera_with_device: None) -> Generator
new_callable=PropertyMock(return_value=None),
):
yield
@pytest.fixture(name="mock_stream")
async def mock_stream_fixture(hass: HomeAssistant) -> None:
"""Initialize a demo camera platform with streaming."""
assert await async_setup_component(hass, "stream", {"stream": {}})
@pytest.fixture(name="mock_stream_source")
def mock_stream_source_fixture() -> Generator[AsyncMock]:
"""Fixture to create an RTSP stream source."""
with patch(
"homeassistant.components.camera.Camera.stream_source",
return_value=STREAM_SOURCE,
) as mock_stream_source:
yield mock_stream_source

View File

@ -27,7 +27,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_turbo_jpeg
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, WEBRTC_ANSWER, mock_turbo_jpeg
from tests.common import (
async_fire_time_changed,
@ -36,19 +36,10 @@ from tests.common import (
)
from tests.typing import ClientSessionGenerator, WebSocketGenerator
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
WEBRTC_OFFER = "v=0\r\n"
@pytest.fixture(name="mock_stream")
def mock_stream_fixture(hass: HomeAssistant) -> None:
"""Initialize a demo camera platform with streaming."""
assert hass.loop.run_until_complete(
async_setup_component(hass, "stream", {"stream": {}})
)
@pytest.fixture(name="image_mock_url")
async def image_mock_url_fixture(hass: HomeAssistant) -> None:
"""Fixture for get_image tests."""
@ -58,16 +49,6 @@ async def image_mock_url_fixture(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
@pytest.fixture(name="mock_stream_source")
def mock_stream_source_fixture() -> Generator[AsyncMock]:
"""Fixture to create an RTSP stream source."""
with patch(
"homeassistant.components.camera.Camera.stream_source",
return_value=STREAM_SOURCE,
) as mock_stream_source:
yield mock_stream_source
@pytest.fixture(name="mock_hls_stream_source")
async def mock_hls_stream_source_fixture() -> Generator[AsyncMock]:
"""Fixture to create an HLS stream source."""

View File

@ -0,0 +1,236 @@
"""Test camera WebRTC."""
import pytest
from homeassistant.components.camera import Camera
from homeassistant.components.camera.const import StreamType
from homeassistant.components.camera.helper import get_camera_from_entity_id
from homeassistant.components.camera.webrtc import (
DATA_ICE_SERVERS,
CameraWebRTCProvider,
RTCIceServer,
async_register_webrtc_provider,
register_ice_server,
)
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.typing import WebSocketGenerator
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
async def test_async_register_webrtc_provider(
hass: HomeAssistant,
) -> None:
"""Test registering a WebRTC provider."""
await async_setup_component(hass, "camera", {})
camera = get_camera_from_entity_id(hass, "camera.demo_camera")
assert camera.frontend_stream_type is StreamType.HLS
stream_supported = True
class TestProvider(CameraWebRTCProvider):
"""Test provider."""
async def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
nonlocal stream_supported
return stream_supported
async def async_handle_web_rtc_offer(
self, camera: Camera, offer_sdp: str
) -> str | None:
"""Handle the WebRTC offer and return an answer."""
return "answer"
unregister = async_register_webrtc_provider(hass, TestProvider())
await hass.async_block_till_done()
assert camera.frontend_stream_type is StreamType.WEB_RTC
# Mark stream as unsupported
stream_supported = False
# Manually refresh the provider
await camera.async_refresh_providers()
assert camera.frontend_stream_type is StreamType.HLS
# Mark stream as unsupported
stream_supported = True
# Manually refresh the provider
await camera.async_refresh_providers()
assert camera.frontend_stream_type is StreamType.WEB_RTC
unregister()
await hass.async_block_till_done()
assert camera.frontend_stream_type is StreamType.HLS
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
async def test_async_register_webrtc_provider_twice(
hass: HomeAssistant,
) -> None:
"""Test registering a WebRTC provider twice should raise."""
await async_setup_component(hass, "camera", {})
class TestProvider(CameraWebRTCProvider):
"""Test provider."""
async def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
return True
async def async_handle_web_rtc_offer(
self, camera: Camera, offer_sdp: str
) -> str | None:
"""Handle the WebRTC offer and return an answer."""
return "answer"
provider = TestProvider()
async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
with pytest.raises(ValueError, match="Provider already registered"):
async_register_webrtc_provider(hass, provider)
async def test_async_register_webrtc_provider_camera_not_loaded(
hass: HomeAssistant,
) -> None:
"""Test registering a WebRTC provider when camera is not loaded."""
class TestProvider(CameraWebRTCProvider):
"""Test provider."""
async def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
return True
async def async_handle_web_rtc_offer(
self, camera: Camera, offer_sdp: str
) -> str | None:
"""Handle the WebRTC offer and return an answer."""
return "answer"
with pytest.raises(ValueError, match="Unexpected state, camera not loaded"):
async_register_webrtc_provider(hass, TestProvider())
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
async def test_async_register_ice_server(
hass: HomeAssistant,
) -> None:
"""Test registering an ICE server."""
await async_setup_component(hass, "camera", {})
# Clear any existing ICE servers
hass.data[DATA_ICE_SERVERS].clear()
called = 0
async def get_ice_server() -> RTCIceServer:
nonlocal called
called += 1
return RTCIceServer(urls="stun:example.com")
unregister = register_ice_server(hass, get_ice_server)
assert not called
camera = get_camera_from_entity_id(hass, "camera.demo_camera")
config = await camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [RTCIceServer(urls="stun:example.com")]
assert called == 1
# register another ICE server
called_2 = 0
async def get_ice_server_2() -> RTCIceServer:
nonlocal called_2
called_2 += 1
return RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
)
unregister_2 = register_ice_server(hass, get_ice_server_2)
config = await camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [
RTCIceServer(urls="stun: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 = await 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 = await camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == []
@pytest.mark.usefixtures("mock_camera_web_rtc")
async def test_ws_get_client_config(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test get WebRTC client config."""
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:stun.l.google.com:19302"}]}
}
@pytest.mark.usefixtures("mock_camera_hls")
async def test_ws_get_client_config_no_rtc_camera(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test get WebRTC client config."""
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 not msg["success"]
assert msg["error"] == {
"code": "web_rtc_offer_failed",
"message": "Camera does not support WebRTC, frontend_stream_type=hls",
}

View File

@ -0,0 +1,13 @@
"""Go2rtc tests."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,57 @@
"""Go2rtc test configuration."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
from go2rtc_client.client import _StreamClient, _WebRTCClient
import pytest
from homeassistant.components.go2rtc.const import CONF_BINARY, DOMAIN
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.go2rtc.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_client() -> Generator[AsyncMock]:
"""Mock a go2rtc client."""
with (
patch(
"homeassistant.components.go2rtc.Go2RtcClient",
) as mock_client,
patch(
"homeassistant.components.go2rtc.config_flow.Go2RtcClient",
new=mock_client,
),
):
client = mock_client.return_value
client.streams = Mock(spec_set=_StreamClient)
client.webrtc = Mock(spec_set=_WebRTCClient)
yield client
@pytest.fixture
def mock_server() -> Generator[Mock]:
"""Mock a go2rtc server."""
with patch("homeassistant.components.go2rtc.Server", autoSpec=True) as mock_server:
yield mock_server
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title=DOMAIN,
data={CONF_HOST: "http://localhost:1984/", CONF_BINARY: "/usr/bin/go2rtc"},
)

View File

@ -0,0 +1,156 @@
"""Tests for the Go2rtc config flow."""
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.go2rtc.const import CONF_BINARY, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_client", "mock_setup_entry")
async def test_single_instance_allowed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that flow will abort if already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "single_instance_allowed"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_docker_with_binary(
hass: HomeAssistant,
) -> None:
"""Test config flow, where HA is running in docker with a go2rtc binary available."""
binary = "/usr/bin/go2rtc"
with (
patch(
"homeassistant.components.go2rtc.config_flow.is_docker_env",
return_value=True,
),
patch(
"homeassistant.components.go2rtc.config_flow.shutil.which",
return_value=binary,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "go2rtc"
assert result["data"] == {
CONF_BINARY: binary,
CONF_HOST: "http://localhost:1984/",
}
@pytest.mark.usefixtures("mock_setup_entry", "mock_client")
@pytest.mark.parametrize(
("is_docker_env", "shutil_which"),
[
(True, None),
(False, None),
(False, "/usr/bin/go2rtc"),
],
)
async def test_config_flow_host(
hass: HomeAssistant,
is_docker_env: bool,
shutil_which: str | None,
) -> None:
"""Test config flow with host input."""
with (
patch(
"homeassistant.components.go2rtc.config_flow.is_docker_env",
return_value=is_docker_env,
),
patch(
"homeassistant.components.go2rtc.config_flow.shutil.which",
return_value=shutil_which,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "host"
host = "http://go2rtc.local:1984/"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: host},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "go2rtc"
assert result["data"] == {
CONF_HOST: host,
}
@pytest.mark.usefixtures("mock_setup_entry")
async def test_flow_errors(
hass: HomeAssistant,
mock_client: Mock,
) -> None:
"""Test flow errors."""
with (
patch(
"homeassistant.components.go2rtc.config_flow.is_docker_env",
return_value=False,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "host"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "go2rtc.local:1984/"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"host": "invalid_url_schema"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "http://"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"host": "invalid_url"}
host = "http://go2rtc.local:1984/"
mock_client.streams.list.side_effect = Exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: host},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"host": "cannot_connect"}
mock_client.streams.list.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: host},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "go2rtc"
assert result["data"] == {
CONF_HOST: host,
}

View File

@ -0,0 +1,219 @@
"""The tests for the go2rtc component."""
from collections.abc import Callable
from unittest.mock import AsyncMock, Mock
from go2rtc_client import Stream, WebRTCSdpAnswer, WebRTCSdpOffer
from go2rtc_client.models import Producer
import pytest
from homeassistant.components.camera import (
DOMAIN as CAMERA_DOMAIN,
Camera,
CameraEntityFeature,
)
from homeassistant.components.camera.const import StreamType
from homeassistant.components.camera.helper import get_camera_from_entity_id
from homeassistant.components.go2rtc import WebRTCProvider
from homeassistant.components.go2rtc.const import DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import setup_integration
from tests.common import (
MockConfigEntry,
MockModule,
mock_config_flow,
mock_integration,
mock_platform,
setup_test_component_platform,
)
TEST_DOMAIN = "test"
# The go2rtc provider does not inspect the details of the offer and answer,
# and is only a pass through.
OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..."
ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..."
class MockCamera(Camera):
"""Mock Camera Entity."""
_attr_name = "Test"
_attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM
def __init__(self) -> None:
"""Initialize the mock entity."""
super().__init__()
self._stream_source: str | None = "rtsp://stream"
def set_stream_source(self, stream_source: str | None) -> None:
"""Set the stream source."""
self._stream_source = stream_source
async def stream_source(self) -> str | None:
"""Return the source of the stream.
This is used by cameras with CameraEntityFeature.STREAM
and StreamType.HLS.
"""
return self._stream_source
@pytest.fixture
def integration_entity() -> MockCamera:
"""Mock Camera Entity."""
return MockCamera()
@pytest.fixture
def integration_config_entry(hass: HomeAssistant) -> ConfigEntry:
"""Test mock config entry."""
entry = MockConfigEntry(domain=TEST_DOMAIN)
entry.add_to_hass(hass)
return entry
@pytest.fixture
async def init_test_integration(
hass: HomeAssistant,
integration_config_entry: ConfigEntry,
integration_entity: MockCamera,
) -> None:
"""Initialize components."""
async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Set up test config entry."""
await hass.config_entries.async_forward_entry_setups(
config_entry, [CAMERA_DOMAIN]
)
return True
async def async_unload_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Unload test config entry."""
await hass.config_entries.async_forward_entry_unload(
config_entry, CAMERA_DOMAIN
)
return True
mock_integration(
hass,
MockModule(
TEST_DOMAIN,
async_setup_entry=async_setup_entry_init,
async_unload_entry=async_unload_entry_init,
),
)
setup_test_component_platform(
hass, CAMERA_DOMAIN, [integration_entity], from_config_entry=True
)
mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock())
with mock_config_flow(TEST_DOMAIN, ConfigFlow):
assert await hass.config_entries.async_setup(integration_config_entry.entry_id)
await hass.async_block_till_done()
return integration_config_entry
@pytest.mark.usefixtures("init_test_integration")
async def _test_setup(
hass: HomeAssistant,
mock_client: AsyncMock,
mock_config_entry: MockConfigEntry,
after_setup_fn: Callable[[], None],
) -> None:
"""Test the go2rtc config entry."""
entity_id = "camera.test"
camera = get_camera_from_entity_id(hass, entity_id)
assert camera.frontend_stream_type == StreamType.HLS
await setup_integration(hass, mock_config_entry)
after_setup_fn()
mock_client.webrtc.forward_whep_sdp_offer.return_value = WebRTCSdpAnswer(ANSWER_SDP)
answer = await camera.async_handle_web_rtc_offer(OFFER_SDP)
assert answer == ANSWER_SDP
mock_client.webrtc.forward_whep_sdp_offer.assert_called_once_with(
entity_id, WebRTCSdpOffer(OFFER_SDP)
)
mock_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream")
# If the stream is already added, the stream should not be added again.
mock_client.streams.add.reset_mock()
mock_client.streams.list.return_value = {
entity_id: Stream([Producer("rtsp://stream")])
}
answer = await camera.async_handle_web_rtc_offer(OFFER_SDP)
assert answer == ANSWER_SDP
mock_client.streams.add.assert_not_called()
assert mock_client.webrtc.forward_whep_sdp_offer.call_count == 2
assert isinstance(camera._webrtc_providers[0], WebRTCProvider)
# Set stream source to None and provider should be skipped
mock_client.streams.list.return_value = {}
camera.set_stream_source(None)
with pytest.raises(
HomeAssistantError,
match="WebRTC offer was not accepted by the supported providers",
):
await camera.async_handle_web_rtc_offer(OFFER_SDP)
# Remove go2rtc config entry
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_remove(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert camera._webrtc_providers == []
assert camera.frontend_stream_type == StreamType.HLS
@pytest.mark.usefixtures("init_test_integration")
async def test_setup_go_binary(
hass: HomeAssistant,
mock_client: AsyncMock,
mock_server: Mock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the go2rtc config entry with binary."""
def after_setup() -> None:
mock_server.assert_called_once_with("/usr/bin/go2rtc")
mock_server.return_value.start.assert_called_once()
await _test_setup(hass, mock_client, mock_config_entry, after_setup)
mock_server.return_value.stop.assert_called_once()
@pytest.mark.usefixtures("init_test_integration")
async def test_setup_go(
hass: HomeAssistant,
mock_client: AsyncMock,
mock_server: Mock,
) -> None:
"""Test the go2rtc config entry without binary."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title=DOMAIN,
data={CONF_HOST: "http://localhost:1984/"},
)
def after_setup() -> None:
mock_server.assert_not_called()
await _test_setup(hass, mock_client, config_entry, after_setup)
mock_server.assert_not_called()

View File

@ -0,0 +1,91 @@
"""Tests for the go2rtc server."""
import asyncio
from collections.abc import Generator
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.go2rtc.server import Server
TEST_BINARY = "/bin/go2rtc"
@pytest.fixture
def server() -> Server:
"""Fixture to initialize the Server."""
return Server(binary=TEST_BINARY)
@pytest.fixture
def mock_tempfile() -> Generator[MagicMock]:
"""Fixture to mock NamedTemporaryFile."""
with patch(
"homeassistant.components.go2rtc.server.NamedTemporaryFile"
) as mock_tempfile:
mock_tempfile.return_value.__enter__.return_value.name = "test.yaml"
yield mock_tempfile
@pytest.fixture
def mock_popen() -> Generator[MagicMock]:
"""Fixture to mock subprocess.Popen."""
with patch("homeassistant.components.go2rtc.server.subprocess.Popen") as mock_popen:
yield mock_popen
@pytest.mark.usefixtures("mock_tempfile")
async def test_server_run_success(mock_popen: MagicMock, server: Server) -> None:
"""Test that the server runs successfully."""
mock_process = MagicMock()
mock_process.poll.return_value = None # Simulate process running
# Simulate process output
mock_process.stdout.readline.side_effect = [
b"log line 1\n",
b"log line 2\n",
b"",
]
mock_popen.return_value.__enter__.return_value = mock_process
server.start()
await asyncio.sleep(0)
# Check that Popen was called with the right arguments
mock_popen.assert_called_once_with(
[TEST_BINARY, "-c", "webrtc.ice_servers=[]", "-c", "test.yaml"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
# Check that server read the log lines
assert mock_process.stdout.readline.call_count == 3
server.stop()
mock_process.terminate.assert_called_once()
assert not server.is_alive()
@pytest.mark.usefixtures("mock_tempfile")
def test_server_run_process_timeout(mock_popen: MagicMock, server: Server) -> None:
"""Test server run where the process takes too long to terminate."""
mock_process = MagicMock()
mock_process.poll.return_value = None # Simulate process running
# Simulate process output
mock_process.stdout.readline.side_effect = [
b"log line 1\n",
b"",
]
# Simulate timeout
mock_process.wait.side_effect = subprocess.TimeoutExpired(cmd="go2rtc", timeout=5)
mock_popen.return_value.__enter__.return_value = mock_process
# Start server thread
server.start()
server.stop()
# Ensure terminate and kill were called due to timeout
mock_process.terminate.assert_called_once()
mock_process.kill.assert_called_once()
assert not server.is_alive()

View File

@ -10,7 +10,7 @@ import aiohttp
import pytest
import rtsp_to_webrtc
from homeassistant.components.rtsp_to_webrtc import CONF_STUN_SERVER, DOMAIN
from homeassistant.components.rtsp_to_webrtc import DOMAIN
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@ -18,7 +18,6 @@ from homeassistant.setup import async_setup_component
from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator
@ -162,69 +161,3 @@ async def test_offer_failure(
assert response["error"].get("code") == "web_rtc_offer_failed"
assert "message" in response["error"]
assert "RTSPtoWebRTC server communication failure" in response["error"]["message"]
async def test_no_stun_server(
hass: HomeAssistant,
rtsp_to_webrtc_client: Any,
setup_integration: ComponentSetup,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test successful setup and unload."""
await setup_integration()
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 2,
"type": "rtsp_to_webrtc/get_settings",
}
)
response = await client.receive_json()
assert response.get("id") == 2
assert response.get("type") == TYPE_RESULT
assert "result" in response
assert response["result"].get("stun_server") == ""
@pytest.mark.parametrize(
"config_entry_options", [{CONF_STUN_SERVER: "example.com:1234"}]
)
async def test_stun_server(
hass: HomeAssistant,
rtsp_to_webrtc_client: Any,
setup_integration: ComponentSetup,
config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test successful setup and unload."""
await setup_integration()
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 3,
"type": "rtsp_to_webrtc/get_settings",
}
)
response = await client.receive_json()
assert response.get("id") == 3
assert response.get("type") == TYPE_RESULT
assert "result" in response
assert response["result"].get("stun_server") == "example.com:1234"
# Simulate an options flow change, clearing the stun server and verify the change is reflected
hass.config_entries.async_update_entry(config_entry, options={})
await hass.async_block_till_done()
await client.send_json(
{
"id": 4,
"type": "rtsp_to_webrtc/get_settings",
}
)
response = await client.receive_json()
assert response.get("id") == 4
assert response.get("type") == TYPE_RESULT
assert "result" in response
assert response["result"].get("stun_server") == ""