From 157276f4e6de6c58b59f8c743d43ec14edc0dd36 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 3 Feb 2022 13:43:23 -0800 Subject: [PATCH] Add a Lovelace cast platform (#65401) --- .../components/cast/home_assistant_cast.py | 9 +- homeassistant/components/lovelace/cast.py | 204 ++++++++++++++++++ .../cast/test_home_assistant_cast.py | 21 +- tests/components/lovelace/test_cast.py | 182 ++++++++++++++++ 4 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/lovelace/cast.py create mode 100644 tests/components/lovelace/test_cast.py diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index 0f312d6a37a..2f9583f329c 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -6,14 +6,16 @@ import voluptuous as vol from homeassistant import auth, config_entries, core from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, dispatcher -from homeassistant.helpers.network import get_url +from homeassistant.helpers.network import NoURLAvailableError, get_url from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW SERVICE_SHOW_VIEW = "show_lovelace_view" ATTR_VIEW_PATH = "view_path" ATTR_URL_PATH = "dashboard_path" +NO_URL_AVAILABLE_ERROR = "Home Assistant Cast requires your instance to be reachable via HTTPS. Enable Home Assistant Cloud or set up an external URL with valid SSL certificates" async def async_setup_ha_cast( @@ -41,7 +43,10 @@ async def async_setup_ha_cast( async def handle_show_view(call: core.ServiceCall) -> None: """Handle a Show View service call.""" - hass_url = get_url(hass, require_ssl=True, prefer_external=True) + try: + hass_url = get_url(hass, require_ssl=True, prefer_external=True) + except NoURLAvailableError as err: + raise HomeAssistantError(NO_URL_AVAILABLE_ERROR) from err controller = HomeAssistantController( # If you are developing Home Assistant Cast, uncomment and set to your dev app id. diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py new file mode 100644 index 00000000000..02280ebd182 --- /dev/null +++ b/homeassistant/components/lovelace/cast.py @@ -0,0 +1,204 @@ +"""Home Assistant Cast platform.""" + +from __future__ import annotations + +from pychromecast import Chromecast +from pychromecast.const import CAST_TYPE_CHROMECAST + +from homeassistant.components.cast.const import DOMAIN as CAST_DOMAIN +from homeassistant.components.cast.home_assistant_cast import ( + ATTR_URL_PATH, + ATTR_VIEW_PATH, + NO_URL_AVAILABLE_ERROR, + SERVICE_SHOW_VIEW, +) +from homeassistant.components.media_player import BrowseError, BrowseMedia +from homeassistant.components.media_player.const import MEDIA_CLASS_APP +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.network import NoURLAvailableError, get_url + +from .const import DOMAIN, ConfigNotFound +from .dashboard import LovelaceConfig + +DEFAULT_DASHBOARD = "_default_" + + +async def async_get_media_browser_root_object( + hass: HomeAssistant, cast_type: str +) -> list[BrowseMedia]: + """Create a root object for media browsing.""" + if cast_type != CAST_TYPE_CHROMECAST: + return [] + return [ + BrowseMedia( + title="Lovelace", + media_class=MEDIA_CLASS_APP, + media_content_id="", + media_content_type=DOMAIN, + thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", + can_play=False, + can_expand=True, + ) + ] + + +async def async_browse_media( + hass: HomeAssistant, + media_content_type: str, + media_content_id: str, + cast_type: str, +) -> BrowseMedia | None: + """Browse media.""" + if media_content_type != DOMAIN: + return None + + try: + get_url(hass, require_ssl=True, prefer_external=True) + except NoURLAvailableError as err: + raise BrowseError(NO_URL_AVAILABLE_ERROR) from err + + # List dashboards. + if not media_content_id: + children = [ + BrowseMedia( + title="Default", + media_class=MEDIA_CLASS_APP, + media_content_id=DEFAULT_DASHBOARD, + media_content_type=DOMAIN, + thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", + can_play=True, + can_expand=False, + ) + ] + for url_path in hass.data[DOMAIN]["dashboards"]: + if url_path is None: + continue + + info = await _get_dashboard_info(hass, url_path) + children.append(_item_from_info(info)) + + root = (await async_get_media_browser_root_object(hass, CAST_TYPE_CHROMECAST))[ + 0 + ] + root.children = children + return root + + try: + info = await _get_dashboard_info(hass, media_content_id) + except ValueError as err: + raise BrowseError(f"Dashboard {media_content_id} not found") from err + + children = [] + + for view in info["views"]: + children.append( + BrowseMedia( + title=view["title"], + media_class=MEDIA_CLASS_APP, + media_content_id=f'{info["url_path"]}/{view["path"]}', + media_content_type=DOMAIN, + thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", + can_play=True, + can_expand=False, + ) + ) + + root = _item_from_info(info) + root.children = children + return root + + +async def async_play_media( + hass: HomeAssistant, + cast_entity_id: str, + chromecast: Chromecast, + media_type: str, + media_id: str, +) -> bool: + """Play media.""" + if media_type != DOMAIN: + return False + + if "/" in media_id: + url_path, view_path = media_id.split("/", 1) + else: + url_path = media_id + try: + info = await _get_dashboard_info(hass, media_id) + except ValueError as err: + raise HomeAssistantError(f"Invalid dashboard {media_id} specified") from err + view_path = info["views"][0]["path"] if info["views"] else "0" + + data = { + ATTR_ENTITY_ID: cast_entity_id, + ATTR_VIEW_PATH: view_path, + } + if url_path != DEFAULT_DASHBOARD: + data[ATTR_URL_PATH] = url_path + + await hass.services.async_call( + CAST_DOMAIN, + SERVICE_SHOW_VIEW, + data, + blocking=True, + ) + return True + + +async def _get_dashboard_info(hass, url_path): + """Load a dashboard and return info on views.""" + if url_path == DEFAULT_DASHBOARD: + url_path = None + dashboard: LovelaceConfig | None = hass.data[DOMAIN]["dashboards"].get(url_path) + + if dashboard is None: + raise ValueError("Invalid dashboard specified") + + try: + config = await dashboard.async_load(False) + except ConfigNotFound: + config = None + + if dashboard.url_path is None: + url_path = DEFAULT_DASHBOARD + title = "Default" + else: + url_path = dashboard.url_path + title = config.get("title", url_path) if config else url_path + + views = [] + data = { + "title": title, + "url_path": url_path, + "views": views, + } + + if config is None: + return data + + for idx, view in enumerate(config["views"]): + path = view.get("path", f"{idx}") + views.append( + { + "title": view.get("title", path), + "path": path, + } + ) + + return data + + +@callback +def _item_from_info(info: dict) -> BrowseMedia: + """Convert dashboard info to browse item.""" + return BrowseMedia( + title=info["title"], + media_class=MEDIA_CLASS_APP, + media_content_id=info["url_path"], + media_content_type=DOMAIN, + thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", + can_play=True, + can_expand=len(info["views"]) > 1, + ) diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py index 67b5454b6e1..a799a6d1d36 100644 --- a/tests/components/cast/test_home_assistant_cast.py +++ b/tests/components/cast/test_home_assistant_cast.py @@ -2,21 +2,34 @@ from unittest.mock import patch +import pytest + from homeassistant.components.cast import home_assistant_cast from homeassistant.config import async_process_ha_core_config +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry, async_mock_signal async def test_service_show_view(hass, mock_zeroconf): - """Test we don't set app id in prod.""" + """Test showing a view.""" + await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry()) + calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW) + + # No valid URL + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "cast", + "show_lovelace_view", + {"entity_id": "media_player.kitchen", "view_path": "mock_path"}, + blocking=True, + ) + + # Set valid URL await async_process_ha_core_config( hass, {"external_url": "https://example.com"}, ) - await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry()) - calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW) - await hass.services.async_call( "cast", "show_lovelace_view", diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py new file mode 100644 index 00000000000..d5b8e43d2bb --- /dev/null +++ b/tests/components/lovelace/test_cast.py @@ -0,0 +1,182 @@ +"""Test the Lovelace Cast platform.""" +from time import time +from unittest.mock import patch + +import pytest + +from homeassistant.components.lovelace import cast as lovelace_cast +from homeassistant.config import async_process_ha_core_config +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + + +@pytest.fixture +async def mock_https_url(hass): + """Mock valid URL.""" + await async_process_ha_core_config( + hass, + {"external_url": "https://example.com"}, + ) + + +@pytest.fixture +async def mock_yaml_dashboard(hass): + """Mock the content of a YAML dashboard.""" + # Set up a YAML dashboard with 2 views. + assert await async_setup_component( + hass, + "lovelace", + { + "lovelace": { + "dashboards": { + "yaml-with-views": { + "title": "YAML Title", + "mode": "yaml", + "filename": "bla.yaml", + } + } + } + }, + ) + + with patch( + "homeassistant.components.lovelace.dashboard.load_yaml", + return_value={ + "title": "YAML Title", + "views": [ + { + "title": "Hello", + }, + {"path": "second-view"}, + ], + }, + ), patch( + "homeassistant.components.lovelace.dashboard.os.path.getmtime", + return_value=time() + 10, + ): + yield + + +async def test_root_object(hass): + """Test getting a root object.""" + assert ( + await lovelace_cast.async_get_media_browser_root_object(hass, "some-type") == [] + ) + + root = await lovelace_cast.async_get_media_browser_root_object( + hass, lovelace_cast.CAST_TYPE_CHROMECAST + ) + assert len(root) == 1 + item = root[0] + assert item.title == "Lovelace" + assert item.media_class == lovelace_cast.MEDIA_CLASS_APP + assert item.media_content_id == "" + assert item.media_content_type == lovelace_cast.DOMAIN + assert item.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png" + assert item.can_play is False + assert item.can_expand is True + + +async def test_browse_media_error(hass): + """Test browse media checks valid URL.""" + assert await async_setup_component(hass, "lovelace", {}) + + with pytest.raises(HomeAssistantError): + await lovelace_cast.async_browse_media( + hass, "lovelace", "", lovelace_cast.CAST_TYPE_CHROMECAST + ) + + assert ( + await lovelace_cast.async_browse_media( + hass, "not_lovelace", "", lovelace_cast.CAST_TYPE_CHROMECAST + ) + is None + ) + + +async def test_browse_media(hass, mock_yaml_dashboard, mock_https_url): + """Test browse media.""" + top_level_items = await lovelace_cast.async_browse_media( + hass, "lovelace", "", lovelace_cast.CAST_TYPE_CHROMECAST + ) + + assert len(top_level_items.children) == 2 + + child_1 = top_level_items.children[0] + assert child_1.title == "Default" + assert child_1.media_class == lovelace_cast.MEDIA_CLASS_APP + assert child_1.media_content_id == lovelace_cast.DEFAULT_DASHBOARD + assert child_1.media_content_type == lovelace_cast.DOMAIN + assert child_1.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png" + assert child_1.can_play is True + assert child_1.can_expand is False + + child_2 = top_level_items.children[1] + assert child_2.title == "YAML Title" + assert child_2.media_class == lovelace_cast.MEDIA_CLASS_APP + assert child_2.media_content_id == "yaml-with-views" + assert child_2.media_content_type == lovelace_cast.DOMAIN + assert child_2.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png" + assert child_2.can_play is True + assert child_2.can_expand is True + + child_2 = await lovelace_cast.async_browse_media( + hass, "lovelace", child_2.media_content_id, lovelace_cast.CAST_TYPE_CHROMECAST + ) + + assert len(child_2.children) == 2 + + grandchild_1 = child_2.children[0] + assert grandchild_1.title == "Hello" + assert grandchild_1.media_class == lovelace_cast.MEDIA_CLASS_APP + assert grandchild_1.media_content_id == "yaml-with-views/0" + assert grandchild_1.media_content_type == lovelace_cast.DOMAIN + assert ( + grandchild_1.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png" + ) + assert grandchild_1.can_play is True + assert grandchild_1.can_expand is False + + grandchild_2 = child_2.children[1] + assert grandchild_2.title == "second-view" + assert grandchild_2.media_class == lovelace_cast.MEDIA_CLASS_APP + assert grandchild_2.media_content_id == "yaml-with-views/second-view" + assert grandchild_2.media_content_type == lovelace_cast.DOMAIN + assert ( + grandchild_2.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png" + ) + assert grandchild_2.can_play is True + assert grandchild_2.can_expand is False + + with pytest.raises(HomeAssistantError): + await lovelace_cast.async_browse_media( + hass, + "lovelace", + "non-existing-dashboard", + lovelace_cast.CAST_TYPE_CHROMECAST, + ) + + +async def test_play_media(hass, mock_yaml_dashboard): + """Test playing media.""" + calls = async_mock_service(hass, "cast", "show_lovelace_view") + + await lovelace_cast.async_play_media( + hass, "media_player.my_cast", None, "lovelace", lovelace_cast.DEFAULT_DASHBOARD + ) + + assert len(calls) == 1 + assert calls[0].data["entity_id"] == "media_player.my_cast" + assert "dashboard_path" not in calls[0].data + assert calls[0].data["view_path"] == "0" + + await lovelace_cast.async_play_media( + hass, "media_player.my_cast", None, "lovelace", "yaml-with-views/second-view" + ) + + assert len(calls) == 2 + assert calls[1].data["entity_id"] == "media_player.my_cast" + assert calls[1].data["dashboard_path"] == "yaml-with-views" + assert calls[1].data["view_path"] == "second-view"