core/tests/components/camera/test_webrtc.py

1297 lines
41 KiB
Python

"""Test camera WebRTC."""
from collections.abc import AsyncGenerator, Generator
import logging
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
import pytest
from webrtc_models import RTCIceCandidate, RTCIceCandidateInit, RTCIceServer
from homeassistant.components.camera import (
DATA_ICE_SERVERS,
DOMAIN as CAMERA_DOMAIN,
Camera,
CameraEntityFeature,
CameraWebRTCProvider,
StreamType,
WebRTCAnswer,
WebRTCCandidate,
WebRTCError,
WebRTCMessage,
WebRTCSendMessage,
async_get_supported_legacy_provider,
async_register_ice_servers,
async_register_rtsp_to_web_rtc_provider,
async_register_webrtc_provider,
get_camera_from_entity_id,
)
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.core import HomeAssistant, callback
from homeassistant.core_config import async_process_ha_core_config
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider
from tests.common import (
MockConfigEntry,
MockModule,
mock_config_flow,
mock_integration,
mock_platform,
setup_test_component_platform,
)
from tests.typing import WebSocketGenerator
WEBRTC_OFFER = "v=0\r\n"
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
TEST_INTEGRATION_DOMAIN = "test"
class Go2RTCProvider(SomeTestProvider):
"""go2rtc provider."""
@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return "go2rtc"
class MockCamera(Camera):
"""Mock Camera Entity."""
_attr_name = "Test"
_attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM
def __init__(self) -> None:
"""Initialize the mock entity."""
super().__init__()
self._sync_answer: str | None | Exception = WEBRTC_ANSWER
def set_sync_answer(self, value: str | None | Exception) -> None:
"""Set sync offer answer."""
self._sync_answer = value
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
"""Handle the WebRTC offer and return the answer."""
if isinstance(self._sync_answer, Exception):
raise self._sync_answer
return self._sync_answer
async def stream_source(self) -> str | None:
"""Return the source of the stream.
This is used by cameras with CameraEntityFeature.STREAM
and StreamType.HLS.
"""
return "rtsp://stream"
@pytest.fixture
async def init_test_integration(
hass: HomeAssistant,
) -> MockCamera:
"""Initialize components."""
entry = MockConfigEntry(domain=TEST_INTEGRATION_DOMAIN)
entry.add_to_hass(hass)
async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Set up test config entry."""
await hass.config_entries.async_forward_entry_setups(
config_entry, [CAMERA_DOMAIN]
)
return True
async def async_unload_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Unload test config entry."""
await hass.config_entries.async_forward_entry_unload(
config_entry, CAMERA_DOMAIN
)
return True
mock_integration(
hass,
MockModule(
TEST_INTEGRATION_DOMAIN,
async_setup_entry=async_setup_entry_init,
async_unload_entry=async_unload_entry_init,
),
)
test_camera = MockCamera()
setup_test_component_platform(
hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True
)
mock_platform(hass, f"{TEST_INTEGRATION_DOMAIN}.config_flow", Mock())
with mock_config_flow(TEST_INTEGRATION_DOMAIN, ConfigFlow):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return test_camera
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_async_register_webrtc_provider(
hass: HomeAssistant,
) -> None:
"""Test registering a WebRTC provider."""
camera = get_camera_from_entity_id(hass, "camera.demo_camera")
assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
provider = SomeTestProvider()
unregister = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
assert camera.camera_capabilities.frontend_stream_types == {
StreamType.HLS,
StreamType.WEB_RTC,
}
# Mark stream as unsupported
provider._is_supported = False
# Manually refresh the provider
await camera.async_refresh_providers()
assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
# Mark stream as supported
provider._is_supported = True
# Manually refresh the provider
await camera.async_refresh_providers()
assert camera.camera_capabilities.frontend_stream_types == {
StreamType.HLS,
StreamType.WEB_RTC,
}
unregister()
await hass.async_block_till_done()
assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_async_register_webrtc_provider_twice(
hass: HomeAssistant,
register_test_provider: SomeTestProvider,
) -> None:
"""Test registering a WebRTC provider twice should raise."""
with pytest.raises(ValueError, match="Provider already registered"):
async_register_webrtc_provider(hass, register_test_provider)
async def test_async_register_webrtc_provider_camera_not_loaded(
hass: HomeAssistant,
) -> None:
"""Test registering a WebRTC provider when camera is not loaded."""
with pytest.raises(ValueError, match="Unexpected state, camera not loaded"):
async_register_webrtc_provider(hass, SomeTestProvider())
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_async_register_ice_server(
hass: HomeAssistant,
) -> None:
"""Test registering an ICE server."""
# Clear any existing ICE servers
hass.data[DATA_ICE_SERVERS].clear()
called = 0
@callback
def get_ice_servers() -> list[RTCIceServer]:
nonlocal called
called += 1
return [
RTCIceServer(urls="stun:example.com"),
RTCIceServer(urls="turn:example.com"),
]
unregister = async_register_ice_servers(hass, get_ice_servers)
assert not called
camera = get_camera_from_entity_id(hass, "camera.async")
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [
RTCIceServer(urls="stun:example.com"),
RTCIceServer(urls="turn:example.com"),
]
assert called == 1
# register another ICE server
called_2 = 0
@callback
def get_ice_servers_2() -> list[RTCIceServer]:
nonlocal called_2
called_2 += 1
return [
RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
)
]
unregister_2 = async_register_ice_servers(hass, get_ice_servers_2)
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [
RTCIceServer(urls="stun:example.com"),
RTCIceServer(urls="turn:example.com"),
RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
),
]
assert called == 2
assert called_2 == 1
# unregister the first ICE server
unregister()
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [
RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
),
]
assert called == 2
assert called_2 == 2
# unregister the second ICE server
unregister_2()
config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == []
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test get WebRTC client config."""
await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
)
msg = await client.receive_json()
# Assert WebSocket response
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"] == {
"configuration": {
"iceServers": [
{
"urls": [
"stun:stun.home-assistant.io:80",
"stun:stun.home-assistant.io:3478",
]
},
],
},
"getCandidatesUpfront": False,
}
@callback
def get_ice_server() -> list[RTCIceServer]:
return [
RTCIceServer(
urls=["stun:example2.com", "turn:example2.com"],
username="user",
credential="pass",
)
]
async_register_ice_servers(hass, get_ice_server)
await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
)
msg = await client.receive_json()
# Assert WebSocket response
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"] == {
"configuration": {
"iceServers": [
{
"urls": [
"stun:stun.home-assistant.io:80",
"stun:stun.home-assistant.io:3478",
]
},
{
"urls": ["stun:example2.com", "turn:example2.com"],
"username": "user",
"credential": "pass",
},
],
},
"getCandidatesUpfront": False,
}
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config_sync_offer(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test get WebRTC client config, when camera is supporting sync offer."""
await async_setup_component(hass, "camera", {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"}
)
msg = await client.receive_json()
# Assert WebSocket response
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"] == {
"configuration": {},
"getCandidatesUpfront": True,
}
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config_custom_config(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test get WebRTC client config."""
await async_process_ha_core_config(
hass,
{"webrtc": {"ice_servers": [{"url": "stun:custom_stun_server:3478"}]}},
)
await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
)
msg = await client.receive_json()
# Assert WebSocket response
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"] == {
"configuration": {"iceServers": [{"urls": ["stun:custom_stun_server:3478"]}]},
"getCandidatesUpfront": False,
}
@pytest.mark.usefixtures("mock_camera")
async def test_ws_get_client_config_no_rtc_camera(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test get WebRTC client config."""
await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"}
)
msg = await client.receive_json()
# Assert WebSocket response
assert msg["type"] == TYPE_RESULT
assert not msg["success"]
assert msg["error"] == {
"code": "webrtc_get_client_config_failed",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}
async def provide_webrtc_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_webrtc")
def mock_rtsp_to_webrtc_fixture(hass: HomeAssistant) -> Generator[Mock]:
"""Fixture that registers a mock rtsp to webrtc provider."""
mock_provider = Mock(side_effect=provide_webrtc_answer)
unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider)
yield mock_provider
unsub()
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_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_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.async",
"offer": WEBRTC_OFFER,
}
)
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": WEBRTC_ANSWER,
}
# Unsubscribe/Close session
await client.send_json_auto_id(
{
"type": "unsubscribe_events",
"subscription": subscription_id,
}
)
msg = await client.receive_json()
assert msg["success"]
@pytest.mark.filterwarnings(
"ignore:Using RTCIceCandidate is deprecated. Use RTCIceCandidateInit instead"
)
@pytest.mark.usefixtures("mock_stream_source", "mock_camera")
async def test_websocket_webrtc_offer_webrtc_provider_deprecated(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
register_test_provider: SomeTestProvider,
) -> None:
"""Test initiating a WebRTC stream with a webrtc provider with the deprecated class."""
await _test_websocket_webrtc_offer_webrtc_provider(
hass,
hass_ws_client,
register_test_provider,
WebRTCCandidate(RTCIceCandidate("candidate")),
{"type": "candidate", "candidate": {"candidate": "candidate"}},
)
@pytest.mark.parametrize(
("message", "expected_frontend_message"),
[
(
WebRTCCandidate(RTCIceCandidateInit("candidate")),
{
"type": "candidate",
"candidate": {"candidate": "candidate", "sdpMLineIndex": 0},
},
),
(
WebRTCError("webrtc_offer_failed", "error"),
{"type": "error", "code": "webrtc_offer_failed", "message": "error"},
),
(WebRTCAnswer("answer"), {"type": "answer", "answer": "answer"}),
],
ids=["candidate", "error", "answer"],
)
@pytest.mark.usefixtures("mock_stream_source", "mock_camera")
async def test_websocket_webrtc_offer_webrtc_provider(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
register_test_provider: SomeTestProvider,
message: WebRTCMessage,
expected_frontend_message: dict[str, Any],
) -> None:
"""Test initiating a WebRTC stream with a webrtc provider."""
await _test_websocket_webrtc_offer_webrtc_provider(
hass,
hass_ws_client,
register_test_provider,
message,
expected_frontend_message,
)
async def _test_websocket_webrtc_offer_webrtc_provider(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
register_test_provider: SomeTestProvider,
message: WebRTCMessage,
expected_frontend_message: dict[str, Any],
) -> None:
"""Test initiating a WebRTC stream with a webrtc provider."""
client = await hass_ws_client(hass)
with (
patch.object(
register_test_provider, "async_handle_async_webrtc_offer", autospec=True
) as mock_async_handle_async_webrtc_offer,
patch.object(
register_test_provider, "async_close_session", autospec=True
) as mock_async_close_session,
):
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
mock_async_handle_async_webrtc_offer.assert_called_once()
assert mock_async_handle_async_webrtc_offer.call_args[0][1] == WEBRTC_OFFER
send_message: WebRTCSendMessage = (
mock_async_handle_async_webrtc_offer.call_args[0][3]
)
# Session id
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"]["type"] == "session"
session_id = response["event"]["session_id"]
send_message(message)
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"] == expected_frontend_message
# Unsubscribe/Close session
await client.send_json_auto_id(
{
"type": "unsubscribe_events",
"subscription": subscription_id,
}
)
msg = await client.receive_json()
assert msg["success"]
mock_async_close_session.assert_called_once_with(session_id)
async def test_websocket_webrtc_offer_invalid_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test WebRTC with a camera entity that does not exist."""
await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.does_not_exist",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"] == {
"code": "home_assistant_error",
"message": "Camera not found",
}
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_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_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"]["code"] == "invalid_format"
@pytest.mark.parametrize(
("error", "expected_message"),
[
(ValueError("value error"), "value error"),
(HomeAssistantError("offer failed"), "offer failed"),
(TimeoutError(), "Timeout handling WebRTC offer"),
],
)
async def test_websocket_webrtc_offer_failure(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
init_test_integration: MockCamera,
error: Exception,
expected_message: str,
) -> None:
"""Test WebRTC stream that fails handling the offer."""
client = await hass_ws_client(hass)
init_test_integration.set_sync_answer(error)
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.test",
"offer": WEBRTC_OFFER,
}
)
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"
# Error
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"] == {
"type": "error",
"code": "webrtc_offer_failed",
"message": expected_message,
}
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_offer_sync(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test sync WebRTC stream offer."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.sync",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert (
"tests.components.camera.conftest",
logging.WARNING,
(
"async_handle_web_rtc_offer was called from camera, this is a deprecated "
"function which will be removed in HA Core 2025.6. Use "
"async_handle_async_webrtc_offer instead"
),
) in caplog.record_tuples
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": WEBRTC_ANSWER}
async def test_websocket_webrtc_offer_sync_no_answer(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
init_test_integration: MockCamera,
) -> None:
"""Test sync WebRTC stream offer with no answer."""
client = await hass_ws_client(hass)
init_test_integration.set_sync_answer(None)
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.test",
"offer": WEBRTC_OFFER,
}
)
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": "No answer on WebRTC offer",
}
assert (
"homeassistant.components.camera",
logging.ERROR,
"Error handling WebRTC offer: No answer",
) in caplog.record_tuples
@pytest.mark.usefixtures("mock_camera")
async def test_websocket_webrtc_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_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"] == {
"code": "webrtc_offer_failed",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_rtsp_to_webrtc_offer(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
mock_rtsp_to_webrtc: Mock,
) -> None:
"""Test creating a webrtc offer from an rstp provider."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
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": WEBRTC_ANSWER,
}
assert mock_rtsp_to_webrtc.called
@pytest.fixture(name="mock_hls_stream_source")
async def mock_hls_stream_source_fixture() -> AsyncGenerator[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
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_rtsp_to_webrtc_provider_unregistered(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test creating a webrtc offer from an rstp provider."""
mock_provider = Mock(side_effect=provide_webrtc_answer)
unsub = 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_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
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": 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_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response.get("type") == TYPE_RESULT
assert not response["success"]
assert response["error"] == {
"code": "webrtc_offer_failed",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}
assert not mock_provider.called
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_rtsp_to_webrtc_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, stream_id: str
) -> str | None:
"""Simulate a provider that can't accept the offer."""
return None
mock_provider = Mock(side_effect=provide_none)
unsub = 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_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
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": "Camera does not support WebRTC",
}
assert mock_provider.called
unsub()
@pytest.mark.parametrize(
("frontend_candidate", "expected_candidate"),
[
(
{"candidate": "candidate", "sdpMLineIndex": 0},
RTCIceCandidateInit("candidate"),
),
(
{"candidate": "candidate", "sdpMLineIndex": 1},
RTCIceCandidateInit("candidate", sdp_m_line_index=1),
),
(
{"candidate": "candidate", "sdpMid": "1"},
RTCIceCandidateInit("candidate", sdp_mid="1"),
),
],
ids=["candidate", "candidate-mline-index", "candidate-mid"],
)
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_candidate(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
frontend_candidate: dict[str, Any],
expected_candidate: RTCIceCandidateInit,
) -> None:
"""Test ws webrtc candidate command."""
client = await hass_ws_client(hass)
session_id = "session_id"
with patch.object(
get_camera_from_entity_id(hass, "camera.async"), "async_on_webrtc_candidate"
) as mock_on_webrtc_candidate:
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
"entity_id": "camera.async",
"session_id": session_id,
"candidate": frontend_candidate,
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
mock_on_webrtc_candidate.assert_called_once_with(session_id, expected_candidate)
@pytest.mark.parametrize(
("message", "expected_error_msg"),
[
(
{"sdpMLineIndex": 0},
(
'Field "candidate" of type str is missing in RTCIceCandidateInit instance'
" for dictionary value @ data['candidate']. Got {'sdpMLineIndex': 0}"
),
),
(
{"candidate": "candidate", "sdpMLineIndex": -1},
(
"sdpMLineIndex must be greater than or equal to 0 for dictionary value @ "
"data['candidate']. Got {'candidate': 'candidate', 'sdpMLineIndex': -1}"
),
),
],
ids=[
"candidate missing",
"spd_mline_index smaller than 0",
],
)
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_candidate_invalid_candidate_message(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
message: dict,
expected_error_msg: str,
) -> None:
"""Test ws WebRTC candidate command for a camera with a different stream_type."""
client = await hass_ws_client(hass)
with patch("homeassistant.components.camera.Camera.async_on_webrtc_candidate"):
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
"entity_id": "camera.async",
"session_id": "session_id",
"candidate": message,
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"] == {
"code": "invalid_format",
"message": expected_error_msg,
}
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_candidate_not_supported(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test ws webrtc candidate command is raising if not supported."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
"entity_id": "camera.sync",
"session_id": "session_id",
"candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"] == {
"code": "home_assistant_error",
"message": "Cannot handle WebRTC candidate",
}
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_ws_webrtc_candidate_webrtc_provider(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
register_test_provider: SomeTestProvider,
) -> None:
"""Test ws webrtc candidate command with WebRTC provider."""
with patch.object(
register_test_provider, "async_on_webrtc_candidate"
) as mock_on_webrtc_candidate:
client = await hass_ws_client(hass)
session_id = "session_id"
candidate = "candidate"
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera",
"session_id": session_id,
"candidate": {"candidate": candidate, "sdpMLineIndex": 1},
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
mock_on_webrtc_candidate.assert_called_once_with(
session_id, RTCIceCandidateInit(candidate, sdp_m_line_index=1)
)
async def test_ws_webrtc_candidate_invalid_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test ws WebRTC candidate command with a camera entity that does not exist."""
await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
"entity_id": "camera.does_not_exist",
"session_id": "session_id",
"candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"] == {
"code": "home_assistant_error",
"message": "Camera not found",
}
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_canidate_missing_candidate(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test ws WebRTC candidate command with missing required fields."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
"entity_id": "camera.async",
"session_id": "session_id",
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"]["code"] == "invalid_format"
@pytest.mark.usefixtures("mock_camera")
async def test_ws_webrtc_candidate_invalid_stream_type(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test ws WebRTC candidate command for a camera with a different stream_type."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera",
"session_id": "session_id",
"candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert not response["success"]
assert response["error"] == {
"code": "webrtc_candidate_failed",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}
async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None:
"""Test optional interface for WebRTC provider."""
class OnlyRequiredInterfaceProvider(CameraWebRTCProvider):
"""Test provider."""
@property
def domain(self) -> str:
"""Return the domain of the provider."""
return "test"
@callback
def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
return True
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.
Return value determines if the offer was handled successfully.
"""
send_message(WebRTCAnswer(answer="answer"))
async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle the WebRTC candidate."""
provider = OnlyRequiredInterfaceProvider()
# Call all interface methods
assert provider.async_is_supported("stream_source") is True
await provider.async_handle_async_webrtc_offer(
Mock(), "offer_sdp", "session_id", Mock()
)
await provider.async_on_webrtc_candidate(
"session_id", RTCIceCandidateInit("candidate")
)
provider.async_close_session("session_id")
@pytest.mark.usefixtures("mock_camera")
async def test_repair_issue_legacy_provider(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repair issue created for legacy provider."""
# Ensure no issue if no provider is registered
assert not issue_registry.async_get_issue(
"camera", "legacy_webrtc_provider_mock_domain"
)
# Register a legacy provider
legacy_provider = Mock(side_effect=provide_webrtc_answer)
unsub_legacy_provider = async_register_rtsp_to_web_rtc_provider(
hass, "mock_domain", legacy_provider
)
await hass.async_block_till_done()
# Ensure no issue if only legacy provider is registered
assert not issue_registry.async_get_issue(
"camera", "legacy_webrtc_provider_mock_domain"
)
provider = Go2RTCProvider()
unsub_go2rtc_provider = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
# Ensure issue when legacy and builtin provider are registered
issue = issue_registry.async_get_issue(
"camera", "legacy_webrtc_provider_mock_domain"
)
assert issue
assert issue.is_fixable is False
assert issue.is_persistent is False
assert issue.issue_domain == "mock_domain"
assert issue.learn_more_url == "https://www.home-assistant.io/integrations/go2rtc/"
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.issue_id == "legacy_webrtc_provider_mock_domain"
assert issue.translation_key == "legacy_webrtc_provider"
assert issue.translation_placeholders == {
"legacy_integration": "mock_domain",
"builtin_integration": "go2rtc",
}
unsub_legacy_provider()
unsub_go2rtc_provider()
@pytest.mark.usefixtures("mock_camera", "register_test_provider", "mock_rtsp_to_webrtc")
async def test_no_repair_issue_without_new_provider(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repair issue not created if no go2rtc provider exists."""
assert not issue_registry.async_get_issue(
"camera", "legacy_webrtc_provider_mock_domain"
)
@pytest.mark.usefixtures("mock_camera", "mock_rtsp_to_webrtc")
async def test_registering_same_legacy_provider(
hass: HomeAssistant,
) -> None:
"""Test registering the same legacy provider twice."""
legacy_provider = Mock(side_effect=provide_webrtc_answer)
with pytest.raises(ValueError, match="Provider already registered"):
async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", legacy_provider)
@pytest.mark.usefixtures("mock_hls_stream_source", "mock_camera", "mock_rtsp_to_webrtc")
async def test_get_not_supported_legacy_provider(hass: HomeAssistant) -> None:
"""Test getting a not supported legacy provider."""
camera = get_camera_from_entity_id(hass, "camera.demo_camera")
assert await async_get_supported_legacy_provider(hass, camera) is None