diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index bfa68fe67e6..56f7c56008b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -63,6 +63,8 @@ from .const import ( DATA_CAMERA_PREFS, DOMAIN, SERVICE_RECORD, + STREAM_TYPE_HLS, + STREAM_TYPE_WEB_RTC, ) from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences @@ -207,7 +209,6 @@ async def async_get_image( async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: """Fetch the stream source for a camera entity.""" camera = _get_camera_from_entity_id(hass, entity_id) - return await camera.stream_source() @@ -303,6 +304,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail, SCHEMA_WS_CAMERA_THUMBNAIL ) hass.components.websocket_api.async_register_command(ws_camera_stream) + hass.components.websocket_api.async_register_command(ws_camera_web_rtc_offer) hass.components.websocket_api.async_register_command(websocket_get_prefs) hass.components.websocket_api.async_register_command(websocket_update_prefs) @@ -421,6 +423,18 @@ class Camera(Entity): """Return the interval between frames of the mjpeg stream.""" return MIN_STREAM_INTERVAL + @property + def stream_type(self) -> str | None: + """Return the type of stream supported by this camera. + + A camera may have a single stream type which is used to inform the + frontend which camera attributes and player to use. The default type + is to use HLS, and components can override to change the type. + """ + if not self.supported_features & SUPPORT_STREAM: + return None + return STREAM_TYPE_HLS + async def create_stream(self) -> Stream | None: """Create a Stream for stream_source.""" # There is at most one stream (a decode worker) per camera @@ -433,10 +447,20 @@ class Camera(Entity): return self.stream async def stream_source(self) -> str | None: - """Return the source of the stream.""" + """Return the source of the stream. + + This is used by cameras with SUPPORT_STREAM and STREAM_TYPE_HLS. + """ # pylint: disable=no-self-use return None + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + """Handle the WebRTC offer and return an answer. + + This is used by cameras with SUPPORT_STREAM and STREAM_TYPE_WEB_RTC. + """ + raise NotImplementedError() + def camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: @@ -548,6 +572,9 @@ class Camera(Entity): if self.motion_detection_enabled: attrs["motion_detection"] = self.motion_detection_enabled + if self.stream_type: + attrs["stream_type"] = self.stream_type + return attrs @callback @@ -699,6 +726,50 @@ 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 +) -> 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 negitioation 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.stream_type != STREAM_TYPE_WEB_RTC: + connection.send_error( + msg["id"], + "web_rtc_offer_failed", + f"Camera does not support WebRTC, stream_type={camera.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 asyncio.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} ) diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 2cb01f44aa9..3eb131200e6 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -14,3 +14,12 @@ CONF_DURATION: Final = "duration" CAMERA_STREAM_SOURCE_TIMEOUT: Final = 10 CAMERA_IMAGE_TIMEOUT: Final = 10 + +# A camera that supports CAMERA_SUPPORT_STREAM may have a single stream +# type which is used to inform the frontend which player to use. +# Streams with RTSP sources typically use the stream component which uses +# HLS for display. WebRTC streams use the home assistant core for a signal +# path to initiate a stream, but the stream itself is between the client and +# device. +STREAM_TYPE_HLS = "hls" +STREAM_TYPE_WEB_RTC = "web_rtc" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index df4b64e4310..122fe13e2f1 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -7,7 +7,11 @@ from unittest.mock import Mock, PropertyMock, mock_open, patch import pytest from homeassistant.components import camera -from homeassistant.components.camera.const import DOMAIN, PREF_PRELOAD_STREAM +from homeassistant.components.camera.const import ( + DOMAIN, + PREF_PRELOAD_STREAM, + STREAM_TYPE_WEB_RTC, +) from homeassistant.components.camera.prefs import CameraEntityPreferences from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.config import async_process_ha_core_config @@ -40,6 +44,24 @@ async def mock_camera_fixture(hass): yield +@pytest.fixture(name="mock_camera_web_rtc") +async def mock_camera_web_rtc_fixture(hass): + """Initialize a demo camera platform.""" + assert await async_setup_component( + hass, "camera", {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.camera.Camera.stream_type", + new_callable=PropertyMock(return_value=STREAM_TYPE_WEB_RTC), + ), patch( + "homeassistant.components.camera.Camera.async_handle_web_rtc_offer", + return_value="a=sendonly", + ): + yield + + @pytest.fixture(name="mock_stream") def mock_stream_fixture(hass): """Initialize a demo camera platform with streaming.""" @@ -467,3 +489,151 @@ async def test_camera_proxy_stream(hass, mock_camera, hass_client): ): response = await client.get("/api/camera_proxy_stream/camera.demo_camera") assert response.status == HTTP_BAD_GATEWAY + + +async def test_websocket_web_rtc_offer( + hass, + hass_ws_client, + mock_camera_web_rtc, +): + """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": "v=0\r\n", + } + ) + response = await client.receive_json() + + assert response["id"] == 9 + assert response["type"] == TYPE_RESULT + assert response["success"] + assert response["result"]["answer"] == "a=sendonly" + + +async def test_websocket_web_rtc_offer_invalid_entity( + hass, + hass_ws_client, + mock_camera_web_rtc, +): + """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": "v=0\r\n", + } + ) + response = await client.receive_json() + + assert response["id"] == 9 + assert response["type"] == TYPE_RESULT + assert not response["success"] + + +async def test_websocket_web_rtc_offer_missing_offer( + hass, + hass_ws_client, + mock_camera_web_rtc, +): + """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" + + +async def test_websocket_web_rtc_offer_failure( + hass, + hass_ws_client, + mock_camera_web_rtc, +): + """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": "v=0\r\n", + } + ) + 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" + + +async def test_websocket_web_rtc_offer_timeout( + hass, + hass_ws_client, + mock_camera_web_rtc, +): + """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=asyncio.TimeoutError(), + ): + await client.send_json( + { + "id": 9, + "type": "camera/web_rtc_offer", + "entity_id": "camera.demo_camera", + "offer": "v=0\r\n", + } + ) + 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" + + +async def test_websocket_web_rtc_offer_invalid_stream_type( + hass, + hass_ws_client, + mock_camera, +): + """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": "v=0\r\n", + } + ) + 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"