Add a Lovelace cast platform (#65401)
This commit is contained in:
parent
d3e36230cb
commit
157276f4e6
4 changed files with 410 additions and 6 deletions
|
@ -6,14 +6,16 @@ import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import auth, config_entries, core
|
from homeassistant import auth, config_entries, core
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv, dispatcher
|
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
|
from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW
|
||||||
|
|
||||||
SERVICE_SHOW_VIEW = "show_lovelace_view"
|
SERVICE_SHOW_VIEW = "show_lovelace_view"
|
||||||
ATTR_VIEW_PATH = "view_path"
|
ATTR_VIEW_PATH = "view_path"
|
||||||
ATTR_URL_PATH = "dashboard_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(
|
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:
|
async def handle_show_view(call: core.ServiceCall) -> None:
|
||||||
"""Handle a Show View service call."""
|
"""Handle a Show View service call."""
|
||||||
|
try:
|
||||||
hass_url = get_url(hass, require_ssl=True, prefer_external=True)
|
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(
|
controller = HomeAssistantController(
|
||||||
# If you are developing Home Assistant Cast, uncomment and set to your dev app id.
|
# If you are developing Home Assistant Cast, uncomment and set to your dev app id.
|
||||||
|
|
204
homeassistant/components/lovelace/cast.py
Normal file
204
homeassistant/components/lovelace/cast.py
Normal file
|
@ -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,
|
||||||
|
)
|
|
@ -2,21 +2,34 @@
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.cast import home_assistant_cast
|
from homeassistant.components.cast import home_assistant_cast
|
||||||
from homeassistant.config import async_process_ha_core_config
|
from homeassistant.config import async_process_ha_core_config
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, async_mock_signal
|
from tests.common import MockConfigEntry, async_mock_signal
|
||||||
|
|
||||||
|
|
||||||
async def test_service_show_view(hass, mock_zeroconf):
|
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(
|
await async_process_ha_core_config(
|
||||||
hass,
|
hass,
|
||||||
{"external_url": "https://example.com"},
|
{"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(
|
await hass.services.async_call(
|
||||||
"cast",
|
"cast",
|
||||||
"show_lovelace_view",
|
"show_lovelace_view",
|
||||||
|
|
182
tests/components/lovelace/test_cast.py
Normal file
182
tests/components/lovelace/test_cast.py
Normal file
|
@ -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"
|
Loading…
Add table
Add a link
Reference in a new issue