Add go2rtc and extend camera integration for better WebRTC support (#124410)
This commit is contained in:
parent
a0a90f03a8
commit
04860ae1d2
35 changed files with 1476 additions and 225 deletions
|
@ -544,6 +544,8 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/github/ @timmo001 @ludeeus
|
/tests/components/github/ @timmo001 @ludeeus
|
||||||
/homeassistant/components/glances/ @engrbm87
|
/homeassistant/components/glances/ @engrbm87
|
||||||
/tests/components/glances/ @engrbm87
|
/tests/components/glances/ @engrbm87
|
||||||
|
/homeassistant/components/go2rtc/ @home-assistant/core
|
||||||
|
/tests/components/go2rtc/ @home-assistant/core
|
||||||
/homeassistant/components/goalzero/ @tkdrob
|
/homeassistant/components/goalzero/ @tkdrob
|
||||||
/tests/components/goalzero/ @tkdrob
|
/tests/components/goalzero/ @tkdrob
|
||||||
/homeassistant/components/gogogate2/ @vangorra
|
/homeassistant/components/gogogate2/ @vangorra
|
||||||
|
|
15
Dockerfile
15
Dockerfile
|
@ -44,4 +44,19 @@ RUN \
|
||||||
# Home Assistant S6-Overlay
|
# Home Assistant S6-Overlay
|
||||||
COPY rootfs /
|
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
|
WORKDIR /config
|
||||||
|
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
from collections.abc import Awaitable, Callable, Iterable
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
@ -14,7 +14,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
from random import SystemRandom
|
from random import SystemRandom
|
||||||
import time
|
import time
|
||||||
from typing import Any, Final, cast, final
|
from typing import Any, Final, final
|
||||||
|
|
||||||
from aiohttp import hdrs, web
|
from aiohttp import hdrs, web
|
||||||
import attr
|
import attr
|
||||||
|
@ -72,7 +72,6 @@ from .const import ( # noqa: F401
|
||||||
CONF_LOOKBACK,
|
CONF_LOOKBACK,
|
||||||
DATA_CAMERA_PREFS,
|
DATA_CAMERA_PREFS,
|
||||||
DATA_COMPONENT,
|
DATA_COMPONENT,
|
||||||
DATA_RTSP_TO_WEB_RTC,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PREF_ORIENTATION,
|
PREF_ORIENTATION,
|
||||||
PREF_PRELOAD_STREAM,
|
PREF_PRELOAD_STREAM,
|
||||||
|
@ -80,11 +79,23 @@ from .const import ( # noqa: F401
|
||||||
CameraState,
|
CameraState,
|
||||||
StreamType,
|
StreamType,
|
||||||
)
|
)
|
||||||
|
from .helper import get_camera_from_entity_id
|
||||||
from .img_util import scale_jpeg_camera_image
|
from .img_util import scale_jpeg_camera_image
|
||||||
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
||||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||||
|
@ -122,7 +133,6 @@ _DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum(
|
||||||
CameraEntityFeature.STREAM, "2025.1"
|
CameraEntityFeature.STREAM, "2025.1"
|
||||||
)
|
)
|
||||||
|
|
||||||
RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
|
|
||||||
|
|
||||||
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
|
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
|
||||||
ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}"
|
ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}"
|
||||||
|
@ -161,7 +171,7 @@ class Image:
|
||||||
@bind_hass
|
@bind_hass
|
||||||
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
|
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
|
||||||
"""Request a stream for a camera entity."""
|
"""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)
|
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.
|
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)
|
return await _async_get_image(camera, timeout, width, height)
|
||||||
|
|
||||||
|
|
||||||
|
@ -241,7 +251,7 @@ async def _async_get_stream_image(
|
||||||
@bind_hass
|
@bind_hass
|
||||||
async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None:
|
async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None:
|
||||||
"""Fetch the stream source for a camera entity."""
|
"""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()
|
return await camera.stream_source()
|
||||||
|
|
||||||
|
|
||||||
|
@ -250,7 +260,7 @@ async def async_get_mjpeg_stream(
|
||||||
hass: HomeAssistant, request: web.Request, entity_id: str
|
hass: HomeAssistant, request: web.Request, entity_id: str
|
||||||
) -> web.StreamResponse | None:
|
) -> web.StreamResponse | None:
|
||||||
"""Fetch an mjpeg stream from a camera entity."""
|
"""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:
|
try:
|
||||||
stream = await camera.handle_async_mjpeg_stream(request)
|
stream = await camera.handle_async_mjpeg_stream(request)
|
||||||
|
@ -317,69 +327,6 @@ async def async_get_still_stream(
|
||||||
return response
|
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:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the camera component."""
|
"""Set up the camera component."""
|
||||||
component = hass.data[DATA_COMPONENT] = EntityComponent[Camera](
|
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, ws_camera_web_rtc_offer)
|
||||||
websocket_api.async_register_command(hass, websocket_get_prefs)
|
websocket_api.async_register_command(hass, websocket_get_prefs)
|
||||||
websocket_api.async_register_command(hass, websocket_update_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)
|
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
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -507,7 +461,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
self._warned_old_signature = False
|
self._warned_old_signature = False
|
||||||
self.async_update_token()
|
self.async_update_token()
|
||||||
self._create_stream_lock: asyncio.Lock | None = None
|
self._create_stream_lock: asyncio.Lock | None = None
|
||||||
self._rtsp_to_webrtc = False
|
self._webrtc_providers: list[CameraWebRTCProvider] = []
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def entity_picture(self) -> str:
|
def entity_picture(self) -> str:
|
||||||
|
@ -581,7 +535,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
return self._attr_frontend_stream_type
|
return self._attr_frontend_stream_type
|
||||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||||
return None
|
return None
|
||||||
if self._rtsp_to_webrtc:
|
if self._webrtc_providers:
|
||||||
return StreamType.WEB_RTC
|
return StreamType.WEB_RTC
|
||||||
return StreamType.HLS
|
return StreamType.HLS
|
||||||
|
|
||||||
|
@ -631,14 +585,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
|
|
||||||
Integrations can override with a native WebRTC implementation.
|
Integrations can override with a native WebRTC implementation.
|
||||||
"""
|
"""
|
||||||
stream_source = await self.stream_source()
|
for provider in self._webrtc_providers:
|
||||||
if not stream_source:
|
if answer := await provider.async_handle_web_rtc_offer(self, offer_sdp):
|
||||||
return None
|
return answer
|
||||||
for provider in _async_get_rtsp_to_web_rtc_providers(self.hass):
|
raise HomeAssistantError(
|
||||||
answer_sdp = await provider(stream_source, offer_sdp, self.entity_id)
|
"WebRTC offer was not accepted by the supported providers"
|
||||||
if answer_sdp:
|
)
|
||||||
return answer_sdp
|
|
||||||
raise HomeAssistantError("WebRTC offer was not accepted by any providers")
|
|
||||||
|
|
||||||
def camera_image(
|
def camera_image(
|
||||||
self, width: int | None = None, height: int | None = None
|
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
|
# Avoid calling async_refresh_providers() in here because it
|
||||||
# it will write state a second time since state is always
|
# it will write state a second time since state is always
|
||||||
# written when an entity is added to hass.
|
# 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:
|
async def async_refresh_providers(self) -> None:
|
||||||
"""Determine if any of the registered providers are suitable for this entity.
|
"""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)
|
Returns True if any state was updated (and needs to be written)
|
||||||
"""
|
"""
|
||||||
old_state = self._rtsp_to_webrtc
|
old_providers = self._webrtc_providers
|
||||||
self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc()
|
new_providers = await self._async_get_supported_webrtc_providers()
|
||||||
if old_state != self._rtsp_to_webrtc:
|
self._webrtc_providers = new_providers
|
||||||
|
if old_providers != new_providers:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def _async_use_rtsp_to_webrtc(self) -> bool:
|
async def _async_get_supported_webrtc_providers(
|
||||||
"""Determine if a WebRTC provider can be used for the camera."""
|
self,
|
||||||
|
) -> list[CameraWebRTCProvider]:
|
||||||
|
"""Get the all providers that supports this camera."""
|
||||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||||
return False
|
return []
|
||||||
if DATA_RTSP_TO_WEB_RTC not in self.hass.data:
|
|
||||||
return False
|
return await async_get_supported_providers(self.hass, self)
|
||||||
stream_source = await self.stream_source()
|
|
||||||
return any(
|
@property
|
||||||
stream_source and stream_source.startswith(prefix)
|
def webrtc_providers(self) -> list[CameraWebRTCProvider]:
|
||||||
for prefix in RTSP_PREFIXES
|
"""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):
|
class CameraView(HomeAssistantView):
|
||||||
|
@ -885,7 +856,7 @@ async def ws_camera_stream(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
entity_id = msg["entity_id"]
|
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"])
|
url = await _async_stream_endpoint_url(hass, camera, fmt=msg["format"])
|
||||||
connection.send_result(msg["id"], {"url": url})
|
connection.send_result(msg["id"], {"url": url})
|
||||||
except HomeAssistantError as ex:
|
except HomeAssistantError as ex:
|
||||||
|
@ -920,7 +891,7 @@ async def ws_camera_web_rtc_offer(
|
||||||
"""
|
"""
|
||||||
entity_id = msg["entity_id"]
|
entity_id = msg["entity_id"]
|
||||||
offer = msg["offer"]
|
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:
|
if camera.frontend_stream_type != StreamType.WEB_RTC:
|
||||||
connection.send_error(
|
connection.send_error(
|
||||||
msg["id"],
|
msg["id"],
|
||||||
|
|
|
@ -17,16 +17,13 @@ from homeassistant.util.hass_dict import HassKey
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
|
||||||
from . import Camera, RtspToWebRtcProviderType
|
from . import Camera
|
||||||
from .prefs import CameraPreferences
|
from .prefs import CameraPreferences
|
||||||
|
|
||||||
DOMAIN: Final = "camera"
|
DOMAIN: Final = "camera"
|
||||||
DATA_COMPONENT: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN)
|
DATA_COMPONENT: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN)
|
||||||
|
|
||||||
DATA_CAMERA_PREFS: HassKey[CameraPreferences] = HassKey("camera_prefs")
|
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_PRELOAD_STREAM: Final = "preload_stream"
|
||||||
PREF_ORIENTATION: Final = "orientation"
|
PREF_ORIENTATION: Final = "orientation"
|
||||||
|
|
|
@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from . import _get_camera_from_entity_id
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .helper import get_camera_from_entity_id
|
||||||
|
|
||||||
|
|
||||||
async def async_get_config_entry_diagnostics(
|
async def async_get_config_entry_diagnostics(
|
||||||
|
@ -22,7 +22,7 @@ async def async_get_config_entry_diagnostics(
|
||||||
if entity.domain != DOMAIN:
|
if entity.domain != DOMAIN:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
camera = _get_camera_from_entity_id(hass, entity.entity_id)
|
camera = get_camera_from_entity_id(hass, entity.entity_id)
|
||||||
except HomeAssistantError:
|
except HomeAssistantError:
|
||||||
continue
|
continue
|
||||||
diagnostics[entity.entity_id] = (
|
diagnostics[entity.entity_id] = (
|
||||||
|
|
28
homeassistant/components/camera/helper.py
Normal file
28
homeassistant/components/camera/helper.py
Normal 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
|
239
homeassistant/components/camera/webrtc.py
Normal file
239
homeassistant/components/camera/webrtc.py
Normal 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)
|
91
homeassistant/components/go2rtc/__init__.py
Normal file
91
homeassistant/components/go2rtc/__init__.py
Normal 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
|
90
homeassistant/components/go2rtc/config_flow.py
Normal file
90
homeassistant/components/go2rtc/config_flow.py
Normal 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,
|
||||||
|
)
|
5
homeassistant/components/go2rtc/const.py
Normal file
5
homeassistant/components/go2rtc/const.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
"""Go2rtc constants."""
|
||||||
|
|
||||||
|
DOMAIN = "go2rtc"
|
||||||
|
|
||||||
|
CONF_BINARY = "binary"
|
11
homeassistant/components/go2rtc/manifest.json
Normal file
11
homeassistant/components/go2rtc/manifest.json
Normal 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
|
||||||
|
}
|
56
homeassistant/components/go2rtc/server.py
Normal file
56
homeassistant/components/go2rtc/server.py
Normal 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()
|
19
homeassistant/components/go2rtc/strings.json
Normal file
19
homeassistant/components/go2rtc/strings.json
Normal 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://`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ from google_nest_sdm.device_manager import DeviceManager
|
||||||
from google_nest_sdm.exceptions import ApiException
|
from google_nest_sdm.exceptions import ApiException
|
||||||
|
|
||||||
from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType
|
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.components.stream import CONF_EXTRA_PART_WAIT_TIME
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -210,3 +211,7 @@ class NestCamera(Camera):
|
||||||
except ApiException as err:
|
except ApiException as err:
|
||||||
raise HomeAssistantError(f"Nest API error: {err}") from err
|
raise HomeAssistantError(f"Nest API error: {err}") from err
|
||||||
return stream.answer_sdp
|
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")
|
||||||
|
|
|
@ -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:
|
Other integrations may use this integration with these steps:
|
||||||
- Check if this integration is loaded
|
- 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
|
- 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 asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from rtsp_to_webrtc.client import get_adaptive_client
|
from rtsp_to_webrtc.client import get_adaptive_client
|
||||||
from rtsp_to_webrtc.exceptions import ClientError, ResponseError
|
from rtsp_to_webrtc.exceptions import ClientError, ResponseError
|
||||||
from rtsp_to_webrtc.interface import WebRTCClientInterface
|
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.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
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:
|
except (TimeoutError, ClientError) as err:
|
||||||
raise ConfigEntryNotReady from 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(
|
async def async_offer_for_stream_source(
|
||||||
stream_source: str,
|
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))
|
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||||
|
|
||||||
websocket_api.async_register_command(hass, ws_get_settings)
|
|
||||||
|
|
||||||
return True
|
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:
|
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Reload config entry when options change."""
|
"""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)
|
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, "")},
|
|
||||||
)
|
|
||||||
|
|
|
@ -221,6 +221,7 @@ FLOWS = {
|
||||||
"gios",
|
"gios",
|
||||||
"github",
|
"github",
|
||||||
"glances",
|
"glances",
|
||||||
|
"go2rtc",
|
||||||
"goalzero",
|
"goalzero",
|
||||||
"gogogate2",
|
"gogogate2",
|
||||||
"goodwe",
|
"goodwe",
|
||||||
|
|
|
@ -2247,6 +2247,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"go2rtc": {
|
||||||
|
"name": "go2rtc",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"single_config_entry": true
|
||||||
|
},
|
||||||
"goalzero": {
|
"goalzero": {
|
||||||
"name": "Goal Zero Yeti",
|
"name": "Goal Zero Yeti",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
|
|
|
@ -38,6 +38,7 @@ httpx==0.27.2
|
||||||
ifaddr==0.2.0
|
ifaddr==0.2.0
|
||||||
Jinja2==3.1.4
|
Jinja2==3.1.4
|
||||||
lru-dict==1.3.0
|
lru-dict==1.3.0
|
||||||
|
mashumaro==3.13.1
|
||||||
mutagen==1.47.0
|
mutagen==1.47.0
|
||||||
orjson==3.10.7
|
orjson==3.10.7
|
||||||
packaging>=23.1
|
packaging>=23.1
|
||||||
|
@ -121,9 +122,6 @@ backoff>=2.0
|
||||||
# v2 has breaking changes (#99218).
|
# v2 has breaking changes (#99218).
|
||||||
pydantic==1.10.18
|
pydantic==1.10.18
|
||||||
|
|
||||||
# Required for Python 3.12.4 compatibility (#119223).
|
|
||||||
mashumaro>=3.13.1
|
|
||||||
|
|
||||||
# Breaks asyncio
|
# Breaks asyncio
|
||||||
# https://github.com/pubnub/python/issues/130
|
# https://github.com/pubnub/python/issues/130
|
||||||
pubnub!=6.4.0
|
pubnub!=6.4.0
|
||||||
|
|
|
@ -50,6 +50,7 @@ dependencies = [
|
||||||
"ifaddr==0.2.0",
|
"ifaddr==0.2.0",
|
||||||
"Jinja2==3.1.4",
|
"Jinja2==3.1.4",
|
||||||
"lru-dict==1.3.0",
|
"lru-dict==1.3.0",
|
||||||
|
"mashumaro==3.13.1",
|
||||||
"PyJWT==2.9.0",
|
"PyJWT==2.9.0",
|
||||||
# PyJWT has loose dependency. We want the latest one.
|
# PyJWT has loose dependency. We want the latest one.
|
||||||
"cryptography==43.0.1",
|
"cryptography==43.0.1",
|
||||||
|
|
|
@ -24,6 +24,7 @@ home-assistant-bluetooth==1.12.2
|
||||||
ifaddr==0.2.0
|
ifaddr==0.2.0
|
||||||
Jinja2==3.1.4
|
Jinja2==3.1.4
|
||||||
lru-dict==1.3.0
|
lru-dict==1.3.0
|
||||||
|
mashumaro==3.13.1
|
||||||
PyJWT==2.9.0
|
PyJWT==2.9.0
|
||||||
cryptography==43.0.1
|
cryptography==43.0.1
|
||||||
Pillow==10.4.0
|
Pillow==10.4.0
|
||||||
|
|
|
@ -981,6 +981,9 @@ gitterpy==0.1.7
|
||||||
# homeassistant.components.glances
|
# homeassistant.components.glances
|
||||||
glances-api==0.8.0
|
glances-api==0.8.0
|
||||||
|
|
||||||
|
# homeassistant.components.go2rtc
|
||||||
|
go2rtc-client==0.0.1b0
|
||||||
|
|
||||||
# homeassistant.components.goalzero
|
# homeassistant.components.goalzero
|
||||||
goalzero==0.2.2
|
goalzero==0.2.2
|
||||||
|
|
||||||
|
|
|
@ -831,6 +831,9 @@ gios==4.0.0
|
||||||
# homeassistant.components.glances
|
# homeassistant.components.glances
|
||||||
glances-api==0.8.0
|
glances-api==0.8.0
|
||||||
|
|
||||||
|
# homeassistant.components.go2rtc
|
||||||
|
go2rtc-client==0.0.1b0
|
||||||
|
|
||||||
# homeassistant.components.goalzero
|
# homeassistant.components.goalzero
|
||||||
goalzero==0.2.2
|
goalzero==0.2.2
|
||||||
|
|
||||||
|
|
|
@ -140,9 +140,6 @@ backoff>=2.0
|
||||||
# v2 has breaking changes (#99218).
|
# v2 has breaking changes (#99218).
|
||||||
pydantic==1.10.18
|
pydantic==1.10.18
|
||||||
|
|
||||||
# Required for Python 3.12.4 compatibility (#119223).
|
|
||||||
mashumaro>=3.13.1
|
|
||||||
|
|
||||||
# Breaks asyncio
|
# Breaks asyncio
|
||||||
# https://github.com/pubnub/python/issues/130
|
# https://github.com/pubnub/python/issues/130
|
||||||
pubnub!=6.4.0
|
pubnub!=6.4.0
|
||||||
|
|
|
@ -57,6 +57,21 @@ RUN \
|
||||||
# Home Assistant S6-Overlay
|
# Home Assistant S6-Overlay
|
||||||
COPY rootfs /
|
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
|
WORKDIR /config
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -96,6 +111,8 @@ LABEL "com.github.actions.icon"="terminal"
|
||||||
LABEL "com.github.actions.color"="gray-dark"
|
LABEL "com.github.actions.color"="gray-dark"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_GO2RTC_VERSION = "1.9.4"
|
||||||
|
|
||||||
|
|
||||||
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
|
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
|
||||||
package_versions: dict[str, str] = {}
|
package_versions: dict[str, str] = {}
|
||||||
|
@ -176,7 +193,11 @@ def _generate_files(config: Config) -> list[File]:
|
||||||
|
|
||||||
return [
|
return [
|
||||||
File(
|
File(
|
||||||
DOCKERFILE_TEMPLATE.format(timeout=timeout, **package_versions),
|
DOCKERFILE_TEMPLATE.format(
|
||||||
|
timeout=timeout,
|
||||||
|
**package_versions,
|
||||||
|
go2rtc=_GO2RTC_VERSION,
|
||||||
|
),
|
||||||
config.root / "Dockerfile",
|
config.root / "Dockerfile",
|
||||||
),
|
),
|
||||||
_generate_hassfest_dockerimage(config, timeout, package_versions),
|
_generate_hassfest_dockerimage(config, timeout, package_versions),
|
||||||
|
|
|
@ -59,7 +59,7 @@ async def test_camera(
|
||||||
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
|
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
|
||||||
|
|
||||||
entity_id = f"{CAMERA_DOMAIN}.{NAME}"
|
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.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi"
|
||||||
assert (
|
assert (
|
||||||
camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi"
|
camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi"
|
||||||
|
|
|
@ -8,6 +8,7 @@ from unittest.mock import Mock
|
||||||
|
|
||||||
EMPTY_8_6_JPEG = b"empty_8_6"
|
EMPTY_8_6_JPEG = b"empty_8_6"
|
||||||
WEBRTC_ANSWER = "a=sendonly"
|
WEBRTC_ANSWER = "a=sendonly"
|
||||||
|
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
||||||
|
|
||||||
|
|
||||||
def mock_turbo_jpeg(
|
def mock_turbo_jpeg(
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""Test helpers for camera."""
|
"""Test helpers for camera."""
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator, Generator
|
from collections.abc import AsyncGenerator, Generator
|
||||||
from unittest.mock import PropertyMock, patch
|
from unittest.mock import AsyncMock, PropertyMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from .common import WEBRTC_ANSWER
|
from .common import STREAM_SOURCE, WEBRTC_ANSWER
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@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),
|
new_callable=PropertyMock(return_value=None),
|
||||||
):
|
):
|
||||||
yield
|
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
|
||||||
|
|
|
@ -27,7 +27,7 @@ from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
from homeassistant.util import dt as dt_util
|
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 (
|
from tests.common import (
|
||||||
async_fire_time_changed,
|
async_fire_time_changed,
|
||||||
|
@ -36,19 +36,10 @@ from tests.common import (
|
||||||
)
|
)
|
||||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||||
|
|
||||||
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
|
||||||
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
|
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
|
||||||
WEBRTC_OFFER = "v=0\r\n"
|
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")
|
@pytest.fixture(name="image_mock_url")
|
||||||
async def image_mock_url_fixture(hass: HomeAssistant) -> None:
|
async def image_mock_url_fixture(hass: HomeAssistant) -> None:
|
||||||
"""Fixture for get_image tests."""
|
"""Fixture for get_image tests."""
|
||||||
|
@ -58,16 +49,6 @@ async def image_mock_url_fixture(hass: HomeAssistant) -> None:
|
||||||
await hass.async_block_till_done()
|
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")
|
@pytest.fixture(name="mock_hls_stream_source")
|
||||||
async def mock_hls_stream_source_fixture() -> Generator[AsyncMock]:
|
async def mock_hls_stream_source_fixture() -> Generator[AsyncMock]:
|
||||||
"""Fixture to create an HLS stream source."""
|
"""Fixture to create an HLS stream source."""
|
||||||
|
|
236
tests/components/camera/test_webrtc.py
Normal file
236
tests/components/camera/test_webrtc.py
Normal 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",
|
||||||
|
}
|
13
tests/components/go2rtc/__init__.py
Normal file
13
tests/components/go2rtc/__init__.py
Normal 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()
|
57
tests/components/go2rtc/conftest.py
Normal file
57
tests/components/go2rtc/conftest.py
Normal 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"},
|
||||||
|
)
|
156
tests/components/go2rtc/test_config_flow.py
Normal file
156
tests/components/go2rtc/test_config_flow.py
Normal 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,
|
||||||
|
}
|
219
tests/components/go2rtc/test_init.py
Normal file
219
tests/components/go2rtc/test_init.py
Normal 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()
|
91
tests/components/go2rtc/test_server.py
Normal file
91
tests/components/go2rtc/test_server.py
Normal 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()
|
|
@ -10,7 +10,7 @@ import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
import rtsp_to_webrtc
|
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.components.websocket_api import TYPE_RESULT
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.core import HomeAssistant
|
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 .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
from tests.typing import WebSocketGenerator
|
from tests.typing import WebSocketGenerator
|
||||||
|
|
||||||
|
@ -162,69 +161,3 @@ async def test_offer_failure(
|
||||||
assert response["error"].get("code") == "web_rtc_offer_failed"
|
assert response["error"].get("code") == "web_rtc_offer_failed"
|
||||||
assert "message" in response["error"]
|
assert "message" in response["error"]
|
||||||
assert "RTSPtoWebRTC server communication failure" in response["error"]["message"]
|
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") == ""
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue