From 49cb24b193027e79e853170f376d27946241d5f9 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 26 Nov 2024 18:32:56 -0300 Subject: [PATCH 01/13] Enable native WebRTC for camera entities --- custom_components/frigate/camera.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index d0390570..9e0293ec 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -4,15 +4,16 @@ import datetime import logging -from typing import Any +from typing import Any, cast +import aiohttp import async_timeout from jinja2 import Template import voluptuous as vol from yarl import URL from custom_components.frigate.api import FrigateApiClient -from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType from homeassistant.components.mqtt import async_publish from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL @@ -189,6 +190,8 @@ def __init__( ) if self._attr_is_streaming: + self._attr_frontend_stream_type = StreamType.WEB_RTC + streaming_template = config_entry.options.get( CONF_RTSP_URL_TEMPLATE, "" ).strip() @@ -299,6 +302,15 @@ async def async_enable_motion_detection(self) -> None: False, ) + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + """Handle the WebRTC offer and return an answer.""" + websession = async_get_clientsession(self.hass) + url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}" + payload = {"type": "offer", "sdp": offer_sdp} + async with websession.post(url, json=payload) as resp: + answer = await resp.json() + return cast(str, answer["sdp"]) + async def async_disable_motion_detection(self) -> None: """Disable motion detection for this camera.""" await async_publish( @@ -358,6 +370,8 @@ def __init__( self._attr_is_streaming = True self._attr_is_recording = False + self._attr_frontend_stream_type = StreamType.WEB_RTC + streaming_template = config_entry.options.get( CONF_RTSP_URL_TEMPLATE, "" ).strip() @@ -420,3 +434,12 @@ async def async_camera_image( async def stream_source(self) -> str | None: """Return the source of the stream.""" return self._stream_source + + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + """Handle the WebRTC offer and return an answer.""" + websession = async_get_clientsession(self.hass) + url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}" + payload = {"type": "offer", "sdp": offer_sdp} + async with websession.post(url, json=payload) as resp: + answer = await resp.json() + return cast(str, answer["sdp"]) From b86f54642d3568ae18c5ab0ae934f1a1c8bab722 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 26 Nov 2024 19:01:25 -0300 Subject: [PATCH 02/13] Add _cam_name to birdseye --- custom_components/frigate/camera.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index 9e0293ec..541da57f 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -360,6 +360,7 @@ def __init__( ) -> None: """Initialize the birdseye camera.""" self._client = frigate_client + self._cam_name = "birdseye" FrigateEntity.__init__(self, config_entry) Camera.__init__(self) self._url = config_entry.data[CONF_URL] @@ -382,10 +383,10 @@ def __init__( # template instead. This means templates cannot access HomeAssistant # state, but rather only the camera config. self._stream_source = Template(streaming_template).render( - {"name": "birdseye"} + {"name": self._cam_name} ) else: - self._stream_source = f"rtsp://{URL(self._url).host}:8554/birdseye" + self._stream_source = f"rtsp://{URL(self._url).host}:8554/{self._cam_name}" @property def unique_id(self) -> str: @@ -423,7 +424,7 @@ async def async_camera_image( image_url = str( URL(self._url) - / "api/birdseye/latest.jpg" + / f"api/{self._cam_name}/latest.jpg" % ({"h": height} if height is not None and height > 0 else {}) ) From 3305a77e49aac6e02ce4ab59f2c35437a2a30d7a Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 26 Nov 2024 19:06:49 -0300 Subject: [PATCH 03/13] Fix tests --- tests/test_camera.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_camera.py b/tests/test_camera.py index b051cee9..84456d65 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -36,7 +36,9 @@ SERVICE_ENABLE_MOTION, async_get_image, async_get_stream_source, + StreamType ) +from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -62,6 +64,7 @@ async def test_frigate_camera_setup( hass: HomeAssistant, aioclient_mock: Any, + hass_ws_client: Any, ) -> None: """Set up a camera.""" @@ -71,6 +74,7 @@ async def test_frigate_camera_setup( assert entity_state assert entity_state.state == "streaming" assert entity_state.attributes["supported_features"] == 2 + assert entity_state.attributes["frontend_stream_type"] == StreamType.WEB_RTC source = await async_get_stream_source(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID) assert source @@ -85,6 +89,26 @@ async def test_frigate_camera_setup( assert image assert image.content == b"data-277" + aioclient_mock.post( + "http://example.com/api/go2rtc/webrtc?src=front_door", + json={"type": "answer", "sdp": "return_sdp"}, + ) + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "camera/web_rtc_offer", + "entity_id": TEST_CAMERA_FRONT_DOOR_ENTITY_ID, + "offer": "send_sdp", + } + ) + + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"]["answer"] == "return_sdp" + async def test_frigate_camera_setup_birdseye(hass: HomeAssistant) -> None: """Set up birdseye camera.""" From 1824f915a8e4873de04151eba4174cb0f5ddd8d6 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 26 Nov 2024 19:12:06 -0300 Subject: [PATCH 04/13] Add WebRTC and image tests for birdseye --- tests/test_camera.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/test_camera.py b/tests/test_camera.py index 84456d65..e2c930e4 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -110,7 +110,11 @@ async def test_frigate_camera_setup( assert msg["result"]["answer"] == "return_sdp" -async def test_frigate_camera_setup_birdseye(hass: HomeAssistant) -> None: +async def test_frigate_camera_setup_birdseye( + hass: HomeAssistant, + aioclient_mock: Any, + hass_ws_client: Any, +) -> None: """Set up birdseye camera.""" config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) @@ -122,11 +126,42 @@ async def test_frigate_camera_setup_birdseye(hass: HomeAssistant) -> None: entity_state = hass.states.get(TEST_CAMERA_BIRDSEYE_ENTITY_ID) assert entity_state assert entity_state.state == "streaming" + assert entity_state.attributes["supported_features"] == 2 + assert entity_state.attributes["frontend_stream_type"] == StreamType.WEB_RTC source = await async_get_stream_source(hass, TEST_CAMERA_BIRDSEYE_ENTITY_ID) assert source assert source == "rtsp://example.com:8554/birdseye" + aioclient_mock.get( + "http://example.com/api/birdseye/latest.jpg?h=299", + content=b"data-299", + ) + + image = await async_get_image(hass, TEST_CAMERA_BIRDSEYE_ENTITY_ID, height=299) + assert image + assert image.content == b"data-299" + + aioclient_mock.post( + "http://example.com/api/go2rtc/webrtc?src=birdseye", + json={"type": "answer", "sdp": "return_sdp"}, + ) + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "camera/web_rtc_offer", + "entity_id": TEST_CAMERA_BIRDSEYE_ENTITY_ID, + "offer": "send_sdp", + } + ) + + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"]["answer"] == "return_sdp" + async def test_frigate_extra_attributes(hass: HomeAssistant) -> None: """Test that frigate extra attributes are correct.""" From 24a9dc95de102a362242859563aa2765de07acdc Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 26 Nov 2024 22:14:29 +0000 Subject: [PATCH 05/13] Format test_camera.py --- tests/test_camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_camera.py b/tests/test_camera.py index e2c930e4..2addbf69 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -34,9 +34,9 @@ DOMAIN as CAMERA_DOMAIN, SERVICE_DISABLE_MOTION, SERVICE_ENABLE_MOTION, + StreamType, async_get_image, async_get_stream_source, - StreamType ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID From 3e432542b02c11332b89e547f84acfc0cd3d6271 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 26 Nov 2024 22:15:51 +0000 Subject: [PATCH 06/13] Remove unused aiohttp --- custom_components/frigate/camera.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index 541da57f..a7d146e3 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -6,7 +6,6 @@ import logging from typing import Any, cast -import aiohttp import async_timeout from jinja2 import Template import voluptuous as vol From 6f44f5e457faf634b3687f3f9db9b50f2c3a24e0 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 26 Nov 2024 21:37:26 -0300 Subject: [PATCH 07/13] Fix deprecated async_handle_web_rtc_offer --- custom_components/frigate/camera.py | 30 +++++++++++++---- requirements.txt | 2 +- requirements_dev.txt | 2 +- tests/test_camera.py | 52 ++++++++++++++++++++++------- 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index a7d146e3..d3b1a642 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -4,7 +4,7 @@ import datetime import logging -from typing import Any, cast +from typing import Any import async_timeout from jinja2 import Template @@ -12,7 +12,13 @@ from yarl import URL from custom_components.frigate.api import FrigateApiClient -from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType +from homeassistant.components.camera import ( + Camera, + CameraEntityFeature, + StreamType, + WebRTCAnswer, + WebRTCSendMessage, +) from homeassistant.components.mqtt import async_publish from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL @@ -301,14 +307,20 @@ async def async_enable_motion_detection(self) -> None: False, ) - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: """Handle the WebRTC offer and return an answer.""" websession = async_get_clientsession(self.hass) url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}" payload = {"type": "offer", "sdp": offer_sdp} async with websession.post(url, json=payload) as resp: answer = await resp.json() - return cast(str, answer["sdp"]) + send_message(WebRTCAnswer(answer["sdp"])) + + async def async_on_webrtc_candidate(self, session_id: str, candidate: Any) -> None: + """Ignore WebRTC candidates for Frigate cameras.""" + return async def async_disable_motion_detection(self) -> None: """Disable motion detection for this camera.""" @@ -435,11 +447,17 @@ async def stream_source(self) -> str | None: """Return the source of the stream.""" return self._stream_source - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: """Handle the WebRTC offer and return an answer.""" websession = async_get_clientsession(self.hass) url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}" payload = {"type": "offer", "sdp": offer_sdp} async with websession.post(url, json=payload) as resp: answer = await resp.json() - return cast(str, answer["sdp"]) + send_message(WebRTCAnswer(answer["sdp"])) + + async def async_on_webrtc_candidate(self, session_id: str, candidate: Any) -> None: + """Ignore WebRTC candidates for Frigate cameras.""" + return diff --git a/requirements.txt b/requirements.txt index 1fa1ba41..43d8013e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ aiohttp aiohttp_cors attr janus -homeassistant==2024.10.4 +homeassistant==2024.11.3 paho-mqtt python-dateutil yarl diff --git a/requirements_dev.txt b/requirements_dev.txt index 217edd08..9b1d25bc 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,7 +4,7 @@ flake8 mypy pre-commit pytest -pytest-homeassistant-custom-component==0.13.175 +pytest-homeassistant-custom-component==0.13.184 pylint-pytest pylint pytest-aiohttp diff --git a/tests/test_camera.py b/tests/test_camera.py index 2addbf69..1d650209 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -97,17 +97,31 @@ async def test_frigate_camera_setup( await client.send_json( { "id": 5, - "type": "camera/web_rtc_offer", + "type": "camera/webrtc/offer", "entity_id": TEST_CAMERA_FRONT_DOOR_ENTITY_ID, "offer": "send_sdp", } ) - msg = await client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == TYPE_RESULT - assert msg["success"] - assert msg["result"]["answer"] == "return_sdp" + response = await client.receive_json() + assert response["id"] == 5 + assert response["type"] == TYPE_RESULT + assert response["success"] + + # Session id + response = await client.receive_json() + assert response["id"] == 5 + assert response["type"] == "event" + assert response["event"]["type"] == "session" + + # Answer + response = await client.receive_json() + assert response["id"] == 5 + assert response["type"] == "event" + assert response["event"] == { + "type": "answer", + "answer": "return_sdp", + } async def test_frigate_camera_setup_birdseye( @@ -150,17 +164,31 @@ async def test_frigate_camera_setup_birdseye( await client.send_json( { "id": 5, - "type": "camera/web_rtc_offer", + "type": "camera/webrtc/offer", "entity_id": TEST_CAMERA_BIRDSEYE_ENTITY_ID, "offer": "send_sdp", } ) - msg = await client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == TYPE_RESULT - assert msg["success"] - assert msg["result"]["answer"] == "return_sdp" + response = await client.receive_json() + assert response["id"] == 5 + assert response["type"] == TYPE_RESULT + assert response["success"] + + # Session id + response = await client.receive_json() + assert response["id"] == 5 + assert response["type"] == "event" + assert response["event"]["type"] == "session" + + # Answer + response = await client.receive_json() + assert response["id"] == 5 + assert response["type"] == "event" + assert response["event"] == { + "type": "answer", + "answer": "return_sdp", + } async def test_frigate_extra_attributes(hass: HomeAssistant) -> None: From e86bbbedc6a9131c2b5029a0d7c8811b366b3339 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 27 Nov 2024 02:07:42 +0000 Subject: [PATCH 08/13] Add test for async_on_webrtc_candidate --- tests/test_camera.py | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/test_camera.py b/tests/test_camera.py index 1d650209..f645f992 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -89,11 +89,13 @@ async def test_frigate_camera_setup( assert image assert image.content == b"data-277" + client = await hass_ws_client(hass) + aioclient_mock.post( "http://example.com/api/go2rtc/webrtc?src=front_door", json={"type": "answer", "sdp": "return_sdp"}, ) - client = await hass_ws_client(hass) + await client.send_json( { "id": 5, @@ -123,6 +125,21 @@ async def test_frigate_camera_setup( "answer": "return_sdp", } + await client.send_json( + { + "id": 6, + "type": "camera/webrtc/candidate", + "entity_id": TEST_CAMERA_FRONT_DOOR_ENTITY_ID, + "session_id": "session_id", + "candidate": "candidate", + } + ) + + response = await client.receive_json() + assert response["id"] == 6 + assert response["type"] == TYPE_RESULT + assert response["success"] + async def test_frigate_camera_setup_birdseye( hass: HomeAssistant, @@ -156,11 +173,13 @@ async def test_frigate_camera_setup_birdseye( assert image assert image.content == b"data-299" + client = await hass_ws_client(hass) + aioclient_mock.post( "http://example.com/api/go2rtc/webrtc?src=birdseye", json={"type": "answer", "sdp": "return_sdp"}, ) - client = await hass_ws_client(hass) + await client.send_json( { "id": 5, @@ -190,6 +209,21 @@ async def test_frigate_camera_setup_birdseye( "answer": "return_sdp", } + await client.send_json( + { + "id": 6, + "type": "camera/webrtc/candidate", + "entity_id": TEST_CAMERA_BIRDSEYE_ENTITY_ID, + "session_id": "session_id", + "candidate": "candidate", + } + ) + + response = await client.receive_json() + assert response["id"] == 6 + assert response["type"] == TYPE_RESULT + assert response["success"] + async def test_frigate_extra_attributes(hass: HomeAssistant) -> None: """Test that frigate extra attributes are correct.""" From 908603e936d7dddf693b17669739433a6146f2ca Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 27 Nov 2024 17:26:41 -0300 Subject: [PATCH 09/13] Fix deprecated _attr_frontend_stream_type --- custom_components/frigate/camera.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index d3b1a642..66f1868a 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -195,8 +195,6 @@ def __init__( ) if self._attr_is_streaming: - self._attr_frontend_stream_type = StreamType.WEB_RTC - streaming_template = config_entry.options.get( CONF_RTSP_URL_TEMPLATE, "" ).strip() @@ -277,6 +275,11 @@ def supported_features(self) -> CameraEntityFeature: return CameraEntityFeature(0) return CameraEntityFeature.STREAM + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + return StreamType.WEB_RTC + async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: @@ -382,8 +385,6 @@ def __init__( self._attr_is_streaming = True self._attr_is_recording = False - self._attr_frontend_stream_type = StreamType.WEB_RTC - streaming_template = config_entry.options.get( CONF_RTSP_URL_TEMPLATE, "" ).strip() @@ -427,6 +428,11 @@ def supported_features(self) -> CameraEntityFeature: """Return supported features of this camera.""" return CameraEntityFeature.STREAM + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + return StreamType.WEB_RTC + async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: From c9159a5f2fc1f092fc357a366ced673a24a5c970 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 27 Nov 2024 18:14:59 -0300 Subject: [PATCH 10/13] WebRTC class separation --- custom_components/frigate/camera.py | 68 +++++++++++++++++------------ 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index 66f1868a..0ff1cd76 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -75,7 +75,7 @@ async def async_setup_entry( async_add_entities( [ - FrigateCamera( + FrigateCameraWebRTC( entry, cam_name, frigate_client, @@ -87,7 +87,7 @@ async def async_setup_entry( for cam_name, camera_config in frigate_config["cameras"].items() ] + ( - [BirdseyeCamera(entry, frigate_client)] + [BirdseyeCameraWebRTC(entry, frigate_client)] if frigate_config.get("birdseye", {}).get("restream", False) else [] ) @@ -275,11 +275,6 @@ def supported_features(self) -> CameraEntityFeature: return CameraEntityFeature(0) return CameraEntityFeature.STREAM - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - return StreamType.WEB_RTC - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: @@ -310,21 +305,6 @@ async def async_enable_motion_detection(self) -> None: False, ) - async def async_handle_async_webrtc_offer( - self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage - ) -> None: - """Handle the WebRTC offer and return an answer.""" - websession = async_get_clientsession(self.hass) - url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}" - payload = {"type": "offer", "sdp": offer_sdp} - async with websession.post(url, json=payload) as resp: - answer = await resp.json() - send_message(WebRTCAnswer(answer["sdp"])) - - async def async_on_webrtc_candidate(self, session_id: str, candidate: Any) -> None: - """Ignore WebRTC candidates for Frigate cameras.""" - return - async def async_disable_motion_detection(self) -> None: """Disable motion detection for this camera.""" await async_publish( @@ -362,7 +342,7 @@ async def ptz(self, action: str, argument: str) -> None: class BirdseyeCamera(FrigateEntity, Camera): - """Representation of the Frigate birdseye camera.""" + """A Frigate birdseye camera.""" # sets the entity name to same as device name ex: camera.front_doorbell _attr_name = None @@ -428,11 +408,6 @@ def supported_features(self) -> CameraEntityFeature: """Return supported features of this camera.""" return CameraEntityFeature.STREAM - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - return StreamType.WEB_RTC - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: @@ -453,6 +428,43 @@ async def stream_source(self) -> str | None: """Return the source of the stream.""" return self._stream_source + +class FrigateCameraWebRTC(FrigateCamera): + """A Frigate camera with WebRTC support.""" + + # TODO: this property can be removed after this fix is released: + # https://github.com/home-assistant/core/pull/130932/files#diff-75655c0eec1c3e736cad1bdb5627100a4595ece9accc391b5c85343bb998594fR598-R603 + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + return StreamType.WEB_RTC + + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + """Handle the WebRTC offer and return an answer.""" + websession = async_get_clientsession(self.hass) + url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}" + payload = {"type": "offer", "sdp": offer_sdp} + async with websession.post(url, json=payload) as resp: + answer = await resp.json() + send_message(WebRTCAnswer(answer["sdp"])) + + async def async_on_webrtc_candidate(self, session_id: str, candidate: Any) -> None: + """Ignore WebRTC candidates for Frigate cameras.""" + return + + +class BirdseyeCameraWebRTC(BirdseyeCamera): + """A Frigate birdseye camera with WebRTC support.""" + + # TODO: this property can be removed after this fix is released: + # https://github.com/home-assistant/core/pull/130932/files#diff-75655c0eec1c3e736cad1bdb5627100a4595ece9accc391b5c85343bb998594fR598-R603 + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + return StreamType.WEB_RTC + async def async_handle_async_webrtc_offer( self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage ) -> None: From cab49bd51f47b30f21dfb9edb2a7b4166583976e Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 27 Nov 2024 21:45:42 +0000 Subject: [PATCH 11/13] Bring back WebRTC option (disabled by default) --- custom_components/frigate/__init__.py | 2 - custom_components/frigate/camera.py | 56 +++++++++---- custom_components/frigate/config_flow.py | 9 ++ .../frigate/translations/en.json | 3 +- tests/test_camera.py | 82 ++++++++++++++++++- tests/test_config_flow.py | 3 + tests/test_init.py | 2 - 7 files changed, 133 insertions(+), 24 deletions(-) diff --git a/custom_components/frigate/__init__.py b/custom_components/frigate/__init__.py index d22a13dd..4bd3381b 100644 --- a/custom_components/frigate/__init__.py +++ b/custom_components/frigate/__init__.py @@ -44,7 +44,6 @@ ATTR_WS_EVENT_PROXY, ATTRIBUTE_LABELS, CONF_CAMERA_STATIC_IMAGE_HEIGHT, - CONF_ENABLE_WEBRTC, CONF_RTMP_URL_TEMPLATE, DOMAIN, FRIGATE_RELEASES_URL, @@ -268,7 +267,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Remove old options. OLD_OPTIONS = [ CONF_CAMERA_STATIC_IMAGE_HEIGHT, - CONF_ENABLE_WEBRTC, CONF_RTMP_URL_TEMPLATE, ] if any(option in entry.options for option in OLD_OPTIONS): diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index 0ff1cd76..d9f3ed19 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -50,6 +50,7 @@ ATTR_PTZ_ACTION, ATTR_PTZ_ARGUMENT, ATTR_START_TIME, + CONF_ENABLE_WEBRTC, CONF_RTSP_URL_TEMPLATE, DEVICE_CLASS_CAMERA, DOMAIN, @@ -73,25 +74,46 @@ async def async_setup_entry( client_id = get_frigate_instance_id_for_config_entry(hass, entry) coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR] - async_add_entities( - [ - FrigateCameraWebRTC( - entry, - cam_name, - frigate_client, - client_id, - coordinator, - frigate_config, - camera_config, + if entry.options.get(CONF_ENABLE_WEBRTC, False): + async_add_entities( + [ + FrigateCameraWebRTC( + entry, + cam_name, + frigate_client, + client_id, + coordinator, + frigate_config, + camera_config, + ) + for cam_name, camera_config in frigate_config["cameras"].items() + ] + + ( + [BirdseyeCameraWebRTC(entry, frigate_client)] + if frigate_config.get("birdseye", {}).get("restream", False) + else [] + ) + ) + else: + async_add_entities( + [ + FrigateCamera( + entry, + cam_name, + frigate_client, + client_id, + coordinator, + frigate_config, + camera_config, + ) + for cam_name, camera_config in frigate_config["cameras"].items() + ] + + ( + [BirdseyeCamera(entry, frigate_client)] + if frigate_config.get("birdseye", {}).get("restream", False) + else [] ) - for cam_name, camera_config in frigate_config["cameras"].items() - ] - + ( - [BirdseyeCameraWebRTC(entry, frigate_client)] - if frigate_config.get("birdseye", {}).get("restream", False) - else [] ) - ) # setup services platform = entity_platform.async_get_current_platform() diff --git a/custom_components/frigate/config_flow.py b/custom_components/frigate/config_flow.py index 0cdc2523..01c0765e 100644 --- a/custom_components/frigate/config_flow.py +++ b/custom_components/frigate/config_flow.py @@ -17,6 +17,7 @@ from .api import FrigateApiClient, FrigateApiClientError from .const import ( + CONF_ENABLE_WEBRTC, CONF_MEDIA_BROWSER_ENABLE, CONF_NOTIFICATION_PROXY_ENABLE, CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, @@ -122,6 +123,14 @@ async def async_step_init( return self.async_abort(reason="only_advanced_options") schema: dict[Any, Any] = { + # Whether to enable webrtc as the medium for camera streaming + vol.Optional( + CONF_ENABLE_WEBRTC, + default=self._config_entry.options.get( + CONF_ENABLE_WEBRTC, + False, + ), + ): bool, # The input URL is not validated as being a URL to allow for the # possibility the template input won't be a valid URL until after # it's rendered. diff --git a/custom_components/frigate/translations/en.json b/custom_components/frigate/translations/en.json index 0c6c9691..a5d41580 100644 --- a/custom_components/frigate/translations/en.json +++ b/custom_components/frigate/translations/en.json @@ -20,6 +20,7 @@ "step": { "init": { "data": { + "enable_webrtc": "Enable WebRTC for camera streams", "rtsp_url_template": "RTSP URL template (see documentation)", "media_browser_enable": "Enable the media browser", "notification_proxy_enable": "Enable the unauthenticated notification event proxy", @@ -31,4 +32,4 @@ "only_advanced_options": "Advanced mode is disabled and there are only advanced options" } } -} \ No newline at end of file +} diff --git a/tests/test_camera.py b/tests/test_camera.py index f645f992..859f7b94 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -23,6 +23,7 @@ ATTR_PTZ_ACTION, ATTR_PTZ_ARGUMENT, ATTR_START_TIME, + CONF_ENABLE_WEBRTC, CONF_RTSP_URL_TEMPLATE, DOMAIN, NAME, @@ -70,6 +71,77 @@ async def test_frigate_camera_setup( await setup_mock_frigate_config_entry(hass) + entity_state = hass.states.get(TEST_CAMERA_FRONT_DOOR_ENTITY_ID) + assert entity_state + assert entity_state.state == "streaming" + assert entity_state.attributes["supported_features"] == 2 + assert entity_state.attributes["frontend_stream_type"] == StreamType.HLS + + source = await async_get_stream_source(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID) + assert source + assert source == "rtsp://example.com:8554/front_door" + + aioclient_mock.get( + "http://example.com/api/front_door/latest.jpg?h=277", + content=b"data-277", + ) + + image = await async_get_image(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID, height=277) + assert image + assert image.content == b"data-277" + + +async def test_frigate_camera_setup_birdseye( + hass: HomeAssistant, + aioclient_mock: Any, + hass_ws_client: Any, +) -> None: + """Set up birdseye camera.""" + + config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + config["birdseye"] = {"restream": True} + client = create_mock_frigate_client() + client.async_get_config = AsyncMock(return_value=config) + await setup_mock_frigate_config_entry(hass, client=client) + + entity_state = hass.states.get(TEST_CAMERA_BIRDSEYE_ENTITY_ID) + assert entity_state + assert entity_state.state == "streaming" + assert entity_state.attributes["supported_features"] == 2 + assert entity_state.attributes["frontend_stream_type"] == StreamType.HLS + + source = await async_get_stream_source(hass, TEST_CAMERA_BIRDSEYE_ENTITY_ID) + assert source + assert source == "rtsp://example.com:8554/birdseye" + + aioclient_mock.get( + "http://example.com/api/birdseye/latest.jpg?h=299", + content=b"data-299", + ) + + image = await async_get_image(hass, TEST_CAMERA_BIRDSEYE_ENTITY_ID, height=299) + assert image + assert image.content == b"data-299" + + +async def test_frigate_camera_setup_webrtc( + hass: HomeAssistant, + aioclient_mock: Any, + hass_ws_client: Any, +) -> None: + """Set up a camera.""" + + config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + client = create_mock_frigate_client() + client.async_get_config = AsyncMock(return_value=config) + config_entry = create_mock_frigate_config_entry( + hass, options={CONF_ENABLE_WEBRTC: True} + ) + + await setup_mock_frigate_config_entry( + hass, client=client, config_entry=config_entry + ) + entity_state = hass.states.get(TEST_CAMERA_FRONT_DOOR_ENTITY_ID) assert entity_state assert entity_state.state == "streaming" @@ -141,7 +213,7 @@ async def test_frigate_camera_setup( assert response["success"] -async def test_frigate_camera_setup_birdseye( +async def test_frigate_camera_setup_birdseye_webrtc( hass: HomeAssistant, aioclient_mock: Any, hass_ws_client: Any, @@ -152,7 +224,13 @@ async def test_frigate_camera_setup_birdseye( config["birdseye"] = {"restream": True} client = create_mock_frigate_client() client.async_get_config = AsyncMock(return_value=config) - await setup_mock_frigate_config_entry(hass, client=client) + config_entry = create_mock_frigate_config_entry( + hass, options={CONF_ENABLE_WEBRTC: True} + ) + + await setup_mock_frigate_config_entry( + hass, client=client, config_entry=config_entry + ) entity_state = hass.states.get(TEST_CAMERA_BIRDSEYE_ENTITY_ID) assert entity_state diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3e82117b..c16909d5 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -9,6 +9,7 @@ from custom_components.frigate.api import FrigateApiClientError from custom_components.frigate.const import ( + CONF_ENABLE_WEBRTC, CONF_MEDIA_BROWSER_ENABLE, CONF_NOTIFICATION_PROXY_ENABLE, CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, @@ -177,6 +178,7 @@ async def test_options_advanced(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ + CONF_ENABLE_WEBRTC: True, CONF_RTSP_URL_TEMPLATE: "http://moo", CONF_NOTIFICATION_PROXY_ENABLE: False, CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS: 60, @@ -185,6 +187,7 @@ async def test_options_advanced(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_ENABLE_WEBRTC] is True assert result["data"][CONF_RTSP_URL_TEMPLATE] == "http://moo" assert result["data"][CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS] == 60 assert not result["data"][CONF_NOTIFICATION_PROXY_ENABLE] diff --git a/tests/test_init.py b/tests/test_init.py index 2fbf3cd0..ea30c82a 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -17,7 +17,6 @@ from custom_components.frigate.api import FrigateApiClientError from custom_components.frigate.const import ( CONF_CAMERA_STATIC_IMAGE_HEIGHT, - CONF_ENABLE_WEBRTC, CONF_RTMP_URL_TEMPLATE, DOMAIN, ) @@ -436,7 +435,6 @@ async def test_startup_message(caplog: Any, hass: HomeAssistant) -> None: "option", [ CONF_CAMERA_STATIC_IMAGE_HEIGHT, - CONF_ENABLE_WEBRTC, CONF_RTMP_URL_TEMPLATE, ], ) From 0b07ca207ac15da767f0e272886b466e611ae557 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 27 Nov 2024 21:47:55 +0000 Subject: [PATCH 12/13] Rephrase option as @OnFreund's suggestion --- custom_components/frigate/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/frigate/translations/en.json b/custom_components/frigate/translations/en.json index a5d41580..4d42f01c 100644 --- a/custom_components/frigate/translations/en.json +++ b/custom_components/frigate/translations/en.json @@ -20,7 +20,7 @@ "step": { "init": { "data": { - "enable_webrtc": "Enable WebRTC for camera streams", + "enable_webrtc": "Use Frigate-native WebRTC support", "rtsp_url_template": "RTSP URL template (see documentation)", "media_browser_enable": "Enable the media browser", "notification_proxy_enable": "Enable the unauthenticated notification event proxy", From 0158825b5fac1b9321b8e1f300b87aafff6096c5 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 27 Nov 2024 22:20:21 +0000 Subject: [PATCH 13/13] Reduce duplication with @dermotduffy's tip --- custom_components/frigate/camera.py | 59 +++++++++--------------- custom_components/frigate/config_flow.py | 2 +- 2 files changed, 22 insertions(+), 39 deletions(-) diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index d9f3ed19..76d3bcf9 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -74,46 +74,29 @@ async def async_setup_entry( client_id = get_frigate_instance_id_for_config_entry(hass, entry) coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR] - if entry.options.get(CONF_ENABLE_WEBRTC, False): - async_add_entities( - [ - FrigateCameraWebRTC( - entry, - cam_name, - frigate_client, - client_id, - coordinator, - frigate_config, - camera_config, - ) - for cam_name, camera_config in frigate_config["cameras"].items() - ] - + ( - [BirdseyeCameraWebRTC(entry, frigate_client)] - if frigate_config.get("birdseye", {}).get("restream", False) - else [] - ) - ) - else: - async_add_entities( - [ - FrigateCamera( - entry, - cam_name, - frigate_client, - client_id, - coordinator, - frigate_config, - camera_config, - ) - for cam_name, camera_config in frigate_config["cameras"].items() - ] - + ( - [BirdseyeCamera(entry, frigate_client)] - if frigate_config.get("birdseye", {}).get("restream", False) - else [] + frigate_webrtc = entry.options.get(CONF_ENABLE_WEBRTC, False) + camera_type = FrigateCameraWebRTC if frigate_webrtc else FrigateCamera + birdseye_type = BirdseyeCameraWebRTC if frigate_webrtc else BirdseyeCamera + + async_add_entities( + [ + camera_type( + entry, + cam_name, + frigate_client, + client_id, + coordinator, + frigate_config, + camera_config, ) + for cam_name, camera_config in frigate_config["cameras"].items() + ] + + ( + [birdseye_type(entry, frigate_client)] + if frigate_config.get("birdseye", {}).get("restream", False) + else [] ) + ) # setup services platform = entity_platform.async_get_current_platform() diff --git a/custom_components/frigate/config_flow.py b/custom_components/frigate/config_flow.py index 01c0765e..e5ebde10 100644 --- a/custom_components/frigate/config_flow.py +++ b/custom_components/frigate/config_flow.py @@ -123,7 +123,7 @@ async def async_step_init( return self.async_abort(reason="only_advanced_options") schema: dict[Any, Any] = { - # Whether to enable webrtc as the medium for camera streaming + # Whether to enable Frigate-native WebRTC for camera streaming vol.Optional( CONF_ENABLE_WEBRTC, default=self._config_entry.options.get(