From 7d1a7b0870914831c9be6d6fc42d144590d3e75a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:51:26 +0000 Subject: [PATCH] Webrtc use RTCIceCandidateInit messages with frontend (#129879) * Add sdp m line index to WebRtc Ice Candidates * Send RTCIceCandidate object in messages * Update tests * Update go2rtc to hardcode spdMid to 0 string on receive * Update for latest webrtc-model changes * Add error check for mushamuro error * Remove sdp_line_index from expected fail tests * Validate and parse message dict * Catch mashumaro error and raise vol.Invalid * Revert conftest change * Use custom validator instead --------- Co-authored-by: Robert Resch --- homeassistant/components/camera/webrtc.py | 17 ++-- tests/components/camera/test_webrtc.py | 96 ++++++++++++++++++++--- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index f020df61092..6a7f70ea48b 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -10,6 +10,7 @@ from functools import cache, partial, wraps import logging from typing import TYPE_CHECKING, Any, Protocol +from mashumaro import MissingField import voluptuous as vol from webrtc_models import ( RTCConfiguration, @@ -89,7 +90,7 @@ class WebRTCCandidate(WebRTCMessage): """Return a dict representation of the message.""" return { "type": self._get_type(), - "candidate": self.candidate.candidate, + "candidate": self.candidate.to_dict(), } @@ -328,12 +329,20 @@ async def ws_get_client_config( ) +def _parse_webrtc_candidate_init(value: Any) -> RTCIceCandidateInit: + """Validate and parse a WebRTCCandidateInit dict.""" + try: + return RTCIceCandidateInit.from_dict(value) + except (MissingField, ValueError) as ex: + raise vol.Invalid(str(ex)) from ex + + @websocket_api.websocket_command( { vol.Required("type"): "camera/webrtc/candidate", vol.Required("entity_id"): cv.entity_id, vol.Required("session_id"): str, - vol.Required("candidate"): str, + vol.Required("candidate"): _parse_webrtc_candidate_init, } ) @websocket_api.async_response @@ -342,9 +351,7 @@ async def ws_candidate( connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera ) -> None: """Handle WebRTC candidate websocket command.""" - await camera.async_on_webrtc_candidate( - msg["session_id"], RTCIceCandidateInit(msg["candidate"]) - ) + await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"]) connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index ba90788bdc3..76d7b15c286 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -495,7 +495,7 @@ async def test_websocket_webrtc_offer_webrtc_provider_deprecated( hass_ws_client, register_test_provider, WebRTCCandidate(RTCIceCandidate("candidate")), - {"type": "candidate", "candidate": "candidate"}, + {"type": "candidate", "candidate": {"candidate": "candidate"}}, ) @@ -504,7 +504,10 @@ async def test_websocket_webrtc_offer_webrtc_provider_deprecated( [ ( WebRTCCandidate(RTCIceCandidateInit("candidate")), - {"type": "candidate", "candidate": "candidate"}, + { + "type": "candidate", + "candidate": {"candidate": "candidate", "sdpMLineIndex": 0}, + }, ), ( WebRTCError("webrtc_offer_failed", "error"), @@ -955,14 +958,34 @@ async def test_rtsp_to_webrtc_offer_not_accepted( 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 + 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" - candidate = "candidate" with patch.object( get_camera_from_entity_id(hass, "camera.async"), "async_on_webrtc_candidate" ) as mock_on_webrtc_candidate: @@ -971,15 +994,64 @@ async def test_ws_webrtc_candidate( "type": "camera/webrtc/candidate", "entity_id": "camera.async", "session_id": session_id, - "candidate": candidate, + "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, RTCIceCandidateInit(candidate) + 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") @@ -993,7 +1065,7 @@ async def test_ws_webrtc_candidate_not_supported( "type": "camera/webrtc/candidate", "entity_id": "camera.sync", "session_id": "session_id", - "candidate": "candidate", + "candidate": {"candidate": "candidate"}, } ) response = await client.receive_json() @@ -1023,14 +1095,14 @@ async def test_ws_webrtc_candidate_webrtc_provider( "type": "camera/webrtc/candidate", "entity_id": "camera.demo_camera", "session_id": session_id, - "candidate": candidate, + "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) + session_id, RTCIceCandidateInit(candidate, sdp_m_line_index=1) ) @@ -1045,7 +1117,7 @@ async def test_ws_webrtc_candidate_invalid_entity( "type": "camera/webrtc/candidate", "entity_id": "camera.does_not_exist", "session_id": "session_id", - "candidate": "candidate", + "candidate": {"candidate": "candidate"}, } ) response = await client.receive_json() @@ -1089,7 +1161,7 @@ async def test_ws_webrtc_candidate_invalid_stream_type( "type": "camera/webrtc/candidate", "entity_id": "camera.demo_camera", "session_id": "session_id", - "candidate": "candidate", + "candidate": {"candidate": "candidate"}, } ) response = await client.receive_json()