diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 110cf11cde9..a2eb70e1c42 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -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,56 +969,22 @@ 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}", - 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, - ) + 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], + }, + blocking=True, + context=service_call.context, + ) async def _async_stream_endpoint_url( diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py new file mode 100644 index 00000000000..841a9365320 --- /dev/null +++ b/homeassistant/components/camera/media_source.py @@ -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, + ) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index d418373e599..1354c5c00fb 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -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( diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 0c52012e43c..9d7780ab900 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -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, diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index bd3841cc4e8..ee2a3cb2974 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -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): diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py new file mode 100644 index 00000000000..b09f7696ef2 --- /dev/null +++ b/tests/components/camera/conftest.py @@ -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 diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 403cacec1f1..0e53e163404 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -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") diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py new file mode 100644 index 00000000000..d5d65296e65 --- /dev/null +++ b/tests/components/camera/test_media_source.py @@ -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" + ) diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 51fe4a086a6..36ee2ce818a 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -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" + ) diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 7e9086f4467..15a9b8d1ff8 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -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