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:
Allen Porter 2021-10-07 22:13:14 -07:00 committed by GitHub
parent 56d6173d70
commit 7d4dd94da8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 253 additions and 3 deletions

View file

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

View file

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

View file

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