Add camera media source (#65977)

This commit is contained in:
Paulus Schoutsen 2022-02-08 14:32:02 -08:00 committed by GitHub
parent b216f6f448
commit 716a1e2a64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 432 additions and 109 deletions

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import base64
import collections
from collections.abc import Awaitable, Callable, Iterable, Mapping
from collections.abc import Awaitable, Callable, Iterable
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
@ -26,7 +26,6 @@ from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_EXTRA,
DOMAIN as DOMAIN_MP,
SERVICE_PLAY_MEDIA,
)
@ -49,7 +48,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
)
from homeassistant.helpers.entity import Entity, EntityDescription, entity_sources
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.network import get_url
from homeassistant.helpers.typing import ConfigType
@ -970,52 +969,18 @@ async def async_handle_play_stream_service(
camera: Camera, service_call: ServiceCall
) -> None:
"""Handle play stream services calls."""
hass = camera.hass
fmt = service_call.data[ATTR_FORMAT]
url = await _async_stream_endpoint_url(camera.hass, camera, fmt)
url = f"{get_url(hass)}{url}"
hass = camera.hass
data: Mapping[str, str] = {
ATTR_MEDIA_CONTENT_ID: f"{get_url(hass)}{url}",
await hass.services.async_call(
DOMAIN_MP,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: service_call.data[ATTR_MEDIA_PLAYER],
ATTR_MEDIA_CONTENT_ID: url,
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt],
}
# It is required to send a different payload for cast media players
entity_ids = service_call.data[ATTR_MEDIA_PLAYER]
sources = entity_sources(hass)
cast_entity_ids = [
entity
for entity in entity_ids
# All entities should be in sources. This extra guard is to
# avoid people writing to the state machine and breaking it.
if entity in sources and sources[entity]["domain"] == "cast"
]
other_entity_ids = list(set(entity_ids) - set(cast_entity_ids))
if cast_entity_ids:
await hass.services.async_call(
DOMAIN_MP,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: cast_entity_ids,
**data,
ATTR_MEDIA_EXTRA: {
"stream_type": "LIVE",
"media_info": {
"hlsVideoSegmentFormat": "fmp4",
},
},
},
blocking=True,
context=service_call.context,
)
if other_entity_ids:
await hass.services.async_call(
DOMAIN_MP,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: other_entity_ids,
**data,
},
blocking=True,
context=service_call.context,

View file

