From 06a21d4ed95e0f23147b13633d2fab820af11e61 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 15 Feb 2024 20:14:01 +0100 Subject: [PATCH] Bump pychromecast to 14.0.0 (#108374) * Pass unregister callback to cast HomeAssisstantController * Update tests * Bump pychromecast to 14.0.0 * Fix lint warning, adjust tests * Improve test coverage --- homeassistant/components/cast/const.py | 15 +- homeassistant/components/cast/helpers.py | 4 +- .../components/cast/home_assistant_cast.py | 7 +- homeassistant/components/cast/manifest.json | 2 +- homeassistant/components/cast/media_player.py | 26 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cast/conftest.py | 10 ++ .../cast/test_home_assistant_cast.py | 14 +- tests/components/cast/test_media_player.py | 136 ++++++++++++------ 10 files changed, 152 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 730757de8b4..f05c2c4c143 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,9 +1,7 @@ """Consts for Cast integration.""" from __future__ import annotations -from typing import TYPE_CHECKING - -from pychromecast.controllers.homeassistant import HomeAssistantController +from typing import TYPE_CHECKING, TypedDict from homeassistant.helpers.dispatcher import SignalType @@ -33,8 +31,17 @@ SIGNAL_CAST_REMOVED: SignalType[ChromecastInfo] = SignalType("cast_removed") # Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[ - HomeAssistantController, str, str, str | None + HomeAssistantControllerData, str, str, str | None ] = SignalType("cast_show_view") CONF_IGNORE_CEC = "ignore_cec" CONF_KNOWN_HOSTS = "known_hosts" + + +class HomeAssistantControllerData(TypedDict): + """Data for creating a HomeAssistantController.""" + + hass_url: str + hass_uuid: str + client_id: str | None + refresh_token: str diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index a3158ee819e..3d880b40407 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -182,10 +182,10 @@ class CastStatusListener( if self._valid: self._cast_device.new_media_status(status) - def load_media_failed(self, item, error_code): + def load_media_failed(self, queue_item_id, error_code): """Handle reception of a new MediaStatus.""" if self._valid: - self._cast_device.load_media_failed(item, error_code) + self._cast_device.load_media_failed(queue_item_id, error_code) def new_connection_status(self, status): """Handle reception of a new ConnectionStatus.""" diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index 5eec2a28908..f7518b9519a 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -1,7 +1,6 @@ """Home Assistant Cast integration for Cast.""" from __future__ import annotations -from pychromecast.controllers.homeassistant import HomeAssistantController import voluptuous as vol from homeassistant import auth, config_entries, core @@ -11,7 +10,7 @@ from homeassistant.helpers import config_validation as cv, dispatcher, instance_ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service import async_register_admin_service -from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW +from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData SERVICE_SHOW_VIEW = "show_lovelace_view" ATTR_VIEW_PATH = "view_path" @@ -55,7 +54,7 @@ async def async_setup_ha_cast( hass_uuid = await instance_id.async_get(hass) - controller = HomeAssistantController( + controller_data = HomeAssistantControllerData( # If you are developing Home Assistant Cast, uncomment and set to # your dev app id. # app_id="5FE44367", @@ -68,7 +67,7 @@ async def async_setup_ha_cast( dispatcher.async_dispatcher_send( hass, SIGNAL_HASS_CAST_SHOW_VIEW, - controller, + controller_data, call.data[ATTR_ENTITY_ID], call.data[ATTR_VIEW_PATH], call.data.get(ATTR_URL_PATH), diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index ae049fefef6..d02bcd3558a 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==13.1.0"], + "requirements": ["PyChromecast==14.0.0"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index b472b18bed0..b2893a54310 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -61,6 +61,7 @@ from .const import ( SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_REMOVED, SIGNAL_HASS_CAST_SHOW_VIEW, + HomeAssistantControllerData, ) from .discovery import setup_internal_discovery from .helpers import ( @@ -389,15 +390,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() - def load_media_failed(self, item, error_code): + def load_media_failed(self, queue_item_id, error_code): """Handle load media failed.""" _LOGGER.debug( - "[%s %s] Load media failed with code %s(%s) for item %s", + "[%s %s] Load media failed with code %s(%s) for queue_item_id %s", self.entity_id, self._cast_info.friendly_name, error_code, MEDIA_PLAYER_ERROR_CODES.get(error_code, "unknown code"), - item, + queue_item_id, ) def new_connection_status(self, connection_status): @@ -951,7 +952,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): def _handle_signal_show_view( self, - controller: HomeAssistantController, + controller_data: HomeAssistantControllerData, entity_id: str, view_path: str, url_path: str | None, @@ -961,6 +962,23 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): return if self._hass_cast_controller is None: + + def unregister() -> None: + """Handle request to unregister the handler.""" + if not self._hass_cast_controller or not self._chromecast: + return + _LOGGER.debug( + "[%s %s] Unregistering HomeAssistantController", + self.entity_id, + self._cast_info.friendly_name, + ) + + self._chromecast.unregister_handler(self._hass_cast_controller) + self._hass_cast_controller = None + + controller = HomeAssistantController( + **controller_data, unregister=unregister + ) self._hass_cast_controller = controller self._chromecast.register_handler(controller) diff --git a/requirements_all.txt b/requirements_all.txt index b19598cf790..b0b71f7c853 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -54,7 +54,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==13.1.0 +PyChromecast==14.0.0 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e58920552a..00fdfbe557e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ PlexAPI==4.15.9 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==13.1.0 +PyChromecast==14.0.0 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/tests/components/cast/conftest.py b/tests/components/cast/conftest.py index bd1dc00659e..817c4428098 100644 --- a/tests/components/cast/conftest.py +++ b/tests/components/cast/conftest.py @@ -46,6 +46,16 @@ def get_chromecast_mock(): return MagicMock() +@pytest.fixture +def ha_controller_mock(): + """Mock HomeAssistantController.""" + with patch( + "homeassistant.components.cast.media_player.HomeAssistantController", + MagicMock(), + ) as ha_controller_mock: + yield ha_controller_mock + + @pytest.fixture(autouse=True) def cast_mock( mz_mock, diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py index fa42eb8a48c..637ba53fc93 100644 --- a/tests/components/cast/test_home_assistant_cast.py +++ b/tests/components/cast/test_home_assistant_cast.py @@ -40,11 +40,11 @@ async def test_service_show_view(hass: HomeAssistant, mock_zeroconf: None) -> No ) assert len(calls) == 1 - controller, entity_id, view_path, url_path = calls[0] - assert controller.hass_url == "https://example.com" - assert controller.client_id is None + controller_data, entity_id, view_path, url_path = calls[0] + assert controller_data["hass_url"] == "https://example.com" + assert controller_data["client_id"] is None # Verify user did not accidentally submit their dev app id - assert controller.supporting_app_id == "A078F6B0" + assert "supporting_app_id" not in controller_data assert entity_id == "media_player.kitchen" assert view_path == "mock_path" assert url_path is None @@ -75,7 +75,7 @@ async def test_service_show_view_dashboard( ) assert len(calls) == 1 - _controller, entity_id, view_path, url_path = calls[0] + _controller_data, entity_id, view_path, url_path = calls[0] assert entity_id == "media_player.kitchen" assert view_path == "mock_path" assert url_path == "mock-dashboard" @@ -106,8 +106,8 @@ async def test_use_cloud_url(hass: HomeAssistant, mock_zeroconf: None) -> None: ) assert len(calls) == 1 - controller = calls[0][0] - assert controller.hass_url == "https://something.nabu.casa" + controller_data = calls[0][0] + assert controller_data["hass_url"] == "https://something.nabu.casa" async def test_remove_entry(hass: HomeAssistant, mock_zeroconf: None) -> None: diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 2af5e67f845..df3eb866710 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -14,6 +14,10 @@ import yarl from homeassistant.components import media_player, tts from homeassistant.components.cast import media_player as cast +from homeassistant.components.cast.const import ( + SIGNAL_HASS_CAST_SHOW_VIEW, + HomeAssistantControllerData, +) from homeassistant.components.cast.media_player import ChromecastInfo from homeassistant.components.media_player import ( BrowseMedia, @@ -29,7 +33,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er, network -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.setup import async_setup_component from tests.common import ( @@ -46,12 +53,8 @@ FakeUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e2") FakeUUID2 = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e4") FakeGroupUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e3") -FAKE_HOST_SERVICE = pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_HOST, ("127.0.0.1", 8009) -) -FAKE_MDNS_SERVICE = pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "the-service" -) +FAKE_HOST_SERVICE = pychromecast.discovery.HostServiceInfo("127.0.0.1", 8009) +FAKE_MDNS_SERVICE = pychromecast.discovery.MDNSServiceInfo("the-service") UNDEFINED = object() @@ -77,9 +80,7 @@ def get_fake_chromecast_info( """Generate a Fake ChromecastInfo with the specified arguments.""" if service is None: - service = pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_HOST, (host, port) - ) + service = pychromecast.discovery.HostServiceInfo(host, port) if cast_type is UNDEFINED: cast_type = CAST_TYPE_GROUP if port != 8009 else CAST_TYPE_CHROMECAST if manufacturer is UNDEFINED: @@ -144,7 +145,11 @@ async def async_setup_cast_internal_discovery(hass, config=None): remove_callback = cast_browser.call_args[0][0].remove_cast def discover_chromecast( - service: pychromecast.discovery.ServiceInfo, info: ChromecastInfo + service: ( + pychromecast.discovery.HostServiceInfo + | pychromecast.discovery.MDNSServiceInfo + ), + info: ChromecastInfo, ) -> None: """Discover a chromecast device.""" browser.devices[info.uuid] = pychromecast.discovery.CastInfo( @@ -214,7 +219,7 @@ async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInf info.cast_info.cast_type, info.cast_info.manufacturer, ) - discovery_callback(info.uuid, FAKE_MDNS_SERVICE[1]) + discovery_callback(info.uuid, FAKE_MDNS_SERVICE.name) await hass.async_block_till_done() await hass.async_block_till_done() @@ -488,9 +493,7 @@ async def test_manual_cast_chromecasts_uuid(hass: HomeAssistant) -> None: return_value=zconf_2, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service2" - ), + pychromecast.discovery.MDNSServiceInfo("service2"), cast_2, ) await hass.async_block_till_done() @@ -502,9 +505,7 @@ async def test_manual_cast_chromecasts_uuid(hass: HomeAssistant) -> None: return_value=zconf_1, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service1" - ), + pychromecast.discovery.MDNSServiceInfo("service1"), cast_1, ) await hass.async_block_till_done() @@ -526,9 +527,7 @@ async def test_auto_cast_chromecasts(hass: HomeAssistant) -> None: return_value=zconf_1, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service2" - ), + pychromecast.discovery.MDNSServiceInfo("service2"), cast_2, ) await hass.async_block_till_done() @@ -540,9 +539,7 @@ async def test_auto_cast_chromecasts(hass: HomeAssistant) -> None: return_value=zconf_2, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service1" - ), + pychromecast.discovery.MDNSServiceInfo("service1"), cast_1, ) await hass.async_block_till_done() @@ -591,9 +588,7 @@ async def test_discover_dynamic_group( wraps=create_task, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service" - ), + pychromecast.discovery.MDNSServiceInfo("service"), cast_1, ) await hass.async_block_till_done() @@ -619,9 +614,7 @@ async def test_discover_dynamic_group( wraps=create_task, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service" - ), + pychromecast.discovery.MDNSServiceInfo("service"), cast_2, ) await hass.async_block_till_done() @@ -647,9 +640,7 @@ async def test_discover_dynamic_group( wraps=create_task, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service" - ), + pychromecast.discovery.MDNSServiceInfo("service"), cast_1, ) await hass.async_block_till_done() @@ -670,9 +661,7 @@ async def test_discover_dynamic_group( return_value=zconf_1, ): remove_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service" - ), + pychromecast.discovery.MDNSServiceInfo("service"), cast_1, ) await hass.async_block_till_done() @@ -696,9 +685,7 @@ async def test_update_cast_chromecasts(hass: HomeAssistant) -> None: return_value=zconf_1, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service1" - ), + pychromecast.discovery.MDNSServiceInfo("service1"), cast_1, ) await hass.async_block_till_done() @@ -710,9 +697,7 @@ async def test_update_cast_chromecasts(hass: HomeAssistant) -> None: return_value=zconf_2, ): discover_cast( - pychromecast.discovery.ServiceInfo( - pychromecast.const.SERVICE_TYPE_MDNS, "service2" - ), + pychromecast.discovery.MDNSServiceInfo("service2"), cast_2, ) await hass.async_block_till_done() @@ -2289,3 +2274,70 @@ async def test_cast_platform_play_media_local_media( app_data["media_id"] == f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla" ) + + +async def test_ha_cast(hass: HomeAssistant, ha_controller_mock) -> None: + """Test Home Assistant cast.""" + entity_id = "media_player.speaker" + + info = get_fake_chromecast_info() + + chromecast, _ = await async_setup_media_player_cast(hass, info) + chromecast.cast_type = pychromecast.const.CAST_TYPE_CHROMECAST + ha_controller = MagicMock() + ha_controller_mock.return_value = ha_controller + + # Test show view signal for other entity is ignored + controller_data = HomeAssistantControllerData( + hass_url="url", + hass_uuid="12341234", + client_id="client_id_1234", + refresh_token="refresh_token_1234", + ) + async_dispatcher_send( + hass, + SIGNAL_HASS_CAST_SHOW_VIEW, + controller_data, + "media_player.other", + "view_path", + "url_path", + ) + await hass.async_block_till_done() + ha_controller_mock.assert_not_called() + + # Test show view signal is handled + controller_data = HomeAssistantControllerData( + hass_url="url", + hass_uuid="12341234", + client_id="client_id_1234", + refresh_token="refresh_token_1234", + ) + async_dispatcher_send( + hass, + SIGNAL_HASS_CAST_SHOW_VIEW, + controller_data, + entity_id, + "view_path", + "url_path", + ) + await hass.async_block_till_done() + + ha_controller_mock.assert_called_once_with( + client_id="client_id_1234", + hass_url="url", + hass_uuid="12341234", + refresh_token="refresh_token_1234", + unregister=ANY, + ) + ha_controller.show_lovelace_view.assert_called_once_with("view_path", "url_path") + chromecast.unregister_handler.assert_not_called() + + # Call unregister callback + unregister_cb = ha_controller_mock.mock_calls[0][2]["unregister"] + unregister_cb() + chromecast.unregister_handler.assert_called_once_with(ha_controller) + + # Test unregister callback called again + chromecast.unregister_handler.reset_mock() + unregister_cb() + chromecast.unregister_handler.assert_not_called()