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 <robert@resch.dev>
This commit is contained in:
Steven B. 2024-11-23 12:51:26 +00:00 committed by GitHub
parent d55eb896d2
commit 7d1a7b0870
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 96 additions and 17 deletions

View File

@ -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"]))

View File

@ -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()