mirror of
https://github.com/home-assistant/core.git
synced 2025-04-19 14:57:52 +00:00
Add go2rtc and extend camera integration for better WebRTC support (#124410)
This commit is contained in:
parent
a0a90f03a8
commit
04860ae1d2
@ -544,6 +544,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/github/ @timmo001 @ludeeus
|
||||
/homeassistant/components/glances/ @engrbm87
|
||||
/tests/components/glances/ @engrbm87
|
||||
/homeassistant/components/go2rtc/ @home-assistant/core
|
||||
/tests/components/go2rtc/ @home-assistant/core
|
||||
/homeassistant/components/goalzero/ @tkdrob
|
||||
/tests/components/goalzero/ @tkdrob
|
||||
/homeassistant/components/gogogate2/ @vangorra
|
||||
|
15
Dockerfile
15
Dockerfile
@ -44,4 +44,19 @@ RUN \
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
# Needs to be redefined inside the FROM statement to be set for RUN commands
|
||||
ARG BUILD_ARCH
|
||||
# Get go2rtc binary
|
||||
RUN \
|
||||
case "${BUILD_ARCH}" in \
|
||||
"aarch64") go2rtc_suffix='arm64' ;; \
|
||||
"armhf") go2rtc_suffix='armv6' ;; \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.4/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
WORKDIR /config
|
||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timedelta
|
||||
@ -14,7 +14,7 @@ import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final, cast, final
|
||||
from typing import Any, Final, final
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
import attr
|
||||
@ -72,7 +72,6 @@ from .const import ( # noqa: F401
|
||||
CONF_LOOKBACK,
|
||||
DATA_CAMERA_PREFS,
|
||||
DATA_COMPONENT,
|
||||
DATA_RTSP_TO_WEB_RTC,
|
||||
DOMAIN,
|
||||
PREF_ORIENTATION,
|
||||
PREF_PRELOAD_STREAM,
|
||||
@ -80,11 +79,23 @@ from .const import ( # noqa: F401
|
||||
CameraState,
|
||||
StreamType,
|
||||
)
|
||||
from .helper import get_camera_from_entity_id
|
||||
from .img_util import scale_jpeg_camera_image
|
||||
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
|
||||
from .webrtc import (
|
||||
DATA_ICE_SERVERS,
|
||||
CameraWebRTCProvider,
|
||||
RTCIceServer,
|
||||
WebRTCClientConfiguration,
|
||||
async_get_supported_providers,
|
||||
async_register_rtsp_to_web_rtc_provider, # noqa: F401
|
||||
register_ice_server,
|
||||
ws_get_client_config,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
@ -122,7 +133,6 @@ _DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum(
|
||||
CameraEntityFeature.STREAM, "2025.1"
|
||||
)
|
||||
|
||||
RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
|
||||
|
||||
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
|
||||
ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}"
|
||||
@ -161,7 +171,7 @@ class Image:
|
||||
@bind_hass
|
||||
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
|
||||
"""Request a stream for a camera entity."""
|
||||
camera = _get_camera_from_entity_id(hass, entity_id)
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
return await _async_stream_endpoint_url(hass, camera, fmt)
|
||||
|
||||
|
||||
@ -219,7 +229,7 @@ async def async_get_image(
|
||||
|
||||
width and height will be passed to the underlying camera.
|
||||
"""
|
||||
camera = _get_camera_from_entity_id(hass, entity_id)
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
return await _async_get_image(camera, timeout, width, height)
|
||||
|
||||
|
||||
@ -241,7 +251,7 @@ async def _async_get_stream_image(
|
||||
@bind_hass
|
||||
async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None:
|
||||
"""Fetch the stream source for a camera entity."""
|
||||
camera = _get_camera_from_entity_id(hass, entity_id)
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
return await camera.stream_source()
|
||||
|
||||
|
||||
@ -250,7 +260,7 @@ async def async_get_mjpeg_stream(
|
||||
hass: HomeAssistant, request: web.Request, entity_id: str
|
||||
) -> web.StreamResponse | None:
|
||||
"""Fetch an mjpeg stream from a camera entity."""
|
||||
camera = _get_camera_from_entity_id(hass, entity_id)
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
|
||||
try:
|
||||
stream = await camera.handle_async_mjpeg_stream(request)
|
||||
@ -317,69 +327,6 @@ async def async_get_still_stream(
|
||||
return response
|
||||
|
||||
|
||||
def _get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera:
|
||||
"""Get camera component from entity_id."""
|
||||
if (component := hass.data.get(DOMAIN)) is None:
|
||||
raise HomeAssistantError("Camera integration not set up")
|
||||
|
||||
if (camera := component.get_entity(entity_id)) is None:
|
||||
raise HomeAssistantError("Camera not found")
|
||||
|
||||
if not camera.is_on:
|
||||
raise HomeAssistantError("Camera is off")
|
||||
|
||||
return cast(Camera, camera)
|
||||
|
||||
|
||||
# An RtspToWebRtcProvider accepts these inputs:
|
||||
# stream_source: The RTSP url
|
||||
# offer_sdp: The WebRTC SDP offer
|
||||
# stream_id: A unique id for the stream, used to update an existing source
|
||||
# The output is the SDP answer, or None if the source or offer is not eligible.
|
||||
# The Callable may throw HomeAssistantError on failure.
|
||||
type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]]
|
||||
|
||||
|
||||
def async_register_rtsp_to_web_rtc_provider(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
provider: RtspToWebRtcProviderType,
|
||||
) -> Callable[[], None]:
|
||||
"""Register an RTSP to WebRTC provider.
|
||||
|
||||
The first provider to satisfy the offer will be used.
|
||||
"""
|
||||
if DOMAIN not in hass.data:
|
||||
raise ValueError("Unexpected state, camera not loaded")
|
||||
|
||||
def remove_provider() -> None:
|
||||
if domain in hass.data[DATA_RTSP_TO_WEB_RTC]:
|
||||
del hass.data[DATA_RTSP_TO_WEB_RTC]
|
||||
hass.async_create_task(_async_refresh_providers(hass))
|
||||
|
||||
hass.data.setdefault(DATA_RTSP_TO_WEB_RTC, {})
|
||||
hass.data[DATA_RTSP_TO_WEB_RTC][domain] = provider
|
||||
hass.async_create_task(_async_refresh_providers(hass))
|
||||
return remove_provider
|
||||
|
||||
|
||||
async def _async_refresh_providers(hass: HomeAssistant) -> None:
|
||||
"""Check all cameras for any state changes for registered providers."""
|
||||
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await asyncio.gather(
|
||||
*(camera.async_refresh_providers() for camera in component.entities)
|
||||
)
|
||||
|
||||
|
||||
def _async_get_rtsp_to_web_rtc_providers(
|
||||
hass: HomeAssistant,
|
||||
) -> Iterable[RtspToWebRtcProviderType]:
|
||||
"""Return registered RTSP to WebRTC providers."""
|
||||
providers = hass.data.get(DATA_RTSP_TO_WEB_RTC, {})
|
||||
return providers.values()
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the camera component."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[Camera](
|
||||
@ -397,6 +344,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
websocket_api.async_register_command(hass, ws_camera_web_rtc_offer)
|
||||
websocket_api.async_register_command(hass, websocket_get_prefs)
|
||||
websocket_api.async_register_command(hass, websocket_update_prefs)
|
||||
websocket_api.async_register_command(hass, ws_get_client_config)
|
||||
|
||||
await component.async_setup(config)
|
||||
|
||||
@ -452,6 +400,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service
|
||||
)
|
||||
|
||||
async def get_ice_server() -> RTCIceServer:
|
||||
# The following servers will replaced before the next stable release with
|
||||
# STUN server provided by Home Assistant. Used Google ones for testing purposes.
|
||||
return RTCIceServer(urls="stun:stun.l.google.com:19302")
|
||||
|
||||
register_ice_server(hass, get_ice_server)
|
||||
return True
|
||||
|
||||
|
||||
@ -507,7 +461,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self._warned_old_signature = False
|
||||
self.async_update_token()
|
||||
self._create_stream_lock: asyncio.Lock | None = None
|
||||
self._rtsp_to_webrtc = False
|
||||
self._webrtc_providers: list[CameraWebRTCProvider] = []
|
||||
|
||||
@cached_property
|
||||
def entity_picture(self) -> str:
|
||||
@ -581,7 +535,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return self._attr_frontend_stream_type
|
||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||
return None
|
||||
if self._rtsp_to_webrtc:
|
||||
if self._webrtc_providers:
|
||||
return StreamType.WEB_RTC
|
||||
return StreamType.HLS
|
||||
|
||||
@ -631,14 +585,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
Integrations can override with a native WebRTC implementation.
|
||||
"""
|
||||
stream_source = await self.stream_source()
|
||||
if not stream_source:
|
||||
return None
|
||||
for provider in _async_get_rtsp_to_web_rtc_providers(self.hass):
|
||||
answer_sdp = await provider(stream_source, offer_sdp, self.entity_id)
|
||||
if answer_sdp:
|
||||
return answer_sdp
|
||||
raise HomeAssistantError("WebRTC offer was not accepted by any providers")
|
||||
for provider in self._webrtc_providers:
|
||||
if answer := await provider.async_handle_web_rtc_offer(self, offer_sdp):
|
||||
return answer
|
||||
raise HomeAssistantError(
|
||||
"WebRTC offer was not accepted by the supported providers"
|
||||
)
|
||||
|
||||
def camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
@ -751,7 +703,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
# Avoid calling async_refresh_providers() in here because it
|
||||
# it will write state a second time since state is always
|
||||
# written when an entity is added to hass.
|
||||
self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc()
|
||||
self._webrtc_providers = await self._async_get_supported_webrtc_providers()
|
||||
|
||||
async def async_refresh_providers(self) -> None:
|
||||
"""Determine if any of the registered providers are suitable for this entity.
|
||||
@ -761,22 +713,41 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
Returns True if any state was updated (and needs to be written)
|
||||
"""
|
||||
old_state = self._rtsp_to_webrtc
|
||||
self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc()
|
||||
if old_state != self._rtsp_to_webrtc:
|
||||
old_providers = self._webrtc_providers
|
||||
new_providers = await self._async_get_supported_webrtc_providers()
|
||||
self._webrtc_providers = new_providers
|
||||
if old_providers != new_providers:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_use_rtsp_to_webrtc(self) -> bool:
|
||||
"""Determine if a WebRTC provider can be used for the camera."""
|
||||
async def _async_get_supported_webrtc_providers(
|
||||
self,
|
||||
) -> list[CameraWebRTCProvider]:
|
||||
"""Get the all providers that supports this camera."""
|
||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||
return False
|
||||
if DATA_RTSP_TO_WEB_RTC not in self.hass.data:
|
||||
return False
|
||||
stream_source = await self.stream_source()
|
||||
return any(
|
||||
stream_source and stream_source.startswith(prefix)
|
||||
for prefix in RTSP_PREFIXES
|
||||
return []
|
||||
|
||||
return await async_get_supported_providers(self.hass, self)
|
||||
|
||||
@property
|
||||
def webrtc_providers(self) -> list[CameraWebRTCProvider]:
|
||||
"""Return the WebRTC providers."""
|
||||
return self._webrtc_providers
|
||||
|
||||
async def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
|
||||
"""Return the WebRTC client configuration adjustable per integration."""
|
||||
return WebRTCClientConfiguration()
|
||||
|
||||
@final
|
||||
async def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
|
||||
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
|
||||
config = await self._async_get_webrtc_client_configuration()
|
||||
|
||||
ice_servers = await asyncio.gather(
|
||||
*[server() for server in self.hass.data.get(DATA_ICE_SERVERS, [])]
|
||||
)
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class CameraView(HomeAssistantView):
|
||||
@ -885,7 +856,7 @@ async def ws_camera_stream(
|
||||
"""
|
||||
try:
|
||||
entity_id = msg["entity_id"]
|
||||
camera = _get_camera_from_entity_id(hass, entity_id)
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
url = await _async_stream_endpoint_url(hass, camera, fmt=msg["format"])
|
||||
connection.send_result(msg["id"], {"url": url})
|
||||
except HomeAssistantError as ex:
|
||||
@ -920,7 +891,7 @@ async def ws_camera_web_rtc_offer(
|
||||
"""
|
||||
entity_id = msg["entity_id"]
|
||||
offer = msg["offer"]
|
||||
camera = _get_camera_from_entity_id(hass, entity_id)
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
if camera.frontend_stream_type != StreamType.WEB_RTC:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
|
@ -17,16 +17,13 @@ from homeassistant.util.hass_dict import HassKey
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from . import Camera, RtspToWebRtcProviderType
|
||||
from . import Camera
|
||||
from .prefs import CameraPreferences
|
||||
|
||||
DOMAIN: Final = "camera"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN)
|
||||
|
||||
DATA_CAMERA_PREFS: HassKey[CameraPreferences] = HassKey("camera_prefs")
|
||||
DATA_RTSP_TO_WEB_RTC: HassKey[dict[str, RtspToWebRtcProviderType]] = HassKey(
|
||||
"rtsp_to_web_rtc"
|
||||
)
|
||||
|
||||
PREF_PRELOAD_STREAM: Final = "preload_stream"
|
||||
PREF_ORIENTATION: Final = "orientation"
|
||||
|
@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import _get_camera_from_entity_id
|
||||
from .const import DOMAIN
|
||||
from .helper import get_camera_from_entity_id
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@ -22,7 +22,7 @@ async def async_get_config_entry_diagnostics(
|
||||
if entity.domain != DOMAIN:
|
||||
continue
|
||||
try:
|
||||
camera = _get_camera_from_entity_id(hass, entity.entity_id)
|
||||
camera = get_camera_from_entity_id(hass, entity.entity_id)
|
||||
except HomeAssistantError:
|
||||
continue
|
||||
diagnostics[entity.entity_id] = (
|
||||
|
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 homeassistant.components.camera import Camera, CameraEntityFeature, StreamType
|
||||
from homeassistant.components.camera.webrtc import WebRTCClientConfiguration
|
||||
from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@ -210,3 +211,7 @@ class NestCamera(Camera):
|
||||
except ApiException as err:
|
||||
raise HomeAssistantError(f"Nest API error: {err}") from err
|
||||
return stream.answer_sdp
|
||||
|
||||
async def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
|
||||
"""Return the WebRTC client configuration adjustable per integration."""
|
||||
return WebRTCClientConfiguration(data_channel="dataSendChannel")
|
||||
|
@ -12,7 +12,7 @@ the offer/answer SDP protocol, other than as a signal path pass through.
|
||||
|
||||
Other integrations may use this integration with these steps:
|
||||
- Check if this integration is loaded
|
||||
- Call is_suported_stream_source for compatibility
|
||||
- Call is_supported_stream_source for compatibility
|
||||
- Call async_offer_for_stream_source to get back an answer for a client offer
|
||||
"""
|
||||
|
||||
@ -20,16 +20,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from rtsp_to_webrtc.client import get_adaptive_client
|
||||
from rtsp_to_webrtc.exceptions import ClientError, ResponseError
|
||||
from rtsp_to_webrtc.interface import WebRTCClientInterface
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import camera, websocket_api
|
||||
from homeassistant.components import camera
|
||||
from homeassistant.components.camera.webrtc import RTCIceServer, register_ice_server
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@ -57,7 +56,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except (TimeoutError, ClientError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
hass.data[DOMAIN][CONF_STUN_SERVER] = entry.options.get(CONF_STUN_SERVER, "")
|
||||
hass.data[DOMAIN][CONF_STUN_SERVER] = entry.options.get(CONF_STUN_SERVER)
|
||||
if server := entry.options.get(CONF_STUN_SERVER):
|
||||
|
||||
async def get_server() -> RTCIceServer:
|
||||
return RTCIceServer(urls=[server])
|
||||
|
||||
entry.async_on_unload(register_ice_server(hass, get_server))
|
||||
|
||||
async def async_offer_for_stream_source(
|
||||
stream_source: str,
|
||||
@ -85,8 +90,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
|
||||
websocket_api.async_register_command(hass, ws_get_settings)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@ -99,21 +102,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry when options change."""
|
||||
if hass.data[DOMAIN][CONF_STUN_SERVER] != entry.options.get(CONF_STUN_SERVER, ""):
|
||||
if hass.data[DOMAIN][CONF_STUN_SERVER] != entry.options.get(CONF_STUN_SERVER):
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "rtsp_to_webrtc/get_settings",
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_get_settings(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle the websocket command."""
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{CONF_STUN_SERVER: hass.data.get(DOMAIN, {}).get(CONF_STUN_SERVER, "")},
|
||||
)
|
||||
|
@ -221,6 +221,7 @@ FLOWS = {
|
||||
"gios",
|
||||
"github",
|
||||
"glances",
|
||||
"go2rtc",
|
||||
"goalzero",
|
||||
"gogogate2",
|
||||
"goodwe",
|
||||
|
@ -2247,6 +2247,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"go2rtc": {
|
||||
"name": "go2rtc",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"single_config_entry": true
|
||||
},
|
||||
"goalzero": {
|
||||
"name": "Goal Zero Yeti",
|
||||
"integration_type": "device",
|
||||
|
@ -38,6 +38,7 @@ httpx==0.27.2
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.4
|
||||
lru-dict==1.3.0
|
||||
mashumaro==3.13.1
|
||||
mutagen==1.47.0
|
||||
orjson==3.10.7
|
||||
packaging>=23.1
|
||||
@ -121,9 +122,6 @@ backoff>=2.0
|
||||
# v2 has breaking changes (#99218).
|
||||
pydantic==1.10.18
|
||||
|
||||
# Required for Python 3.12.4 compatibility (#119223).
|
||||
mashumaro>=3.13.1
|
||||
|
||||
# Breaks asyncio
|
||||
# https://github.com/pubnub/python/issues/130
|
||||
pubnub!=6.4.0
|
||||
|
@ -50,6 +50,7 @@ dependencies = [
|
||||
"ifaddr==0.2.0",
|
||||
"Jinja2==3.1.4",
|
||||
"lru-dict==1.3.0",
|
||||
"mashumaro==3.13.1",
|
||||
"PyJWT==2.9.0",
|
||||
# PyJWT has loose dependency. We want the latest one.
|
||||
"cryptography==43.0.1",
|
||||
|
@ -24,6 +24,7 @@ home-assistant-bluetooth==1.12.2
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.4
|
||||
lru-dict==1.3.0
|
||||
mashumaro==3.13.1
|
||||
PyJWT==2.9.0
|
||||
cryptography==43.0.1
|
||||
Pillow==10.4.0
|
||||
|
@ -981,6 +981,9 @@ gitterpy==0.1.7
|
||||
# homeassistant.components.glances
|
||||
glances-api==0.8.0
|
||||
|
||||
# homeassistant.components.go2rtc
|
||||
go2rtc-client==0.0.1b0
|
||||
|
||||
# homeassistant.components.goalzero
|
||||
goalzero==0.2.2
|
||||
|
||||
|
@ -831,6 +831,9 @@ gios==4.0.0
|
||||
# homeassistant.components.glances
|
||||
glances-api==0.8.0
|
||||
|
||||
# homeassistant.components.go2rtc
|
||||
go2rtc-client==0.0.1b0
|
||||
|
||||
# homeassistant.components.goalzero
|
||||
goalzero==0.2.2
|
||||
|
||||
|
@ -140,9 +140,6 @@ backoff>=2.0
|
||||
# v2 has breaking changes (#99218).
|
||||
pydantic==1.10.18
|
||||
|
||||
# Required for Python 3.12.4 compatibility (#119223).
|
||||
mashumaro>=3.13.1
|
||||
|
||||
# Breaks asyncio
|
||||
# https://github.com/pubnub/python/issues/130
|
||||
pubnub!=6.4.0
|
||||
|
@ -57,6 +57,21 @@ RUN \
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
# Needs to be redefined inside the FROM statement to be set for RUN commands
|
||||
ARG BUILD_ARCH
|
||||
# Get go2rtc binary
|
||||
RUN \
|
||||
case "${{BUILD_ARCH}}" in \
|
||||
"aarch64") go2rtc_suffix='arm64' ;; \
|
||||
"armhf") go2rtc_suffix='armv6' ;; \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${{BUILD_ARCH}} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
WORKDIR /config
|
||||
"""
|
||||
|
||||
@ -96,6 +111,8 @@ LABEL "com.github.actions.icon"="terminal"
|
||||
LABEL "com.github.actions.color"="gray-dark"
|
||||
"""
|
||||
|
||||
_GO2RTC_VERSION = "1.9.4"
|
||||
|
||||
|
||||
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
|
||||
package_versions: dict[str, str] = {}
|
||||
@ -176,7 +193,11 @@ def _generate_files(config: Config) -> list[File]:
|
||||
|
||||
return [
|
||||
File(
|
||||
DOCKERFILE_TEMPLATE.format(timeout=timeout, **package_versions),
|
||||
DOCKERFILE_TEMPLATE.format(
|
||||
timeout=timeout,
|
||||
**package_versions,
|
||||
go2rtc=_GO2RTC_VERSION,
|
||||
),
|
||||
config.root / "Dockerfile",
|
||||
),
|
||||
_generate_hassfest_dockerimage(config, timeout, package_versions),
|
||||
|
@ -59,7 +59,7 @@ async def test_camera(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
|
||||
|
||||
entity_id = f"{CAMERA_DOMAIN}.{NAME}"
|
||||
camera_entity = camera._get_camera_from_entity_id(hass, entity_id)
|
||||
camera_entity = camera.helper.get_camera_from_entity_id(hass, entity_id)
|
||||
assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi"
|
||||
assert (
|
||||
camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi"
|
||||
|
@ -8,6 +8,7 @@ from unittest.mock import Mock
|
||||
|
||||
EMPTY_8_6_JPEG = b"empty_8_6"
|
||||
WEBRTC_ANSWER = "a=sendonly"
|
||||
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
||||
|
||||
|
||||
def mock_turbo_jpeg(
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""Test helpers for camera."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from unittest.mock import PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .common import WEBRTC_ANSWER
|
||||
from .common import STREAM_SOURCE, WEBRTC_ANSWER
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -111,3 +111,19 @@ def mock_camera_with_no_name_fixture(mock_camera_with_device: None) -> Generator
|
||||
new_callable=PropertyMock(return_value=None),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_stream")
|
||||
async def mock_stream_fixture(hass: HomeAssistant) -> None:
|
||||
"""Initialize a demo camera platform with streaming."""
|
||||
assert await async_setup_component(hass, "stream", {"stream": {}})
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_stream_source")
|
||||
def mock_stream_source_fixture() -> Generator[AsyncMock]:
|
||||
"""Fixture to create an RTSP stream source."""
|
||||
with patch(
|
||||
"homeassistant.components.camera.Camera.stream_source",
|
||||
return_value=STREAM_SOURCE,
|
||||
) as mock_stream_source:
|
||||
yield mock_stream_source
|
||||
|
@ -27,7 +27,7 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_turbo_jpeg
|
||||
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, WEBRTC_ANSWER, mock_turbo_jpeg
|
||||
|
||||
from tests.common import (
|
||||
async_fire_time_changed,
|
||||
@ -36,19 +36,10 @@ from tests.common import (
|
||||
)
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
||||
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
|
||||
WEBRTC_OFFER = "v=0\r\n"
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_stream")
|
||||
def mock_stream_fixture(hass: HomeAssistant) -> None:
|
||||
"""Initialize a demo camera platform with streaming."""
|
||||
assert hass.loop.run_until_complete(
|
||||
async_setup_component(hass, "stream", {"stream": {}})
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="image_mock_url")
|
||||
async def image_mock_url_fixture(hass: HomeAssistant) -> None:
|
||||
"""Fixture for get_image tests."""
|
||||
@ -58,16 +49,6 @@ async def image_mock_url_fixture(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_stream_source")
|
||||
def mock_stream_source_fixture() -> Generator[AsyncMock]:
|
||||
"""Fixture to create an RTSP stream source."""
|
||||
with patch(
|
||||
"homeassistant.components.camera.Camera.stream_source",
|
||||
return_value=STREAM_SOURCE,
|
||||
) as mock_stream_source:
|
||||
yield mock_stream_source
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_hls_stream_source")
|
||||
async def mock_hls_stream_source_fixture() -> Generator[AsyncMock]:
|
||||
"""Fixture to create an HLS stream source."""
|
||||
|
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 rtsp_to_webrtc
|
||||
|
||||
from homeassistant.components.rtsp_to_webrtc import CONF_STUN_SERVER, DOMAIN
|
||||
from homeassistant.components.rtsp_to_webrtc import DOMAIN
|
||||
from homeassistant.components.websocket_api import TYPE_RESULT
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
@ -18,7 +18,6 @@ from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
@ -162,69 +161,3 @@ async def test_offer_failure(
|
||||
assert response["error"].get("code") == "web_rtc_offer_failed"
|
||||
assert "message" in response["error"]
|
||||
assert "RTSPtoWebRTC server communication failure" in response["error"]["message"]
|
||||
|
||||
|
||||
async def test_no_stun_server(
|
||||
hass: HomeAssistant,
|
||||
rtsp_to_webrtc_client: Any,
|
||||
setup_integration: ComponentSetup,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test successful setup and unload."""
|
||||
await setup_integration()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "rtsp_to_webrtc/get_settings",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response.get("id") == 2
|
||||
assert response.get("type") == TYPE_RESULT
|
||||
assert "result" in response
|
||||
assert response["result"].get("stun_server") == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_entry_options", [{CONF_STUN_SERVER: "example.com:1234"}]
|
||||
)
|
||||
async def test_stun_server(
|
||||
hass: HomeAssistant,
|
||||
rtsp_to_webrtc_client: Any,
|
||||
setup_integration: ComponentSetup,
|
||||
config_entry: MockConfigEntry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test successful setup and unload."""
|
||||
await setup_integration()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 3,
|
||||
"type": "rtsp_to_webrtc/get_settings",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response.get("id") == 3
|
||||
assert response.get("type") == TYPE_RESULT
|
||||
assert "result" in response
|
||||
assert response["result"].get("stun_server") == "example.com:1234"
|
||||
|
||||
# Simulate an options flow change, clearing the stun server and verify the change is reflected
|
||||
hass.config_entries.async_update_entry(config_entry, options={})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 4,
|
||||
"type": "rtsp_to_webrtc/get_settings",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response.get("id") == 4
|
||||
assert response.get("type") == TYPE_RESULT
|
||||
assert "result" in response
|
||||
assert response["result"].get("stun_server") == ""
|
||||
|
Loading…
x
Reference in New Issue
Block a user