Add async webrtc offer support (#127981)

* Add async webrtc offer support

* Create dataclass for messages

* Send session ID over websocket

* Fixes

* Rename

* Implement some review findings

* Add WebRTCError and small renames

* Use dedicated function instead of inspec

* Update go2rtc-client to 0.0.1b1

* Improve checking for sync offer

* Revert change as not needed anymore

* Typo

* Fix tests

* Add missing go2rtc tests

* Move webrtc offer tests to test_webrtc file

* Add ws camera/webrtc/candidate tests

* Add missing tests

* Implement suggestions

* Implement review changes

* rename

* Revert test to use ws endpoints

* Change doc string

* Don't import from submodule

* Get type form class name

* Update homeassistant/components/camera/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Adopt tests

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Fix tests

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Erik <erik@montnemery.com>
This commit is contained in:
Robert Resch 2024-10-28 15:46:15 +01:00 committed by GitHub
parent 50ccce7387
commit 675ee8e813
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1715 additions and 640 deletions

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import collections
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Coroutine
from contextlib import suppress
from dataclasses import asdict
from datetime import datetime, timedelta
@ -86,12 +86,20 @@ from .img_util import scale_jpeg_camera_image
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
from .webrtc import (
DATA_ICE_SERVERS,
CameraWebRTCLegacyProvider,
CameraWebRTCProvider,
WebRTCAnswer,
WebRTCCandidate, # noqa: F401
WebRTCClientConfiguration,
async_get_supported_providers,
WebRTCError,
WebRTCMessage, # noqa: F401
WebRTCSendMessage,
async_get_supported_legacy_provider,
async_get_supported_provider,
async_register_ice_servers,
async_register_rtsp_to_web_rtc_provider, # noqa: F401
ws_get_client_config,
async_register_webrtc_provider, # noqa: F401
async_register_ws,
)
_LOGGER = logging.getLogger(__name__)
@ -342,10 +350,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.register_view(CameraMjpegStream(component))
websocket_api.async_register_command(hass, ws_camera_stream)
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)
async_register_ws(hass)
await component.async_setup(config)
@ -463,7 +470,11 @@ 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._webrtc_providers: list[CameraWebRTCProvider] = []
self._webrtc_provider: CameraWebRTCProvider | None = None
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
self._webrtc_sync_offer = (
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
)
@cached_property
def entity_picture(self) -> str:
@ -537,7 +548,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._webrtc_providers:
if self._webrtc_provider or self._legacy_webrtc_provider:
return StreamType.WEB_RTC
return StreamType.HLS
@ -587,12 +598,66 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Integrations can override with a native WebRTC implementation.
"""
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"
)
async def async_handle_async_webrtc_offer(
self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
) -> None:
"""Handle the async WebRTC offer.
Async means that it could take some time to process the offer and responses/message
will be sent with the send_message callback.
This method is used by cameras with CameraEntityFeature.STREAM and StreamType.WEB_RTC.
An integration overriding this method must also implement async_on_webrtc_candidate.
Integrations can override with a native WebRTC implementation.
"""
if self._webrtc_sync_offer:
try:
answer = await self.async_handle_web_rtc_offer(offer_sdp)
except ValueError as ex:
_LOGGER.error("Error handling WebRTC offer: %s", ex)
send_message(
WebRTCError(
"webrtc_offer_failed",
str(ex),
)
)
except TimeoutError:
# This catch was already here and should stay through the deprecation
_LOGGER.error("Timeout handling WebRTC offer")
send_message(
WebRTCError(
"webrtc_offer_failed",
"Timeout handling WebRTC offer",
)
)
else:
if answer:
send_message(WebRTCAnswer(answer))
else:
_LOGGER.error("Error handling WebRTC offer: No answer")
send_message(
WebRTCError(
"webrtc_offer_failed",
"No answer on WebRTC offer",
)
)
return
if self._webrtc_provider:
await self._webrtc_provider.async_handle_async_webrtc_offer(
self, offer_sdp, session_id, send_message
)
return
if self._legacy_webrtc_provider and (
answer := await self._legacy_webrtc_provider.async_handle_web_rtc_offer(
self, offer_sdp
)
):
send_message(WebRTCAnswer(answer))
else:
raise HomeAssistantError("Camera does not support WebRTC")
def camera_image(
self, width: int | None = None, height: int | None = None
@ -702,38 +767,41 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
async def async_internal_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_internal_added_to_hass()
# 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._webrtc_providers = await self._async_get_supported_webrtc_providers()
await self.async_refresh_providers(write_state=False)
async def async_refresh_providers(self) -> None:
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
"""Determine if any of the registered providers are suitable for this entity.
This affects state attributes, so it should be invoked any time the registered
providers or inputs to the state attributes change.
Returns True if any state was updated (and needs to be written)
"""
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()
old_provider = self._webrtc_provider
new_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_provider
)
async def _async_get_supported_webrtc_providers(
self,
) -> list[CameraWebRTCProvider]:
"""Get the all providers that supports this camera."""
old_legacy_provider = self._legacy_webrtc_provider
new_legacy_provider = None
if new_provider is None:
# Only add the legacy provider if the new provider is not available
new_legacy_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_legacy_provider
)
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
self._webrtc_provider = new_provider
self._legacy_webrtc_provider = new_legacy_provider
if write_state:
self.async_write_ha_state()
async def _async_get_supported_webrtc_provider[_T](
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
) -> _T | None:
"""Get first provider that supports this camera."""
if CameraEntityFeature.STREAM not in self.supported_features_compat:
return []
return None
return await async_get_supported_providers(self.hass, self)
@property
def webrtc_providers(self) -> list[CameraWebRTCProvider]:
"""Return the WebRTC providers."""
return self._webrtc_providers
return await fn(self.hass, self)
async def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
"""Return the WebRTC client configuration adjustable per integration."""
@ -751,8 +819,25 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
]
config.configuration.ice_servers.extend(ice_servers)
config.get_candidates_upfront = (
self._webrtc_sync_offer or self._legacy_webrtc_provider is not None
)
return config
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
"""Handle a WebRTC candidate."""
if self._webrtc_provider:
await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate)
else:
raise HomeAssistantError("Cannot handle WebRTC candidate")
@callback
def close_webrtc_session(self, session_id: str) -> None:
"""Close a WebRTC session."""
if self._webrtc_provider:
self._webrtc_provider.async_close_session(session_id)
class CameraView(HomeAssistantView):
"""Base CameraView."""
@ -873,53 +958,6 @@ async def ws_camera_stream(
)
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/web_rtc_offer",
vol.Required("entity_id"): cv.entity_id,
vol.Required("offer"): str,
}
)
@websocket_api.async_response
async def ws_camera_web_rtc_offer(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle the signal path for a WebRTC stream.
This signal path is used to route the offer created by the client to the
camera device through the integration for negotiation on initial setup,
which returns an answer. The actual streaming is handled entirely between
the client and camera device.
Async friendly.
"""
entity_id = msg["entity_id"]
offer = msg["offer"]
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
try:
answer = await camera.async_handle_web_rtc_offer(offer)
except (HomeAssistantError, ValueError) as ex:
_LOGGER.error("Error handling WebRTC offer: %s", ex)
connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex))
except TimeoutError:
_LOGGER.error("Timeout handling WebRTC offer")
connection.send_error(
msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer"
)
else:
connection.send_result(msg["id"], {"answer": answer})
@websocket_api.websocket_command(
{vol.Required("type"): "camera/get_prefs", vol.Required("entity_id"): cv.entity_id}
)

