Add WebSocket API for intiting a WebRTC stream (#57034)
* Add WebSocket API for intiting a WebRTC stream See https://github.com/home-assistant/architecture/discussions/640 * Increase test coverage for webrtc camera stream * Apply suggestions from code review Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io> Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
56d6173d70
commit
7d4dd94da8
3 changed files with 253 additions and 3 deletions
|
@ -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}
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue