diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index cc112984f88..4dfb58ef3b7 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,6 +1,7 @@ """Component to embed Google Cast.""" from homeassistant import config_entries +from . import home_assistant_cast from .const import DOMAIN @@ -20,8 +21,10 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up Cast from a config entry.""" + await home_assistant_cast.async_setup_ha_cast(hass, entry) + hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "media_player") ) diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index a493c322f14..c6164484dbb 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -21,3 +21,6 @@ SIGNAL_CAST_DISCOVERED = "cast_discovered" # Dispatcher signal fired with a ChromecastInfo every time a Chromecast is # removed SIGNAL_CAST_REMOVED = "cast_removed" + +# Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. +SIGNAL_HASS_CAST_SHOW_VIEW = "cast_show_view" diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py new file mode 100644 index 00000000000..f604594bfc5 --- /dev/null +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -0,0 +1,64 @@ +"""Home Assistant Cast integration for Cast.""" +from typing import Optional + +import voluptuous as vol + +from pychromecast.controllers.homeassistant import HomeAssistantController + +from homeassistant import auth, config_entries, core +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import config_validation as cv, dispatcher + +from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW + +SERVICE_SHOW_VIEW = "show_lovelace_view" +ATTR_VIEW_PATH = "view_path" + + +async def async_setup_ha_cast( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up Home Assistant Cast.""" + user_id: Optional[str] = entry.data.get("user_id") + user: Optional[auth.models.User] = None + + if user_id is not None: + user = await hass.auth.async_get_user(user_id) + + if user is None: + user = await hass.auth.async_create_system_user( + "Home Assistant Cast", [auth.GROUP_ID_ADMIN] + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, "user_id": user.id} + ) + + if user.refresh_tokens: + refresh_token: auth.models.RefreshToken = list(user.refresh_tokens.values())[0] + else: + refresh_token = await hass.auth.async_create_refresh_token(user) + + async def handle_show_view(call: core.ServiceCall): + """Handle a Show View service call.""" + controller = HomeAssistantController( + # If you are developing Home Assistant Cast, uncomment and set to your dev app id. + # app_id="5FE44367", + hass_url=hass.config.api.base_url, + client_id=None, + refresh_token=refresh_token.token, + ) + + dispatcher.async_dispatcher_send( + hass, + SIGNAL_HASS_CAST_SHOW_VIEW, + controller, + call.data[ATTR_ENTITY_ID], + call.data[ATTR_VIEW_PATH], + ) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_SHOW_VIEW, + handle_show_view, + vol.Schema({ATTR_ENTITY_ID: cv.entity_id, ATTR_VIEW_PATH: str}), + ) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index ff9e8907ec5..4fb1c67a56e 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,9 +3,7 @@ "name": "Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/components/cast", - "requirements": [ - "pychromecast==3.2.2" - ], + "requirements": ["pychromecast==4.0.0"], "dependencies": [], "zeroconf": ["_googlecast._tcp.local."], "codeowners": [] diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 7b750c3fe0c..c2d847fd09b 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -9,6 +9,7 @@ from pychromecast.socket_client import ( CONNECTION_STATUS_DISCONNECTED, ) from pychromecast.controllers.multizone import MultizoneManager +from pychromecast.controllers.homeassistant import HomeAssistantController import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice @@ -52,6 +53,7 @@ from .const import ( CAST_MULTIZONE_MANAGER_KEY, DEFAULT_PORT, SIGNAL_CAST_REMOVED, + SIGNAL_HASS_CAST_SHOW_VIEW, ) from .helpers import ( ChromecastInfo, @@ -225,9 +227,11 @@ class CastDevice(MediaPlayerDevice): self._dynamic_group_status_listener: Optional[ DynamicGroupCastStatusListener ] = None + self._hass_cast_controller: Optional[HomeAssistantController] = None self._add_remove_handler = None self._del_remove_handler = None + self._cast_view_remove_handler = None async def async_added_to_hass(self): """Create chromecast object when added to hass.""" @@ -256,6 +260,10 @@ class CastDevice(MediaPlayerDevice): ) break + self._cast_view_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_HASS_CAST_SHOW_VIEW, self._handle_signal_show_view + ) + async def async_will_remove_from_hass(self) -> None: """Disconnect Chromecast object when removed.""" await self._async_disconnect() @@ -265,8 +273,13 @@ class CastDevice(MediaPlayerDevice): self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid) if self._add_remove_handler: self._add_remove_handler() + self._add_remove_handler = None if self._del_remove_handler: self._del_remove_handler() + self._del_remove_handler = None + if self._cast_view_remove_handler: + self._cast_view_remove_handler() + self._cast_view_remove_handler = None async def async_set_cast_info(self, cast_info): """Set the cast information and set up the chromecast object.""" @@ -453,6 +466,7 @@ class CastDevice(MediaPlayerDevice): self.mz_media_status = {} self.mz_media_status_received = {} self.mz_mgr = None + self._hass_cast_controller = None if self._status_listener is not None: self._status_listener.invalidate() self._status_listener = None @@ -932,3 +946,16 @@ class CastDevice(MediaPlayerDevice): async def _async_stop(self, event): """Disconnect socket on Home Assistant stop.""" await self._async_disconnect() + + def _handle_signal_show_view( + self, controller: HomeAssistantController, entity_id: str, view_path: str + ): + """Handle a show view signal.""" + if entity_id != self.entity_id: + return + + if self._hass_cast_controller is None: + self._hass_cast_controller = controller + self._chromecast.register_handler(controller) + + self._hass_cast_controller.show_lovelace_view(view_path) diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml new file mode 100644 index 00000000000..24bc7b16a90 --- /dev/null +++ b/homeassistant/components/cast/services.yaml @@ -0,0 +1,9 @@ +show_lovelace_view: + description: Show a Lovelace view on a Chromecast. + fields: + entity_id: + description: Media Player entity to show the Lovelace view on. + example: "media_player.kitchen" + view_path: + description: The path of the Lovelace view to show. + example: downstairs diff --git a/requirements_all.txt b/requirements_all.txt index 173e203a892..80868fa05b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1110,7 +1110,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==3.2.2 +pychromecast==4.0.0 # homeassistant.components.cmus pycmus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e80567f217..c9c32d596bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -279,7 +279,7 @@ pyMetno==0.4.6 pyblackbird==0.5 # homeassistant.components.cast -pychromecast==3.2.2 +pychromecast==4.0.0 # homeassistant.components.deconz pydeconz==62 diff --git a/tests/common.py b/tests/common.py index 0e2f701c210..847635d4dad 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1030,3 +1030,18 @@ def async_capture_events(hass, event_name): hass.bus.async_listen(event_name, capture_events) return events + + +@ha.callback +def async_mock_signal(hass, signal): + """Catch all dispatches to a signal.""" + calls = [] + + @ha.callback + def mock_signal_handler(*args): + """Mock service call.""" + calls.append(args) + + hass.helpers.dispatcher.async_dispatcher_connect(signal, mock_signal_handler) + + return calls diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py new file mode 100644 index 00000000000..e67b8f70160 --- /dev/null +++ b/tests/components/cast/test_home_assistant_cast.py @@ -0,0 +1,28 @@ +"""Test Home Assistant Cast.""" +from unittest.mock import Mock +from homeassistant.components.cast import home_assistant_cast + +from tests.common import MockConfigEntry, async_mock_signal + + +async def test_service_show_view(hass): + """Test we don't set app id in prod.""" + hass.config.api = Mock(base_url="http://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", + {"entity_id": "media_player.kitchen", "view_path": "mock_path"}, + blocking=True, + ) + + assert len(calls) == 1 + controller, entity_id, view_path = calls[0] + assert controller.hass_url == "http://example.com" + assert controller.client_id is None + # Verify user did not accidentally submit their dev app id + assert controller.supporting_app_id == "B12CE3CA" + assert entity_id == "media_player.kitchen" + assert view_path == "mock_path"