View file

@ -4,7 +4,9 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from functools import cache, partial
import logging
from typing import TYPE_CHECKING, Any, Protocol
import voluptuous as vol
@ -12,8 +14,10 @@ from webrtc_models import RTCConfiguration, RTCIceServer
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ulid import ulid
from .const import DATA_COMPONENT, DOMAIN, StreamType
from .helper import get_camera_from_entity_id
@ -21,15 +25,72 @@ from .helper import get_camera_from_entity_id
if TYPE_CHECKING:
from . import Camera
_LOGGER = logging.getLogger(__name__)
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
"camera_web_rtc_providers"
"camera_webrtc_providers"
)
DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[set[CameraWebRTCLegacyProvider]] = HassKey(
"camera_webrtc_legacy_providers"
)
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
"camera_web_rtc_ice_servers"
"camera_webrtc_ice_servers"
)
_WEBRTC = "WebRTC"
@dataclass(frozen=True)
class WebRTCMessage:
"""Base class for WebRTC messages."""
@classmethod
@cache
def _get_type(cls) -> str:
_, _, name = cls.__name__.partition(_WEBRTC)
return name.lower()
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the message."""
data = asdict(self)
data["type"] = self._get_type()
return data
@dataclass(frozen=True)
class WebRTCSession(WebRTCMessage):
"""WebRTC session."""
session_id: str
@dataclass(frozen=True)
class WebRTCAnswer(WebRTCMessage):
"""WebRTC answer."""
answer: str
@dataclass(frozen=True)
class WebRTCCandidate(WebRTCMessage):
"""WebRTC candidate."""
candidate: str
@dataclass(frozen=True)
class WebRTCError(WebRTCMessage):
"""WebRTC error."""
code: str
message: str
type WebRTCSendMessage = Callable[[WebRTCMessage], None]
@dataclass(kw_only=True)
class WebRTCClientConfiguration:
"""WebRTC configuration for the client.
@ -39,11 +100,13 @@ class WebRTCClientConfiguration:
configuration: RTCConfiguration = field(default_factory=RTCConfiguration)
data_channel: str | None = None
get_candidates_upfront: bool = False
def to_frontend_dict(self) -> dict[str, Any]:
"""Return a dict that can be used by the frontend."""
data: dict[str, Any] = {
"configuration": self.configuration.to_dict(),
"getCandidatesUpfront": self.get_candidates_upfront,
}
if self.data_channel is not None:
data["dataChannel"] = self.data_channel
@ -53,6 +116,30 @@ class WebRTCClientConfiguration:
class CameraWebRTCProvider(Protocol):
"""WebRTC provider."""
@callback
def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
async def async_handle_async_webrtc_offer(
self,
camera: Camera,
offer_sdp: str,
session_id: str,
send_message: WebRTCSendMessage,
) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback."""
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
"""Handle the WebRTC candidate."""
@callback
def async_close_session(self, session_id: str) -> None:
"""Close the session."""
class CameraWebRTCLegacyProvider(Protocol):
"""WebRTC provider."""
async def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
@ -62,9 +149,10 @@ class CameraWebRTCProvider(Protocol):
"""Handle the WebRTC offer and return an answer."""
def async_register_webrtc_provider(
def _async_register_webrtc_provider[_T](
hass: HomeAssistant,
provider: CameraWebRTCProvider,
key: HassKey[set[_T]],
provider: _T,
) -> Callable[[], None]:
"""Register a WebRTC provider.
@ -73,9 +161,7 @@ def async_register_webrtc_provider(
if DOMAIN not in hass.data:
raise ValueError("Unexpected state, camera not loaded")
providers: set[CameraWebRTCProvider] = hass.data.setdefault(
DATA_WEBRTC_PROVIDERS, set()
)
providers = hass.data.setdefault(key, set())
@callback
def remove_provider() -> None:
@ -90,6 +176,18 @@ def async_register_webrtc_provider(
return remove_provider
@callback
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.
"""
return _async_register_webrtc_provider(hass, DATA_WEBRTC_PROVIDERS, provider)
async def _async_refresh_providers(hass: HomeAssistant) -> None:
"""Check all cameras for any state changes for registered providers."""
@ -99,6 +197,72 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
)
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/offer",
vol.Required("entity_id"): cv.entity_id,
vol.Required("offer"): str,
}
)
@websocket_api.async_response
async def ws_webrtc_offer(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle the signal path for a WebRTC stream.
This signal path is used to route the offer created by the client to the
camera device through the integration for negotiation on initial setup.
The ws endpoint returns a subscription id, where ice candidates and the
final answer will be returned.
The actual streaming is handled entirely between the client and camera device.
Async friendly.
"""
entity_id = msg["entity_id"]
offer = msg["offer"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_offer_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
session_id = ulid()
connection.subscriptions[msg["id"]] = partial(
camera.close_webrtc_session, session_id
)
connection.send_message(websocket_api.result_message(msg["id"]))
@callback
def send_message(message: WebRTCMessage) -> None:
"""Push a value to websocket."""
connection.send_message(
websocket_api.event_message(
msg["id"],
message.as_dict(),
)
)
send_message(WebRTCSession(session_id))
try:
await camera.async_handle_async_webrtc_offer(offer, session_id, send_message)
except HomeAssistantError as ex:
_LOGGER.error("Error handling WebRTC offer: %s", ex)
send_message(
WebRTCError(
"webrtc_offer_failed",
str(ex),
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/get_client_config",
@ -115,7 +279,7 @@ async def ws_get_client_config(
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"web_rtc_offer_failed",
"webrtc_get_client_config_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
@ -130,19 +294,74 @@ async def ws_get_client_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 []
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/candidate",
vol.Required("entity_id"): cv.entity_id,
vol.Required("session_id"): str,
vol.Required("candidate"): str,
}
)
@websocket_api.async_response
async def ws_candidate(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle WebRTC candidate 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"],
"webrtc_candidate_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
return [
provider
for provider in providers
if await provider.async_is_supported(stream_source)
]
await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"])
connection.send_message(websocket_api.result_message(msg["id"]))
@callback
def async_register_ws(hass: HomeAssistant) -> None:
"""Register camera webrtc ws endpoints."""
websocket_api.async_register_command(hass, ws_webrtc_offer)
websocket_api.async_register_command(hass, ws_get_client_config)
websocket_api.async_register_command(hass, ws_candidate)
async def _async_get_supported_provider[
_T: CameraWebRTCLegacyProvider | CameraWebRTCProvider
](hass: HomeAssistant, camera: Camera, key: HassKey[set[_T]]) -> _T | None:
"""Return the first supported provider for the camera."""
providers = hass.data.get(key)
if not providers or not (stream_source := await camera.stream_source()):
return None
for provider in providers:
if provider.async_is_supported(stream_source):
return provider
return None
async def async_get_supported_provider(
hass: HomeAssistant, camera: Camera
) -> CameraWebRTCProvider | None:
"""Return the first supported provider for the camera."""
return await _async_get_supported_provider(hass, camera, DATA_WEBRTC_PROVIDERS)
async def async_get_supported_legacy_provider(
hass: HomeAssistant, camera: Camera
) -> CameraWebRTCLegacyProvider | None:
"""Return the first supported provider for the camera."""
return await _async_get_supported_provider(
hass, camera, DATA_WEBRTC_LEGACY_PROVIDERS
)
@callback
@ -177,7 +396,7 @@ _RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]]
class _CameraRtspToWebRTCProvider(CameraWebRTCProvider):
class _CameraRtspToWebRTCProvider(CameraWebRTCLegacyProvider):
def __init__(self, fn: RtspToWebRtcProviderType) -> None:
"""Initialize the RTSP to WebRTC provider."""
self._fn = fn
@ -206,4 +425,6 @@ def async_register_rtsp_to_web_rtc_provider(
The first provider to satisfy the offer will be used.
"""
provider_instance = _CameraRtspToWebRTCProvider(provider)
return async_register_webrtc_provider(hass, provider_instance)
return _async_register_webrtc_provider(
hass, DATA_WEBRTC_LEGACY_PROVIDERS, provider_instance
)

View file

@ -3,16 +3,29 @@
import logging
import shutil
from go2rtc_client import Go2RtcClient, WebRTCSdpOffer
from go2rtc_client import Go2RtcRestClient
from go2rtc_client.ws import (
Go2RtcWsClient,
ReceiveMessages,
WebRTCAnswer,
WebRTCCandidate,
WebRTCOffer,
WsError,
)
import voluptuous as vol
from homeassistant.components.camera import Camera
from homeassistant.components.camera.webrtc import (
from homeassistant.components.camera import (
Camera,
CameraWebRTCProvider,
WebRTCAnswer as HAWebRTCAnswer,
WebRTCCandidate as HAWebRTCCandidate,
WebRTCError,
WebRTCMessage,
WebRTCSendMessage,
async_register_webrtc_provider,
)
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
@ -22,6 +35,7 @@ from .const import DOMAIN
from .server import Server
_LOGGER = logging.getLogger(__name__)
_SUPPORTED_STREAMS = frozenset(
(
"bubble",
@ -87,13 +101,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Validate the server URL
try:
client = Go2RtcClient(async_get_clientsession(hass), url)
client = Go2RtcRestClient(async_get_clientsession(hass), url)
await client.streams.list()
except Exception: # noqa: BLE001
_LOGGER.warning("Could not connect to go2rtc instance on %s", url)
return False
provider = WebRTCProvider(client)
provider = WebRTCProvider(hass, url)
async_register_webrtc_provider(hass, provider)
return True
@ -106,25 +120,71 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider."""
def __init__(self, client: Go2RtcClient) -> None:
def __init__(self, hass: HomeAssistant, url: str) -> None:
"""Initialize the WebRTC provider."""
self._client = client
self._hass = hass
self._url = url
self._session = async_get_clientsession(hass)
self._rest_client = Go2RtcRestClient(self._session, url)
self._sessions: dict[str, Go2RtcWsClient] = {}
async def async_is_supported(self, stream_source: str) -> bool:
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()
async def async_handle_async_webrtc_offer(
self,
camera: Camera,
offer_sdp: str,
session_id: str,
send_message: WebRTCSendMessage,
) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback."""
self._sessions[session_id] = ws_client = Go2RtcWsClient(
self._session, self._url, source=camera.entity_id
)
streams = await self._rest_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)
send_message(
WebRTCError(
"go2rtc_webrtc_offer_failed", "Camera has no stream source"
)
)
return
await self._rest_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
@callback
def on_messages(message: ReceiveMessages) -> None:
"""Handle messages."""
value: WebRTCMessage
match message:
case WebRTCCandidate():
value = HAWebRTCCandidate(message.candidate)
case WebRTCAnswer():
value = HAWebRTCAnswer(message.answer)
case WsError():
value = WebRTCError("go2rtc_webrtc_offer_failed", message.error)
case _:
_LOGGER.warning("Unknown message %s", message)
return
send_message(value)
ws_client.subscribe(on_messages)
await ws_client.send(WebRTCOffer(offer_sdp))
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
"""Handle the WebRTC candidate."""
if ws_client := self._sessions.get(session_id):
await ws_client.send(WebRTCCandidate(candidate))
else:
_LOGGER.debug("Unknown session %s. Ignoring candidate", session_id)
@callback
def async_close_session(self, session_id: str) -> None:
"""Close the session."""
ws_client = self._sessions.pop(session_id)
self._hass.async_create_task(ws_client.close())

View file

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system",
"iot_class": "local_polling",
"requirements": ["go2rtc-client==0.0.1b0"]
"requirements": ["go2rtc-client==0.0.1b1"]
}

View file

@ -986,7 +986,7 @@ gitterpy==0.1.7
glances-api==0.8.0
# homeassistant.components.go2rtc
go2rtc-client==0.0.1b0
go2rtc-client==0.0.1b1
# homeassistant.components.goalzero
goalzero==0.2.2

View file

@ -836,7 +836,7 @@ gios==5.0.0
glances-api==0.8.0
# homeassistant.components.go2rtc
go2rtc-client==0.0.1b0
go2rtc-client==0.0.1b1
# homeassistant.components.goalzero
goalzero==0.2.2

View file

@ -7,6 +7,7 @@ import pytest
from homeassistant.components import camera
from homeassistant.components.camera.const import StreamType
from homeassistant.components.camera.webrtc import WebRTCAnswer, WebRTCSendMessage
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
@ -56,23 +57,37 @@ def mock_camera_hls_fixture(mock_camera: None) -> Generator[None]:
yield
@pytest.fixture(name="mock_camera_web_rtc")
async def mock_camera_web_rtc_fixture(hass: HomeAssistant) -> AsyncGenerator[None]:
@pytest.fixture
async def mock_camera_webrtc_frontendtype_only(
hass: HomeAssistant,
) -> AsyncGenerator[None]:
"""Initialize a demo camera platform with WebRTC."""
assert await async_setup_component(
hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
)
await hass.async_block_till_done()
with (
patch(
"homeassistant.components.camera.Camera.frontend_stream_type",
new_callable=PropertyMock(return_value=StreamType.WEB_RTC),
),
patch(
"homeassistant.components.camera.Camera.async_handle_web_rtc_offer",
return_value=WEBRTC_ANSWER,
),
with patch(
"homeassistant.components.camera.Camera.frontend_stream_type",
new_callable=PropertyMock(return_value=StreamType.WEB_RTC),
):
yield
@pytest.fixture
async def mock_camera_webrtc(
mock_camera_webrtc_frontendtype_only: None,
) -> AsyncGenerator[None]:
"""Initialize a demo camera platform with WebRTC."""
async def async_handle_async_webrtc_offer(
offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
) -> None:
send_message(WebRTCAnswer(WEBRTC_ANSWER))
with patch(
"homeassistant.components.camera.Camera.async_handle_async_webrtc_offer",
side_effect=async_handle_async_webrtc_offer,
):
yield

View file

@ -1,6 +1,5 @@
"""The tests for the camera component."""
from collections.abc import Generator
from http import HTTPStatus
import io
from types import ModuleType
@ -28,7 +27,7 @@ from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, WEBRTC_ANSWER, mock_turbo_jpeg
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg
from tests.common import (
async_fire_time_changed,
@ -37,9 +36,6 @@ from tests.common import (
)
from tests.typing import ClientSessionGenerator, WebSocketGenerator
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
WEBRTC_OFFER = "v=0\r\n"
@pytest.fixture(name="image_mock_url")
async def image_mock_url_fixture(hass: HomeAssistant) -> None:
@ -50,34 +46,6 @@ async def image_mock_url_fixture(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
@pytest.fixture(name="mock_hls_stream_source")
async def mock_hls_stream_source_fixture() -> Generator[AsyncMock]:
"""Fixture to create an HLS stream source."""
with patch(
"homeassistant.components.camera.Camera.stream_source",
return_value=HLS_STREAM_SOURCE,
) as mock_hls_stream_source:
yield mock_hls_stream_source
async def provide_web_rtc_answer(stream_source: str, offer: str, stream_id: str) -> str:
"""Simulate an rtsp to webrtc provider."""
assert stream_source == STREAM_SOURCE
assert offer == WEBRTC_OFFER
return WEBRTC_ANSWER
@pytest.fixture(name="mock_rtsp_to_web_rtc")
def mock_rtsp_to_web_rtc_fixture(hass: HomeAssistant) -> Generator[Mock]:
"""Fixture that registers a mock rtsp to web_rtc provider."""
mock_provider = Mock(side_effect=provide_web_rtc_answer)
unsub = camera.async_register_rtsp_to_web_rtc_provider(
hass, "mock_domain", mock_provider
)
yield mock_provider
unsub()
@pytest.mark.usefixtures("image_mock_url")
async def test_get_image_from_camera(hass: HomeAssistant) -> None:
"""Grab an image from camera entity."""
@ -705,148 +673,6 @@ async def test_camera_proxy_stream(hass_client: ClientSessionGenerator) -> None:
assert response.status == HTTPStatus.BAD_GATEWAY
@pytest.mark.usefixtures("mock_camera_web_rtc")
async def test_websocket_web_rtc_offer(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test initiating a WebRTC stream with offer and answer."""
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 9,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["id"] == 9
assert response["type"] == TYPE_RESULT
assert response["success"]
assert response["result"]["answer"] == WEBRTC_ANSWER
@pytest.mark.usefixtures("mock_camera_web_rtc")
async def test_websocket_web_rtc_offer_invalid_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test WebRTC with a camera entity that does not exist."""
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 9,
"type": "camera/web_rtc_offer",
"entity_id": "camera.does_not_exist",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["id"] == 9
assert response["type"] == TYPE_RESULT
assert not response["success"]
@pytest.mark.usefixtures("mock_camera_web_rtc")
async def test_websocket_web_rtc_offer_missing_offer(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test WebRTC stream with missing required fields."""
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 9,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
}
)
response = await client.receive_json()
assert response["id"] == 9
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"]["code"] == "invalid_format"
@pytest.mark.usefixtures("mock_camera_web_rtc")
async def test_websocket_web_rtc_offer_failure(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test WebRTC stream that fails handling the offer."""
client = await hass_ws_client(hass)
with patch(
"homeassistant.components.camera.Camera.async_handle_web_rtc_offer",
side_effect=HomeAssistantError("offer failed"),
):
await client.send_json(
{
"id": 9,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["id"] == 9
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"]["code"] == "web_rtc_offer_failed"
assert response["error"]["message"] == "offer failed"
@pytest.mark.usefixtures("mock_camera_web_rtc")
async def test_websocket_web_rtc_offer_timeout(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test WebRTC stream with timeout handling the offer."""
client = await hass_ws_client(hass)
with patch(
"homeassistant.components.camera.Camera.async_handle_web_rtc_offer",
side_effect=TimeoutError(),
):
await client.send_json(
{
"id": 9,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["id"] == 9
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"]["code"] == "web_rtc_offer_failed"
assert response["error"]["message"] == "Timeout handling WebRTC offer"
@pytest.mark.usefixtures("mock_camera")
async def test_websocket_web_rtc_offer_invalid_stream_type(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test WebRTC initiating for a camera with a different stream_type."""
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 9,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["id"] == 9
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"]["code"] == "web_rtc_offer_failed"
@pytest.mark.usefixtures("mock_camera")
async def test_state_streaming(hass: HomeAssistant) -> None:
"""Camera state."""
@ -908,144 +734,6 @@ async def test_stream_unavailable(
assert demo_camera.state == camera.CameraState.STREAMING
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_rtsp_to_web_rtc_offer(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
mock_rtsp_to_web_rtc: Mock,
) -> None:
"""Test creating a web_rtc offer from an rstp provider."""
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 9,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response.get("id") == 9
assert response.get("type") == TYPE_RESULT
assert response.get("success")
assert "result" in response
assert response["result"] == {"answer": WEBRTC_ANSWER}
assert mock_rtsp_to_web_rtc.called
@pytest.mark.usefixtures(
"mock_camera",
"mock_hls_stream_source", # Not an RTSP stream source
"mock_rtsp_to_web_rtc",
)
async def test_unsupported_rtsp_to_web_rtc_stream_type(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test rtsp-to-webrtc is not registered for non-RTSP streams."""
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 10,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response.get("id") == 10
assert response.get("type") == TYPE_RESULT
assert "success" in response
assert not response["success"]
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_rtsp_to_web_rtc_provider_unregistered(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test creating a web_rtc offer from an rstp provider."""
mock_provider = Mock(side_effect=provide_web_rtc_answer)
unsub = camera.async_register_rtsp_to_web_rtc_provider(
hass, "mock_domain", mock_provider
)
client = await hass_ws_client(hass)
# Registered provider can handle the WebRTC offer
await client.send_json(
{
"id": 11,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["id"] == 11
assert response["type"] == TYPE_RESULT
assert response["success"]
assert response["result"]["answer"] == WEBRTC_ANSWER
assert mock_provider.called
mock_provider.reset_mock()
# Unregister provider, then verify the WebRTC offer cannot be handled
unsub()
await client.send_json(
{
"id": 12,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response.get("id") == 12
assert response.get("type") == TYPE_RESULT
assert "success" in response
assert not response["success"]
assert not mock_provider.called
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_rtsp_to_web_rtc_offer_not_accepted(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test a provider that can't satisfy the rtsp to webrtc offer."""
async def provide_none(stream_source: str, offer: str) -> str:
"""Simulate a provider that can't accept the offer."""
return None
mock_provider = Mock(side_effect=provide_none)
unsub = camera.async_register_rtsp_to_web_rtc_provider(
hass, "mock_domain", mock_provider
)
client = await hass_ws_client(hass)
# Registered provider can handle the WebRTC offer
await client.send_json(
{
"id": 11,
"type": "camera/web_rtc_offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["id"] == 11
assert response.get("type") == TYPE_RESULT
assert "success" in response
assert not response["success"]
assert mock_provider.called
unsub()
@pytest.mark.usefixtures("mock_camera")
async def test_use_stream_for_stills(
hass: HomeAssistant, hass_client: ClientSessionGenerator

View file

@ -65,8 +65,8 @@ async def test_browsing_mjpeg(hass: HomeAssistant) -> None:
assert item.children[0].title == "Demo camera without stream"
@pytest.mark.usefixtures("mock_camera_web_rtc")
async def test_browsing_web_rtc(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_camera_webrtc")
async def test_browsing_webrtc(hass: HomeAssistant) -> None:
"""Test browsing WebRTC camera media source."""
# 3 cameras:
# one only supports WebRTC (no stream source)

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
from go2rtc_client.client import _StreamClient, _WebRTCClient
from go2rtc_client.rest import _StreamClient, _WebRTCClient
import pytest
from homeassistant.components.go2rtc.server import Server
@ -12,11 +12,11 @@ GO2RTC_PATH = "homeassistant.components.go2rtc"
@pytest.fixture
def mock_client() -> Generator[AsyncMock]:
"""Mock a go2rtc client."""
def rest_client() -> Generator[AsyncMock]:
"""Mock a go2rtc rest client."""
with (
patch(
"homeassistant.components.go2rtc.Go2RtcClient",
"homeassistant.components.go2rtc.Go2RtcRestClient",
) as mock_client,
):
client = mock_client.return_value
@ -26,7 +26,16 @@ def mock_client() -> Generator[AsyncMock]:
@pytest.fixture
def mock_server_start() -> Generator[AsyncMock]:
def ws_client() -> Generator[Mock]:
"""Mock a go2rtc websocket client."""
with patch(
"homeassistant.components.go2rtc.Go2RtcWsClient", autospec=True
) as ws_client_mock:
yield ws_client_mock.return_value
@pytest.fixture
def server_start() -> Generator[AsyncMock]:
"""Mock start of a go2rtc server."""
with (
patch(f"{GO2RTC_PATH}.server.asyncio.create_subprocess_exec") as mock_subproc,
@ -41,7 +50,7 @@ def mock_server_start() -> Generator[AsyncMock]:
@pytest.fixture
def mock_server_stop() -> Generator[AsyncMock]:
def server_stop() -> Generator[AsyncMock]:
"""Mock stop of a go2rtc server."""
with (
patch(
@ -52,7 +61,7 @@ def mock_server_stop() -> Generator[AsyncMock]:
@pytest.fixture
def mock_server(mock_server_start, mock_server_stop) -> Generator[AsyncMock]:
def server(server_start, server_stop) -> Generator[AsyncMock]:
"""Mock a go2rtc server."""
with patch(f"{GO2RTC_PATH}.Server", wraps=Server) as mock_server:
yield mock_server

View file

@ -1,25 +1,37 @@
"""The tests for the go2rtc component."""
from collections.abc import Callable, Generator
import logging
from typing import NamedTuple
from unittest.mock import AsyncMock, Mock, patch
from go2rtc_client import Stream, WebRTCSdpAnswer, WebRTCSdpOffer
from go2rtc_client import Stream
from go2rtc_client.models import Producer
from go2rtc_client.ws import (
ReceiveMessages,
WebRTCAnswer,
WebRTCCandidate,
WebRTCOffer,
WsError,
)
import pytest
from homeassistant.components.camera import (
DOMAIN as CAMERA_DOMAIN,
Camera,
CameraEntityFeature,
StreamType,
WebRTCAnswer as HAWebRTCAnswer,
WebRTCCandidate as HAWebRTCCandidate,
WebRTCError,
WebRTCMessage,
WebRTCSendMessage,
)
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, ConfigFlow
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
@ -64,12 +76,6 @@ class MockCamera(Camera):
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."""
@ -110,12 +116,23 @@ def mock_is_docker_env(is_docker_env) -> Generator[Mock]:
yield mock_is_docker_env
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
rest_client: AsyncMock,
mock_is_docker_env,
mock_get_binary,
server: Mock,
) -> None:
"""Initialize the go2rtc integration."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@pytest.fixture
async def init_test_integration(
hass: HomeAssistant,
integration_config_entry: ConfigEntry,
integration_entity: MockCamera,
) -> None:
) -> MockCamera:
"""Initialize components."""
async def async_setup_entry_init(
@ -144,8 +161,9 @@ async def init_test_integration(
async_unload_entry=async_unload_entry_init,
),
)
test_camera = MockCamera()
setup_test_component_platform(
hass, CAMERA_DOMAIN, [integration_entity], from_config_entry=True
hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True
)
mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock())
@ -153,54 +171,66 @@ async def init_test_integration(
assert await hass.config_entries.async_setup(integration_config_entry.entry_id)
await hass.async_block_till_done()
return integration_config_entry
return test_camera
async def _test_setup(
async def _test_setup_and_signaling(
hass: HomeAssistant,
mock_client: AsyncMock,
rest_client: AsyncMock,
ws_client: Mock,
config: ConfigType,
after_setup_fn: Callable[[], None],
camera: MockCamera,
) -> None:
"""Test the go2rtc config entry."""
entity_id = "camera.test"
camera = get_camera_from_entity_id(hass, entity_id)
entity_id = camera.entity_id
assert camera.frontend_stream_type == StreamType.HLS
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
after_setup_fn()
mock_client.webrtc.forward_whep_sdp_offer.return_value = WebRTCSdpAnswer(ANSWER_SDP)
receive_message_callback = Mock(spec_set=WebRTCSendMessage)
answer = await camera.async_handle_web_rtc_offer(OFFER_SDP)
assert answer == ANSWER_SDP
async def test() -> None:
await camera.async_handle_async_webrtc_offer(
OFFER_SDP, "session_id", receive_message_callback
)
ws_client.send.assert_called_once_with(WebRTCOffer(OFFER_SDP))
ws_client.subscribe.assert_called_once()
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")
# Simulate the answer from the go2rtc server
callback = ws_client.subscribe.call_args[0][0]
callback(WebRTCAnswer(ANSWER_SDP))
receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP))
await test()
rest_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 = {
rest_client.streams.add.reset_mock()
rest_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)
receive_message_callback.reset_mock()
ws_client.reset_mock()
await test()
rest_client.streams.add.assert_not_called()
assert isinstance(camera._webrtc_provider, WebRTCProvider)
# Set stream source to None and provider should be skipped
mock_client.streams.list.return_value = {}
rest_client.streams.list.return_value = {}
receive_message_callback.reset_mock()
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)
await camera.async_handle_async_webrtc_offer(
OFFER_SDP, "session_id", receive_message_callback
)
receive_message_callback.assert_called_once_with(
WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source")
)
@pytest.mark.usefixtures(
@ -208,21 +238,25 @@ async def _test_setup(
)
async def test_setup_go_binary(
hass: HomeAssistant,
mock_client: AsyncMock,
mock_server: AsyncMock,
mock_server_start: Mock,
mock_server_stop: Mock,
rest_client: AsyncMock,
ws_client: Mock,
server: AsyncMock,
server_start: Mock,
server_stop: Mock,
init_test_integration: MockCamera,
) -> None:
"""Test the go2rtc config entry with binary."""
def after_setup() -> None:
mock_server.assert_called_once_with(hass, "/usr/bin/go2rtc")
mock_server_start.assert_called_once()
server.assert_called_once_with(hass, "/usr/bin/go2rtc")
server_start.assert_called_once()
await _test_setup(hass, mock_client, {DOMAIN: {}}, after_setup)
await _test_setup_and_signaling(
hass, rest_client, ws_client, {DOMAIN: {}}, after_setup, init_test_integration
)
await hass.async_stop()
mock_server_stop.assert_called_once()
server_stop.assert_called_once()
@pytest.mark.parametrize(
@ -232,11 +266,12 @@ async def test_setup_go_binary(
(None, False),
],
)
@pytest.mark.usefixtures("init_test_integration")
async def test_setup_go(
hass: HomeAssistant,
mock_client: AsyncMock,
mock_server: Mock,
rest_client: AsyncMock,
ws_client: Mock,
server: Mock,
init_test_integration: MockCamera,
mock_get_binary: Mock,
mock_is_docker_env: Mock,
) -> None:
@ -244,13 +279,150 @@ async def test_setup_go(
config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}}
def after_setup() -> None:
mock_server.assert_not_called()
server.assert_not_called()
await _test_setup(hass, mock_client, config, after_setup)
await _test_setup_and_signaling(
hass, rest_client, ws_client, config, after_setup, init_test_integration
)
mock_get_binary.assert_not_called()
mock_get_binary.assert_not_called()
mock_server.assert_not_called()
server.assert_not_called()
class Callbacks(NamedTuple):
"""Callbacks for the test."""
on_message: Mock
send_message: Mock
@pytest.fixture
async def message_callbacks(
ws_client: Mock,
init_test_integration: MockCamera,
) -> Callbacks:
"""Prepare and return receive message callback."""
receive_callback = Mock(spec_set=WebRTCSendMessage)
await init_test_integration.async_handle_async_webrtc_offer(
OFFER_SDP, "session_id", receive_callback
)
ws_client.send.assert_called_once_with(WebRTCOffer(OFFER_SDP))
ws_client.subscribe.assert_called_once()
# Simulate messages from the go2rtc server
send_callback = ws_client.subscribe.call_args[0][0]
return Callbacks(receive_callback, send_callback)
@pytest.mark.parametrize(
("message", "expected_message"),
[
(
WebRTCCandidate("candidate"),
HAWebRTCCandidate("candidate"),
),
(
WebRTCAnswer(ANSWER_SDP),
HAWebRTCAnswer(ANSWER_SDP),
),
(
WsError("error"),
WebRTCError("go2rtc_webrtc_offer_failed", "error"),
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_receiving_messages_from_go2rtc_server(
message_callbacks: Callbacks,
message: ReceiveMessages,
expected_message: WebRTCMessage,
) -> None:
"""Test receiving message from go2rtc server."""
on_message, send_message = message_callbacks
send_message(message)
on_message.assert_called_once_with(expected_message)
@pytest.mark.usefixtures("init_integration")
async def test_receiving_unknown_message_from_go2rtc_server(
message_callbacks: Callbacks,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test receiving unknown message from go2rtc server."""
on_message, send_message = message_callbacks
send_message({"type": "unknown"})
on_message.assert_not_called()
assert (
"homeassistant.components.go2rtc",
logging.WARNING,
"Unknown message {'type': 'unknown'}",
) in caplog.record_tuples
@pytest.mark.usefixtures("init_integration")
async def test_on_candidate(
ws_client: Mock,
init_test_integration: MockCamera,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test frontend sending candidate to go2rtc server."""
camera = init_test_integration
session_id = "session_id"
# Session doesn't exist
await camera.async_on_webrtc_candidate(session_id, "candidate")
assert (
"homeassistant.components.go2rtc",
logging.DEBUG,
f"Unknown session {session_id}. Ignoring candidate",
) in caplog.record_tuples
caplog.clear()
# Store session
await init_test_integration.async_handle_async_webrtc_offer(
OFFER_SDP, session_id, Mock()
)
ws_client.send.assert_called_once_with(WebRTCOffer(OFFER_SDP))
ws_client.reset_mock()
await camera.async_on_webrtc_candidate(session_id, "candidate")
ws_client.send.assert_called_once_with(WebRTCCandidate("candidate"))
assert caplog.record_tuples == []
@pytest.mark.usefixtures("init_integration")
async def test_close_session(
ws_client: Mock,
init_test_integration: MockCamera,
) -> None:
"""Test closing session."""
camera = init_test_integration
session_id = "session_id"
# Session doesn't exist
with pytest.raises(KeyError):
camera.close_webrtc_session(session_id)
ws_client.close.assert_not_called()
# Store session
await init_test_integration.async_handle_async_webrtc_offer(
OFFER_SDP, session_id, Mock()
)
ws_client.send.assert_called_once_with(WebRTCOffer(OFFER_SDP))
# Close session
camera.close_webrtc_session(session_id)
ws_client.close.assert_called_once()
# Close again should raise an error
ws_client.reset_mock()
with pytest.raises(KeyError):
camera.close_webrtc_session(session_id)
ws_client.close.assert_not_called()
ERR_BINARY_NOT_FOUND = "Could not find go2rtc docker binary"
@ -288,7 +460,7 @@ async def test_non_user_setup_with_error(
({DOMAIN: {CONF_URL: "http://localhost:1984/"}}, None, True, ERR_CONNECT),
],
)
@pytest.mark.usefixtures("mock_get_binary", "mock_is_docker_env", "mock_server")
@pytest.mark.usefixtures("mock_get_binary", "mock_is_docker_env", "server")
async def test_setup_with_error(
hass: HomeAssistant,
config: ConfigType,

View file

@ -577,11 +577,11 @@ async def test_refresh_expired_stream_failure(
assert create_stream.called
@pytest.mark.usefixtures("webrtc_camera_device")
async def test_camera_web_rtc(
hass: HomeAssistant,
auth,
hass_ws_client: WebSocketGenerator,
webrtc_camera_device,
setup_platform,
) -> None:
"""Test a basic camera that supports web rtc."""
@ -606,31 +606,43 @@ async def test_camera_web_rtc(
assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 5,
"type": "camera/web_rtc_offer",
"type": "camera/webrtc/offer",
"entity_id": "camera.my_camera",
"offer": "a=recvonly",
}
)
msg = await client.receive_json()
assert msg["id"] == 5
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"]["answer"] == "v=0\r\ns=-\r\n"
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
# Session id
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"]["type"] == "session"
# Answer
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"] == {
"type": "answer",
"answer": "v=0\r\ns=-\r\n",
}
# Nest WebRTC cameras return a placeholder
await async_get_image(hass)
await async_get_image(hass, width=1024, height=768)
@pytest.mark.usefixtures("auth", "camera_device")
async def test_camera_web_rtc_unsupported(
hass: HomeAssistant,
auth,
hass_ws_client: WebSocketGenerator,
camera_device,
setup_platform,
) -> None:
"""Test a basic camera that supports web rtc."""
@ -643,28 +655,28 @@ async def test_camera_web_rtc_unsupported(
assert cam.attributes["frontend_stream_type"] == StreamType.HLS
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 5,
"type": "camera/web_rtc_offer",
"type": "camera/webrtc/offer",
"entity_id": "camera.my_camera",
"offer": "a=recvonly",
}
)
msg = await client.receive_json()
assert msg["id"] == 5
assert msg["type"] == TYPE_RESULT
assert not msg["success"]
assert msg["error"]["code"] == "web_rtc_offer_failed"
assert msg["error"]["message"].startswith("Camera does not support WebRTC")
assert msg["error"] == {
"code": "webrtc_offer_failed",
"message": "Camera does not support WebRTC, frontend_stream_type=hls",
}
@pytest.mark.usefixtures("webrtc_camera_device")
async def test_camera_web_rtc_offer_failure(
hass: HomeAssistant,
auth,
hass_ws_client: WebSocketGenerator,
webrtc_camera_device,
setup_platform,
) -> None:
"""Test a basic camera that supports web rtc."""
@ -679,30 +691,43 @@ async def test_camera_web_rtc_offer_failure(
assert cam.state == CameraState.STREAMING
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 5,
"type": "camera/web_rtc_offer",
"type": "camera/webrtc/offer",
"entity_id": "camera.my_camera",
"offer": "a=recvonly",
}
)
msg = await client.receive_json()
assert msg["id"] == 5
assert msg["type"] == TYPE_RESULT
assert not msg["success"]
assert msg["error"]["code"] == "web_rtc_offer_failed"
assert msg["error"]["message"].startswith("Nest API error")
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
# Session id
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"]["type"] == "session"
# Answer
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"] == {
"type": "error",
"code": "webrtc_offer_failed",
"message": "Nest API error: Bad Request response from API (400)",
}
@pytest.mark.usefixtures("mock_create_stream")
async def test_camera_multiple_streams(
hass: HomeAssistant,
auth,
hass_ws_client: WebSocketGenerator,
create_device,
setup_platform,
mock_create_stream,
) -> None:
"""Test a camera supporting multiple stream types."""
expiration = utcnow() + datetime.timedelta(seconds=100)
@ -751,17 +776,30 @@ async def test_camera_multiple_streams(
# WebRTC stream
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 5,
"type": "camera/web_rtc_offer",
"type": "camera/webrtc/offer",
"entity_id": "camera.my_camera",
"offer": "a=recvonly",
}
)
msg = await client.receive_json()
assert msg["id"] == 5
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"]["answer"] == "v=0\r\ns=-\r\n"
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
# Session id
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"]["type"] == "session"
# Answer
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"] == {
"type": "answer",
"answer": "v=0\r\ns=-\r\n",
}

View file

@ -86,12 +86,11 @@ async def test_setup_communication_failure(
assert entries[0].state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("mock_camera", "rtsp_to_webrtc_client")
async def test_offer_for_stream_source(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_ws_client: WebSocketGenerator,
mock_camera: Any,
rtsp_to_webrtc_client: Any,
setup_integration: ComponentSetup,
) -> None:
"""Test successful response from RTSPtoWebRTC server."""
@ -103,21 +102,33 @@ async def test_offer_for_stream_source(
)
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 1,
"type": "camera/web_rtc_offer",
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": OFFER_SDP,
}
)
response = await client.receive_json()
assert response.get("id") == 1
assert response.get("type") == TYPE_RESULT
assert response.get("success")
assert "result" in response
assert response["result"].get("answer") == ANSWER_SDP
assert "error" not in response
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
# Session id
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"]["type"] == "session"
# Answer
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"] == {
"type": "answer",
"answer": ANSWER_SDP,
}
# Validate request parameters were sent correctly
assert len(aioclient_mock.mock_calls) == 1
@ -127,12 +138,11 @@ async def test_offer_for_stream_source(
}
@pytest.mark.usefixtures("mock_camera", "rtsp_to_webrtc_client")
async def test_offer_failure(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_ws_client: WebSocketGenerator,
mock_camera: Any,
rtsp_to_webrtc_client: Any,
setup_integration: ComponentSetup,
) -> None:
"""Test a transient failure talking to RTSPtoWebRTC server."""
@ -144,20 +154,31 @@ async def test_offer_failure(
)
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 2,
"type": "camera/web_rtc_offer",
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": OFFER_SDP,
}
)
response = await client.receive_json()
assert response.get("id") == 2
assert response.get("type") == TYPE_RESULT
assert "success" in response
assert not response.get("success")
assert "error" in response
assert response["error"].get("code") == "web_rtc_offer_failed"
assert "message" in response["error"]
assert "RTSPtoWebRTC server communication failure" in response["error"]["message"]
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
# Session id
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"]["type"] == "session"
# Answer
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"] == {
"type": "error",
"code": "webrtc_offer_failed",
"message": "RTSPtoWebRTC server communication failure: ",
}