@ -0,0 +1,103 @@
"""Expose cameras as media sources."""
from __future__ import annotations
from typing import Optional, cast
from homeassistant.components.media_player.const import (
MEDIA_CLASS_APP,
MEDIA_CLASS_VIDEO,
)
from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.media_source.error import Unresolvable
from homeassistant.components.media_source.models import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
)
from homeassistant.components.stream.const import FORMAT_CONTENT_TYPE, HLS_PROVIDER
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_component import EntityComponent
from . import Camera, _async_stream_endpoint_url
from .const import DOMAIN, STREAM_TYPE_HLS
async def async_get_media_source(hass: HomeAssistant) -> CameraMediaSource:
"""Set up camera media source."""
return CameraMediaSource(hass)
class CameraMediaSource(MediaSource):
"""Provide camera feeds as media sources."""
name: str = "Camera"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize CameraMediaSource."""
super().__init__(DOMAIN)
self.hass = hass
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
component: EntityComponent = self.hass.data[DOMAIN]
camera = cast(Optional[Camera], component.get_entity(item.identifier))
if not camera:
raise Unresolvable(f"Could not resolve media item: {item.identifier}")
if camera.frontend_stream_type != STREAM_TYPE_HLS:
raise Unresolvable("Camera does not support HLS streaming.")
try:
url = await _async_stream_endpoint_url(self.hass, camera, HLS_PROVIDER)
except HomeAssistantError as err:
raise Unresolvable(str(err)) from err
return PlayMedia(url, FORMAT_CONTENT_TYPE[HLS_PROVIDER])
async def async_browse_media(
self,
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
if item.identifier:
raise BrowseError("Unknown item")
if "stream" not in self.hass.config.components:
raise BrowseError("Stream integration is not loaded")
# Root. List cameras.
component: EntityComponent = self.hass.data[DOMAIN]
children = []
for camera in component.entities:
camera = cast(Camera, camera)
if camera.frontend_stream_type != STREAM_TYPE_HLS:
continue
children.append(
BrowseMediaSource(
domain=DOMAIN,
identifier=camera.entity_id,
media_class=MEDIA_CLASS_VIDEO,
media_content_type=FORMAT_CONTENT_TYPE[HLS_PROVIDER],
title=camera.name,
thumbnail=f"/api/camera_proxy/{camera.entity_id}",
can_play=True,
can_expand=False,
)
)
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MEDIA_CLASS_APP,
media_content_type="",
title="Camera",
can_play=False,
can_expand=True,
children_media_class=MEDIA_CLASS_VIDEO,
children=children,
)

View file

@ -18,6 +18,7 @@ from pychromecast.socket_client import (
CONNECTION_STATUS_DISCONNECTED,
)
import voluptuous as vol
import yarl
from homeassistant.components import media_source, zeroconf
from homeassistant.components.http.auth import async_sign_path
@ -59,7 +60,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url
import homeassistant.util.dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
@ -535,19 +536,6 @@ class CastDevice(MediaPlayerEntity):
media_type = sourced_media.mime_type
media_id = sourced_media.url
# If media ID is a relative URL, we serve it from HA.
# Create a signed path.
if media_id[0] == "/":
media_id = async_sign_path(
self.hass,
quote(media_id),
timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME),
)
# prepend external URL
hass_url = get_url(self.hass, prefer_external=True)
media_id = f"{hass_url}{media_id}"
extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
metadata = extra.get("metadata")
@ -593,6 +581,33 @@ class CastDevice(MediaPlayerEntity):
if result:
return
# If media ID is a relative URL, we serve it from HA.
# Create a signed path.
if media_id[0] == "/" or is_hass_url(self.hass, media_id):
parsed = yarl.URL(media_id)
# Configure play command for when playing a HLS stream
if parsed.path.startswith("/api/hls/"):
extra = {
**extra,
"stream_type": "LIVE",
"media_info": {
"hlsVideoSegmentFormat": "fmp4",
},
}
if parsed.query:
_LOGGER.debug("Not signing path for content with query param")
else:
media_id = async_sign_path(
self.hass,
quote(media_id),
timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME),
)
if media_id[0] == "/":
# prepend URL
media_id = f"{get_url(self.hass)}{media_id}"
# Default to play with the default media receiver
app_data = {"media_id": media_id, "media_type": media_type, **extra}
await self.hass.async_add_executor_job(

View file

@ -31,6 +31,48 @@ def is_internal_request(hass: HomeAssistant) -> bool:
return False
def is_hass_url(hass: HomeAssistant, url: str) -> bool:
"""Return if the URL points at this Home Assistant instance."""
parsed = yarl.URL(normalize_url(url))
def host_ip() -> str | None:
if hass.config.api is None or is_loopback(ip_address(hass.config.api.local_ip)):
return None
return str(
yarl.URL.build(
scheme="http", host=hass.config.api.local_ip, port=hass.config.api.port
)
)
def cloud_url() -> str | None:
try:
return _get_cloud_url(hass)
except NoURLAvailableError:
return None
for potential_base_factory in (
lambda: hass.config.internal_url,
lambda: hass.config.external_url,
cloud_url,
host_ip,
):
potential_base = potential_base_factory()
if potential_base is None:
continue
potential_parsed = yarl.URL(normalize_url(potential_base))
if (
parsed.scheme == potential_parsed.scheme
and parsed.authority == potential_parsed.authority
):
return True
return False
@bind_hass
def get_url(
hass: HomeAssistant,

View file

@ -8,6 +8,7 @@ from unittest.mock import Mock
from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM
EMPTY_8_6_JPEG = b"empty_8_6"
WEBRTC_ANSWER = "a=sendonly"
def mock_camera_prefs(hass, entity_id, prefs=None):

View file

@ -0,0 +1,53 @@
"""Test helpers for camera."""
from unittest.mock import PropertyMock, patch
import pytest
from homeassistant.components import camera
from homeassistant.components.camera.const import STREAM_TYPE_HLS, STREAM_TYPE_WEB_RTC
from homeassistant.setup import async_setup_component
from .common import WEBRTC_ANSWER
@pytest.fixture(name="mock_camera")
async def mock_camera_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.demo.camera.Path.read_bytes",
return_value=b"Test",
):
yield
@pytest.fixture(name="mock_camera_hls")
async def mock_camera_hls_fixture(mock_camera):
"""Initialize a demo camera platform with HLS."""
with patch(
"homeassistant.components.camera.Camera.frontend_stream_type",
new_callable=PropertyMock(return_value=STREAM_TYPE_HLS),
):
yield
@pytest.fixture(name="mock_camera_web_rtc")
async def mock_camera_web_rtc_fixture(hass):
"""Initialize a demo camera platform with WebRTC."""
assert await async_setup_component(
hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
)
await hass.async_block_till_done()
with patch(
"homeassistant.components.camera.Camera.frontend_stream_type",
new_callable=PropertyMock(return_value=STREAM_TYPE_WEB_RTC),
), patch(
"homeassistant.components.camera.Camera.async_handle_web_rtc_offer",
return_value=WEBRTC_ANSWER,
):
yield

View file

@ -8,11 +8,7 @@ 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,
STREAM_TYPE_WEB_RTC,
)
from homeassistant.components.camera.const import DOMAIN, PREF_PRELOAD_STREAM
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
@ -24,47 +20,11 @@ from homeassistant.const import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg
from tests.components.camera import common
from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_camera_prefs, mock_turbo_jpeg
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
WEBRTC_OFFER = "v=0\r\n"
WEBRTC_ANSWER = "a=sendonly"
@pytest.fixture(name="mock_camera")
async def mock_camera_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.demo.camera.Path.read_bytes",
return_value=b"Test",
):
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.frontend_stream_type",
new_callable=PropertyMock(return_value=STREAM_TYPE_WEB_RTC),
), patch(
"homeassistant.components.camera.Camera.async_handle_web_rtc_offer",
return_value=WEBRTC_ANSWER,
):
yield
@pytest.fixture(name="mock_stream")
@ -78,7 +38,7 @@ def mock_stream_fixture(hass):
@pytest.fixture(name="setup_camera_prefs")
def setup_camera_prefs_fixture(hass):
"""Initialize HTTP API."""
return common.mock_camera_prefs(hass, "camera.demo_camera")
return mock_camera_prefs(hass, "camera.demo_camera")
@pytest.fixture(name="image_mock_url")

View file

@ -0,0 +1,72 @@
"""Test camera media source."""
from unittest.mock import PropertyMock, patch
import pytest
from homeassistant.components import media_source
from homeassistant.components.camera.const import STREAM_TYPE_WEB_RTC
from homeassistant.components.stream.const import FORMAT_CONTENT_TYPE
from homeassistant.setup import async_setup_component
@pytest.fixture(autouse=True)
async def setup_media_source(hass):
"""Set up media source."""
assert await async_setup_component(hass, "media_source", {})
@pytest.fixture(autouse=True)
async def mock_stream(hass):
"""Mock stream."""
hass.config.components.add("stream")
async def test_browsing(hass, mock_camera_hls):
"""Test browsing camera media source."""
item = await media_source.async_browse_media(hass, "media-source://camera")
assert item is not None
assert item.title == "Camera"
assert len(item.children) == 2
async def test_browsing_filter_non_hls(hass, mock_camera_web_rtc):
"""Test browsing camera media source hides non-HLS cameras."""
item = await media_source.async_browse_media(hass, "media-source://camera")
assert item is not None
assert item.title == "Camera"
assert len(item.children) == 0
async def test_resolving(hass, mock_camera_hls):
"""Test resolving."""
with patch(
"homeassistant.components.camera.media_source._async_stream_endpoint_url",
return_value="http://example.com/stream",
):
item = await media_source.async_resolve_media(
hass, "media-source://camera/camera.demo_camera"
)
assert item is not None
assert item.url == "http://example.com/stream"
assert item.mime_type == FORMAT_CONTENT_TYPE["hls"]
async def test_resolving_errors(hass, mock_camera_hls):
"""Test resolving."""
with pytest.raises(media_source.Unresolvable):
await media_source.async_resolve_media(
hass, "media-source://camera/camera.non_existing"
)
with pytest.raises(media_source.Unresolvable), patch(
"homeassistant.components.camera.Camera.frontend_stream_type",
new_callable=PropertyMock(return_value=STREAM_TYPE_WEB_RTC),
):
await media_source.async_resolve_media(
hass, "media-source://camera/camera.demo_camera"
)
with pytest.raises(media_source.Unresolvable):
await media_source.async_resolve_media(
hass, "media-source://camera/camera.demo_camera"
)

View file

@ -10,6 +10,7 @@ import attr
import pychromecast
from pychromecast.const import CAST_TYPE_CHROMECAST, CAST_TYPE_GROUP
import pytest
import yarl
from homeassistant.components import media_player, tts
from homeassistant.components.cast import media_player as cast
@ -37,7 +38,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import entity_registry as er, network
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.setup import async_setup_component
@ -1001,7 +1002,7 @@ async def test_entity_play_media_sign_URL(hass: HomeAssistant, quick_play_mock):
await async_process_ha_core_config(
hass,
{"external_url": "http://example.com:8123"},
{"internal_url": "http://example.com:8123"},
)
info = get_fake_chromecast_info()
@ -1824,3 +1825,69 @@ async def test_cast_platform_browse_media(hass: HomeAssistant, hass_ws_client):
"children": [],
}
assert response["result"] == expected_response
async def test_cast_platform_play_media_local_media(
hass: HomeAssistant, quick_play_mock, caplog
):
"""Test we process data when playing local media."""
entity_id = "media_player.speaker"
info = get_fake_chromecast_info()
chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, _ = get_status_callbacks(chromecast)
# Bring Chromecast online
connection_status = MagicMock()
connection_status.status = "CONNECTED"
conn_status_cb(connection_status)
await hass.async_block_till_done()
# This will play using the cast platform
await hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: entity_id,
media_player.ATTR_MEDIA_CONTENT_TYPE: "application/vnd.apple.mpegurl",
media_player.ATTR_MEDIA_CONTENT_ID: "/api/hls/bla/master_playlist.m3u8",
},
blocking=True,
)
await hass.async_block_till_done()
# Assert we added extra play information
quick_play_mock.assert_called()
app_data = quick_play_mock.call_args[0][2]
assert not app_data["media_id"].startswith("/")
assert "authSig" in yarl.URL(app_data["media_id"]).query
assert app_data["media_type"] == "application/vnd.apple.mpegurl"
assert app_data["stream_type"] == "LIVE"
assert app_data["media_info"] == {
"hlsVideoSegmentFormat": "fmp4",
}
quick_play_mock.reset_mock()
# Test not appending if we have a signature
await hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: entity_id,
media_player.ATTR_MEDIA_CONTENT_TYPE: "application/vnd.apple.mpegurl",
media_player.ATTR_MEDIA_CONTENT_ID: f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla",
},
blocking=True,
)
await hass.async_block_till_done()
# Assert we added extra play information
quick_play_mock.assert_called()
app_data = quick_play_mock.call_args[0][2]
# No authSig appended
assert (
app_data["media_id"]
== f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla"
)

View file

@ -13,6 +13,7 @@ from homeassistant.helpers.network import (
_get_internal_url,
_get_request_host,
get_url,
is_hass_url,
is_internal_request,
)
@ -645,3 +646,47 @@ async def test_is_internal_request(hass: HomeAssistant):
"homeassistant.helpers.network._get_request_host", return_value="192.168.0.1"
):
assert is_internal_request(hass)
async def test_is_hass_url(hass):
"""Test is_hass_url."""
assert hass.config.api is None
assert hass.config.internal_url is None
assert hass.config.external_url is None
assert is_hass_url(hass, "http://example.com") is False
hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123")
assert is_hass_url(hass, "http://192.168.123.123:8123") is True
assert is_hass_url(hass, "https://192.168.123.123:8123") is False
assert is_hass_url(hass, "http://192.168.123.123") is False
await async_process_ha_core_config(
hass,
{"internal_url": "http://example.local:8123"},
)
assert is_hass_url(hass, "http://example.local:8123") is True
assert is_hass_url(hass, "https://example.local:8123") is False
assert is_hass_url(hass, "http://example.local") is False
await async_process_ha_core_config(
hass,
{"external_url": "https://example.com:443"},
)
assert is_hass_url(hass, "https://example.com:443") is True
assert is_hass_url(hass, "https://example.com") is True
assert is_hass_url(hass, "http://example.com:443") is False
assert is_hass_url(hass, "http://example.com") is False
with patch.object(
hass.components.cloud,
"async_remote_ui_url",
return_value="https://example.nabu.casa",
):
assert is_hass_url(hass, "https://example.nabu.casa") is False
hass.config.components.add("cloud")
assert is_hass_url(hass, "https://example.nabu.casa:443") is True
assert is_hass_url(hass, "https://example.nabu.casa") is True
assert is_hass_url(hass, "http://example.nabu.casa:443") is False
assert is_hass_url(hass, "http://example.nabu.casa") is False