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 d0390570..76d3bcf9 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -12,7 +12,13 @@ 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, + WebRTCAnswer, + WebRTCSendMessage, +) from homeassistant.components.mqtt import async_publish from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL @@ -44,6 +50,7 @@ ATTR_PTZ_ACTION, ATTR_PTZ_ARGUMENT, ATTR_START_TIME, + CONF_ENABLE_WEBRTC, CONF_RTSP_URL_TEMPLATE, DEVICE_CLASS_CAMERA, DOMAIN, @@ -67,9 +74,13 @@ 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] + 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( [ - FrigateCamera( + camera_type( entry, cam_name, frigate_client, @@ -81,7 +92,7 @@ async def async_setup_entry( for cam_name, camera_config in frigate_config["cameras"].items() ] + ( - [BirdseyeCamera(entry, frigate_client)] + [birdseye_type(entry, frigate_client)] if frigate_config.get("birdseye", {}).get("restream", False) else [] ) @@ -336,7 +347,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 @@ -348,6 +359,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] @@ -368,10 +380,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: @@ -409,7 +421,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 {}) ) @@ -420,3 +432,55 @@ async def async_camera_image( 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: + """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 diff --git a/custom_components/frigate/config_flow.py b/custom_components/frigate/config_flow.py index 0cdc2523..e5ebde10 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 Frigate-native WebRTC 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..4d42f01c 100644 --- a/custom_components/frigate/translations/en.json +++ b/custom_components/frigate/translations/en.json @@ -20,6 +20,7 @@ "step": { "init": { "data": { + "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", @@ -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/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 b051cee9..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, @@ -34,9 +35,11 @@ DOMAIN as CAMERA_DOMAIN, SERVICE_DISABLE_MOTION, SERVICE_ENABLE_MOTION, + StreamType, async_get_image, async_get_stream_source, ) +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 +65,7 @@ async def test_frigate_camera_setup( hass: HomeAssistant, aioclient_mock: Any, + hass_ws_client: Any, ) -> None: """Set up a camera.""" @@ -71,6 +75,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.HLS source = await async_get_stream_source(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID) assert source @@ -86,7 +91,11 @@ async def test_frigate_camera_setup( assert image.content == b"data-277" -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) @@ -98,11 +107,201 @@ 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.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" + 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 + 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" + + client = await hass_ws_client(hass) + + aioclient_mock.post( + "http://example.com/api/go2rtc/webrtc?src=front_door", + json={"type": "answer", "sdp": "return_sdp"}, + ) + + await client.send_json( + { + "id": 5, + "type": "camera/webrtc/offer", + "entity_id": TEST_CAMERA_FRONT_DOOR_ENTITY_ID, + "offer": "send_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", + } + + 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_webrtc( + 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) + 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 + 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" + + client = await hass_ws_client(hass) + + aioclient_mock.post( + "http://example.com/api/go2rtc/webrtc?src=birdseye", + json={"type": "answer", "sdp": "return_sdp"}, + ) + + await client.send_json( + { + "id": 5, + "type": "camera/webrtc/offer", + "entity_id": TEST_CAMERA_BIRDSEYE_ENTITY_ID, + "offer": "send_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", + } + + 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.""" 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, ], )