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:
parent
50ccce7387
commit
675ee8e813
14 changed files with 1715 additions and 640 deletions
|
@ -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}
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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: ",
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue