From 04860ae1d2a6b24b69f7f94a9f4782bc9cb15000 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 3 Oct 2024 09:20:03 +0200 Subject: [PATCH] Add go2rtc and extend camera integration for better WebRTC support (#124410) --- CODEOWNERS | 2 + Dockerfile | 15 ++ homeassistant/components/camera/__init__.py | 163 +++++------- homeassistant/components/camera/const.py | 5 +- .../components/camera/diagnostics.py | 4 +- homeassistant/components/camera/helper.py | 28 ++ homeassistant/components/camera/webrtc.py | 239 ++++++++++++++++++ homeassistant/components/go2rtc/__init__.py | 91 +++++++ .../components/go2rtc/config_flow.py | 90 +++++++ homeassistant/components/go2rtc/const.py | 5 + homeassistant/components/go2rtc/manifest.json | 11 + homeassistant/components/go2rtc/server.py | 56 ++++ homeassistant/components/go2rtc/strings.json | 19 ++ homeassistant/components/nest/camera.py | 5 + .../components/rtsp_to_webrtc/__init__.py | 37 +-- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 7 + homeassistant/package_constraints.txt | 4 +- pyproject.toml | 1 + requirements.txt | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 3 - script/hassfest/docker.py | 23 +- tests/components/axis/test_camera.py | 2 +- tests/components/camera/common.py | 1 + tests/components/camera/conftest.py | 20 +- tests/components/camera/test_init.py | 21 +- tests/components/camera/test_webrtc.py | 236 +++++++++++++++++ tests/components/go2rtc/__init__.py | 13 + tests/components/go2rtc/conftest.py | 57 +++++ tests/components/go2rtc/test_config_flow.py | 156 ++++++++++++ tests/components/go2rtc/test_init.py | 219 ++++++++++++++++ tests/components/go2rtc/test_server.py | 91 +++++++ tests/components/rtsp_to_webrtc/test_init.py | 69 +---- 35 files changed, 1476 insertions(+), 225 deletions(-) create mode 100644 homeassistant/components/camera/helper.py create mode 100644 homeassistant/components/camera/webrtc.py create mode 100644 homeassistant/components/go2rtc/__init__.py create mode 100644 homeassistant/components/go2rtc/config_flow.py create mode 100644 homeassistant/components/go2rtc/const.py create mode 100644 homeassistant/components/go2rtc/manifest.json create mode 100644 homeassistant/components/go2rtc/server.py create mode 100644 homeassistant/components/go2rtc/strings.json create mode 100644 tests/components/camera/test_webrtc.py create mode 100644 tests/components/go2rtc/__init__.py create mode 100644 tests/components/go2rtc/conftest.py create mode 100644 tests/components/go2rtc/test_config_flow.py create mode 100644 tests/components/go2rtc/test_init.py create mode 100644 tests/components/go2rtc/test_server.py diff --git a/CODEOWNERS b/CODEOWNERS index db7e1747647814..36ed63175f2509 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/Dockerfile b/Dockerfile index 684357be82a9c4..44edbdf8e3e38f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index e5bce1b545b98f..b78030318ccec1 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -4,7 +4,7 @@ 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 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 @@ CONF_LOOKBACK, DATA_CAMERA_PREFS, DATA_COMPONENT, - DATA_RTSP_TO_WEB_RTC, DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM, @@ -80,11 +79,23 @@ 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 @@ class CameraEntityFeature(IntFlag): 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 write_to_mjpeg_stream(img_bytes: bytes) -> None: 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 @@ def unsub_track_time_interval(_event: Event) -> None: 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 @@ def __init__(self) -> None: 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 @@ def frontend_stream_type(self) -> StreamType | None: 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 @@ async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: 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 @@ async def async_internal_added_to_hass(self) -> None: # 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 @@ async def async_refresh_providers(self) -> None: 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"], diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 1286e0f39764b9..7e4633d410a2eb 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -17,16 +17,13 @@ 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" diff --git a/homeassistant/components/camera/diagnostics.py b/homeassistant/components/camera/diagnostics.py index 1edda5079b4c3e..3408ab3a0af6d2 100644 --- a/homeassistant/components/camera/diagnostics.py +++ b/homeassistant/components/camera/diagnostics.py @@ -7,8 +7,8 @@ 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] = ( diff --git a/homeassistant/components/camera/helper.py b/homeassistant/components/camera/helper.py new file mode 100644 index 00000000000000..5e84b18dda8a03 --- /dev/null +++ b/homeassistant/components/camera/helper.py @@ -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 diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py new file mode 100644 index 00000000000000..05924855bc4caa --- /dev/null +++ b/homeassistant/components/camera/webrtc.py @@ -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) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py new file mode 100644 index 00000000000000..4ff7ee73efc31f --- /dev/null +++ b/homeassistant/components/go2rtc/__init__.py @@ -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 diff --git a/homeassistant/components/go2rtc/config_flow.py b/homeassistant/components/go2rtc/config_flow.py new file mode 100644 index 00000000000000..5162850461402b --- /dev/null +++ b/homeassistant/components/go2rtc/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py new file mode 100644 index 00000000000000..af8266e0d723db --- /dev/null +++ b/homeassistant/components/go2rtc/const.py @@ -0,0 +1,5 @@ +"""Go2rtc constants.""" + +DOMAIN = "go2rtc" + +CONF_BINARY = "binary" diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json new file mode 100644 index 00000000000000..faf6c991ac1719 --- /dev/null +++ b/homeassistant/components/go2rtc/manifest.json @@ -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 +} diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py new file mode 100644 index 00000000000000..fc9c2b17f60046 --- /dev/null +++ b/homeassistant/components/go2rtc/server.py @@ -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() diff --git a/homeassistant/components/go2rtc/strings.json b/homeassistant/components/go2rtc/strings.json new file mode 100644 index 00000000000000..44e28d712c1b5d --- /dev/null +++ b/homeassistant/components/go2rtc/strings.json @@ -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://`." + } + } +} diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index e87c9ccbbe7c5d..e25ff82694f961 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -21,6 +21,7 @@ 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 @@ async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: 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") diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py index 77bf7ffeb8f3d4..948ba8929fc22f 100644 --- a/homeassistant/components/rtsp_to_webrtc/__init__.py +++ b/homeassistant/components/rtsp_to_webrtc/__init__.py @@ -12,7 +12,7 @@ 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 @@ 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_offer_for_stream_source( ) 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, "")}, - ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 40ddcbd86c0e67..10e27ff2c975a1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -221,6 +221,7 @@ "gios", "github", "glances", + "go2rtc", "goalzero", "gogogate2", "goodwe", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2972aabbbfccf9..7b1cb0450413ce 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2db64bfd61902d..786af866c81007 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 56ca531257105e..2cd8ff7502d93b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/requirements.txt b/requirements.txt index 500af7a6793983..178539f991c74c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index 3e24aa507f1b2d..fd823c63ff4b8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e5c736c9d71de..42f647f07d2b9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4641d4ac12ac59..7787578902cac7 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -140,9 +140,6 @@ # 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 diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index d12a7e5f78e866..213f21a7a3e334 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -57,6 +57,21 @@ # 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.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), diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 91e24a8c0c0f23..6cc4bbd7c2f755 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -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" diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 9cacf85d9077ae..f7dcf46db01ac1 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -8,6 +8,7 @@ EMPTY_8_6_JPEG = b"empty_8_6" WEBRTC_ANSWER = "a=sendonly" +STREAM_SOURCE = "rtsp://127.0.0.1/stream" def mock_turbo_jpeg( diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index ea3d65f4864790..5eda2f1eb55656 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -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.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 diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index fd3ee8df22e123..2b90d621329531 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -27,7 +27,7 @@ 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.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.""" diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py new file mode 100644 index 00000000000000..d304c7e5fb0a11 --- /dev/null +++ b/tests/components/camera/test_webrtc.py @@ -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", + } diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py new file mode 100644 index 00000000000000..20cbd67d571e4b --- /dev/null +++ b/tests/components/go2rtc/__init__.py @@ -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() diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py new file mode 100644 index 00000000000000..02c1b3b908c255 --- /dev/null +++ b/tests/components/go2rtc/conftest.py @@ -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"}, + ) diff --git a/tests/components/go2rtc/test_config_flow.py b/tests/components/go2rtc/test_config_flow.py new file mode 100644 index 00000000000000..25c993e7d3149a --- /dev/null +++ b/tests/components/go2rtc/test_config_flow.py @@ -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, + } diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py new file mode 100644 index 00000000000000..afd336dc2b8302 --- /dev/null +++ b/tests/components/go2rtc/test_init.py @@ -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() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py new file mode 100644 index 00000000000000..1617ea55015392 --- /dev/null +++ b/tests/components/go2rtc/test_server.py @@ -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() diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py index 3071c3d9d083f4..cb4d5f7a1316ac 100644 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -10,7 +10,7 @@ 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 .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") == ""