From dfbb6531073f93afe3f8a90abe6814a48c31392b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Feb 2021 13:43:53 +0100 Subject: [PATCH] Bump pychromecast to 9.0.0 (#47086) * Adapt to Pychromecast 9.0.0 * Bump pychromecast to 9.0.0 * Fix lint issues --- homeassistant/components/cast/discovery.py | 91 ++++------- homeassistant/components/cast/helpers.py | 19 +-- homeassistant/components/cast/manifest.json | 2 +- homeassistant/components/cast/media_player.py | 16 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cast/test_media_player.py | 148 ++++++++++-------- 7 files changed, 134 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py index 4858d37f732..81048b35a97 100644 --- a/homeassistant/components/cast/discovery.py +++ b/homeassistant/components/cast/discovery.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( + DEFAULT_PORT, INTERNAL_DISCOVERY_RUNNING_KEY, KNOWN_CHROMECAST_INFO_KEY, SIGNAL_CAST_DISCOVERED, @@ -19,8 +20,17 @@ from .helpers import ChromecastInfo, ChromeCastZeroconf _LOGGER = logging.getLogger(__name__) -def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo): +def discover_chromecast(hass: HomeAssistant, device_info): """Discover a Chromecast.""" + + info = ChromecastInfo( + services=device_info.services, + uuid=device_info.uuid, + model_name=device_info.model_name, + friendly_name=device_info.friendly_name, + is_audio_group=device_info.port != DEFAULT_PORT, + ) + if info.uuid is None: _LOGGER.error("Discovered chromecast without uuid %s", info) return @@ -51,72 +61,39 @@ def setup_internal_discovery(hass: HomeAssistant) -> None: # Internal discovery is already running return - def internal_add_update_callback(uuid, service_name): - """Handle zeroconf discovery of a new or updated chromecast.""" - service = listener.services[uuid] + class CastListener(pychromecast.discovery.AbstractCastListener): + """Listener for discovering chromecasts.""" - # For support of deprecated IP based white listing - zconf = ChromeCastZeroconf.get_zeroconf() - service_info = None - tries = 0 - while service_info is None and tries < 4: - try: - service_info = zconf.get_service_info( - "_googlecast._tcp.local.", service_name - ) - except OSError: - # If the zeroconf fails to receive the necessary data we abort - # adding the service - break - tries += 1 + def add_cast(self, uuid, _): + """Handle zeroconf discovery of a new chromecast.""" + discover_chromecast(hass, browser.devices[uuid]) - if not service_info: - _LOGGER.warning( - "setup_internal_discovery failed to get info for %s, %s", - uuid, - service_name, + def update_cast(self, uuid, _): + """Handle zeroconf discovery of an updated chromecast.""" + discover_chromecast(hass, browser.devices[uuid]) + + def remove_cast(self, uuid, service, cast_info): + """Handle zeroconf discovery of a removed chromecast.""" + _remove_chromecast( + hass, + ChromecastInfo( + services=cast_info.services, + uuid=cast_info.uuid, + model_name=cast_info.model_name, + friendly_name=cast_info.friendly_name, + ), ) - return - - addresses = service_info.parsed_addresses() - host = addresses[0] if addresses else service_info.server - - discover_chromecast( - hass, - ChromecastInfo( - services=service[0], - uuid=service[1], - model_name=service[2], - friendly_name=service[3], - host=host, - port=service_info.port, - ), - ) - - def internal_remove_callback(uuid, service_name, service): - """Handle zeroconf discovery of a removed chromecast.""" - _remove_chromecast( - hass, - ChromecastInfo( - services=service[0], - uuid=service[1], - model_name=service[2], - friendly_name=service[3], - ), - ) _LOGGER.debug("Starting internal pychromecast discovery") - listener = pychromecast.CastListener( - internal_add_update_callback, - internal_remove_callback, - internal_add_update_callback, + browser = pychromecast.discovery.CastBrowser( + CastListener(), ChromeCastZeroconf.get_zeroconf() ) - browser = pychromecast.start_discovery(listener, ChromeCastZeroconf.get_zeroconf()) + browser.start_discovery() def stop_discovery(event): """Stop discovery of new chromecasts.""" _LOGGER.debug("Stopping internal pychromecast discovery") - pychromecast.discovery.stop_discovery(browser) + browser.stop_discovery() hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index b8742ec2b5e..91382c69591 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -7,8 +7,6 @@ import attr from pychromecast import dial from pychromecast.const import CAST_MANUFACTURERS -from .const import DEFAULT_PORT - @attr.s(slots=True, frozen=True) class ChromecastInfo: @@ -18,21 +16,15 @@ class ChromecastInfo: """ services: Optional[set] = attr.ib() - host: Optional[str] = attr.ib(default=None) - port: Optional[int] = attr.ib(default=0) uuid: Optional[str] = attr.ib( converter=attr.converters.optional(str), default=None ) # always convert UUID to string if not None _manufacturer = attr.ib(type=Optional[str], default=None) model_name: str = attr.ib(default="") friendly_name: Optional[str] = attr.ib(default=None) + is_audio_group = attr.ib(type=Optional[bool], default=False) is_dynamic_group = attr.ib(type=Optional[bool], default=None) - @property - def is_audio_group(self) -> bool: - """Return if this is an audio group.""" - return self.port != DEFAULT_PORT - @property def is_information_complete(self) -> bool: """Return if all information is filled out.""" @@ -74,7 +66,7 @@ class ChromecastInfo: http_group_status = None if self.uuid: http_group_status = dial.get_multizone_status( - self.host, + None, services=self.services, zconf=ChromeCastZeroconf.get_zeroconf(), ) @@ -86,17 +78,16 @@ class ChromecastInfo: return ChromecastInfo( services=self.services, - host=self.host, - port=self.port, uuid=self.uuid, friendly_name=self.friendly_name, model_name=self.model_name, + is_audio_group=True, is_dynamic_group=is_dynamic_group, ) # Fill out some missing information (friendly_name, uuid) via HTTP dial. http_device_status = dial.get_device_status( - self.host, services=self.services, zconf=ChromeCastZeroconf.get_zeroconf() + None, services=self.services, zconf=ChromeCastZeroconf.get_zeroconf() ) if http_device_status is None: # HTTP dial didn't give us any new information. @@ -104,8 +95,6 @@ class ChromecastInfo: return ChromecastInfo( services=self.services, - host=self.host, - port=self.port, uuid=(self.uuid or http_device_status.uuid), friendly_name=(self.friendly_name or http_device_status.friendly_name), manufacturer=(self.manufacturer or http_device_status.manufacturer), diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 28ccb78d5b9..ac728b4ec45 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==8.1.2"], + "requirements": ["pychromecast==9.0.0"], "after_dependencies": ["cloud", "http", "media_source", "plex", "tts", "zeroconf"], "zeroconf": ["_googlecast._tcp.local."], "codeowners": ["@emontnemery"] diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 981d67f0caa..235c7ab4479 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -154,15 +154,15 @@ async def _async_setup_platform( hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set()) hass.data.setdefault(KNOWN_CHROMECAST_INFO_KEY, {}) - info = None + wanted_uuid = None if CONF_UUID in config: - info = ChromecastInfo(uuid=config[CONF_UUID], services=None) + wanted_uuid = config[CONF_UUID] @callback def async_cast_discovered(discover: ChromecastInfo) -> None: """Handle discovery of a new chromecast.""" - # If info is set, we're handling a specific cast device identified by UUID - if info is not None and (info.uuid is not None and info.uuid != discover.uuid): + # If wanted_uuid is set, we're handling a specific cast device identified by UUID + if wanted_uuid is not None and wanted_uuid != discover.uuid: # UUID not matching, this is not it. return @@ -251,8 +251,8 @@ class CastDevice(MediaPlayerEntity): self.services, ) chromecast = await self.hass.async_add_executor_job( - pychromecast.get_chromecast_from_service, - ( + pychromecast.get_chromecast_from_cast_info, + pychromecast.discovery.CastInfo( self.services, self._cast_info.uuid, self._cast_info.model_name, @@ -875,8 +875,8 @@ class DynamicCastGroup: self.services, ) chromecast = await self.hass.async_add_executor_job( - pychromecast.get_chromecast_from_service, - ( + pychromecast.get_chromecast_from_cast_info, + pychromecast.discovery.CastInfo( self.services, self._cast_info.uuid, self._cast_info.model_name, diff --git a/requirements_all.txt b/requirements_all.txt index ea5abe0f221..135e2c74f63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,7 +1305,7 @@ pycfdns==1.2.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==8.1.2 +pychromecast==9.0.0 # homeassistant.components.pocketcasts pycketcasts==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2054da1aa6..1499679fd5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -688,7 +688,7 @@ pybotvac==0.0.20 pycfdns==1.2.1 # homeassistant.components.cast -pychromecast==8.1.2 +pychromecast==9.0.0 # homeassistant.components.climacell pyclimacell==0.14.0 diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index be24afcb538..51c49484c50 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -6,6 +6,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from uuid import UUID import attr +import pychromecast import pytest from homeassistant.components import tts @@ -47,6 +48,12 @@ def dial_mock(): return dial_mock +@pytest.fixture() +def castbrowser_mock(): + """Mock pychromecast CastBrowser.""" + return MagicMock() + + @pytest.fixture() def mz_mock(): """Mock pychromecast MultizoneManager.""" @@ -54,10 +61,13 @@ def mz_mock(): @pytest.fixture() -def pycast_mock(): +def pycast_mock(castbrowser_mock): """Mock pychromecast.""" pycast_mock = MagicMock() - pycast_mock.start_discovery.return_value = (None, Mock()) + pycast_mock.discovery.CastBrowser.return_value = castbrowser_mock + pycast_mock.discovery.AbstractCastListener = ( + pychromecast.discovery.AbstractCastListener + ) return pycast_mock @@ -97,7 +107,7 @@ FakeGroupUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e3") def get_fake_chromecast(info: ChromecastInfo): """Generate a Fake Chromecast object with the specified arguments.""" - mock = MagicMock(host=info.host, port=info.port, uuid=info.uuid) + mock = MagicMock(uuid=info.uuid) mock.media_controller.status = None return mock @@ -106,12 +116,35 @@ def get_fake_chromecast_info( host="192.168.178.42", port=8009, uuid: Optional[UUID] = FakeUUID ): """Generate a Fake ChromecastInfo with the specified arguments.""" - return ChromecastInfo( + + @attr.s(slots=True, frozen=True, eq=False) + class ExtendedChromecastInfo(ChromecastInfo): + host: Optional[str] = attr.ib(default=None) + port: Optional[int] = attr.ib(default=0) + + def __eq__(self, other): + if isinstance(other, ChromecastInfo): + return ( + ChromecastInfo( + services=self.services, + uuid=self.uuid, + manufacturer=self.manufacturer, + model_name=self.model_name, + friendly_name=self.friendly_name, + is_audio_group=self.is_audio_group, + is_dynamic_group=self.is_dynamic_group, + ) + == other + ) + return super().__eq__(other) + + return ExtendedChromecastInfo( host=host, port=port, uuid=uuid, friendly_name="Speaker", services={"the-service"}, + is_audio_group=port != 8009, ) @@ -141,32 +174,30 @@ async def async_setup_cast(hass, config=None): async def async_setup_cast_internal_discovery(hass, config=None): """Set up the cast platform and the discovery.""" - listener = MagicMock(services={}) - browser = MagicMock(zc={}) + browser = MagicMock(devices={}, zc={}) with patch( - "homeassistant.components.cast.discovery.pychromecast.CastListener", - return_value=listener, - ) as cast_listener, patch( - "homeassistant.components.cast.discovery.pychromecast.start_discovery", + "homeassistant.components.cast.discovery.pychromecast.discovery.CastBrowser", return_value=browser, - ) as start_discovery: + ) as cast_browser: add_entities = await async_setup_cast(hass, config) await hass.async_block_till_done() await hass.async_block_till_done() - assert start_discovery.call_count == 1 + assert browser.start_discovery.call_count == 1 - discovery_callback = cast_listener.call_args[0][0] - remove_callback = cast_listener.call_args[0][1] + discovery_callback = cast_browser.call_args[0][0].add_cast + remove_callback = cast_browser.call_args[0][0].remove_cast def discover_chromecast(service_name: str, info: ChromecastInfo) -> None: """Discover a chromecast device.""" - listener.services[info.uuid] = ( + browser.devices[info.uuid] = pychromecast.discovery.CastInfo( {service_name}, info.uuid, info.model_name, info.friendly_name, + info.host, + info.port, ) discovery_callback(info.uuid, service_name) @@ -175,7 +206,14 @@ async def async_setup_cast_internal_discovery(hass, config=None): remove_callback( info.uuid, service_name, - (set(), info.uuid, info.model_name, info.friendly_name), + pychromecast.discovery.CastInfo( + set(), + info.uuid, + info.model_name, + info.friendly_name, + info.host, + info.port, + ), ) return discover_chromecast, remove_chromecast, add_entities @@ -183,21 +221,17 @@ async def async_setup_cast_internal_discovery(hass, config=None): async def async_setup_media_player_cast(hass: HomeAssistantType, info: ChromecastInfo): """Set up the cast platform with async_setup_component.""" - listener = MagicMock(services={}) - browser = MagicMock(zc={}) + browser = MagicMock(devices={}, zc={}) chromecast = get_fake_chromecast(info) zconf = get_fake_zconf(host=info.host, port=info.port) with patch( - "homeassistant.components.cast.discovery.pychromecast.get_chromecast_from_service", + "homeassistant.components.cast.discovery.pychromecast.get_chromecast_from_cast_info", return_value=chromecast, ) as get_chromecast, patch( - "homeassistant.components.cast.discovery.pychromecast.CastListener", - return_value=listener, - ) as cast_listener, patch( - "homeassistant.components.cast.discovery.pychromecast.start_discovery", + "homeassistant.components.cast.discovery.pychromecast.discovery.CastBrowser", return_value=browser, - ), patch( + ) as cast_browser, patch( "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf, ): @@ -205,15 +239,18 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas hass, "cast", {"cast": {"media_player": {"uuid": info.uuid}}} ) await hass.async_block_till_done() + await hass.async_block_till_done() - discovery_callback = cast_listener.call_args[0][0] + discovery_callback = cast_browser.call_args[0][0].add_cast service_name = "the-service" - listener.services[info.uuid] = ( + browser.devices[info.uuid] = pychromecast.discovery.CastInfo( {service_name}, info.uuid, info.model_name, info.friendly_name, + info.host, + info.port, ) discovery_callback(info.uuid, service_name) @@ -223,11 +260,13 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas def discover_chromecast(service_name: str, info: ChromecastInfo) -> None: """Discover a chromecast device.""" - listener.services[info.uuid] = ( + browser.devices[info.uuid] = pychromecast.discovery.CastInfo( {service_name}, info.uuid, info.model_name, info.friendly_name, + info.host, + info.port, ) discovery_callback(info.uuid, service_name) @@ -253,18 +292,13 @@ def get_status_callbacks(chromecast_mock, mz_mock=None): return cast_status_cb, conn_status_cb, media_status_cb, group_media_status_cb -async def test_start_discovery_called_once(hass): +async def test_start_discovery_called_once(hass, castbrowser_mock): """Test pychromecast.start_discovery called exactly once.""" - with patch( - "homeassistant.components.cast.discovery.pychromecast.start_discovery", - return_value=Mock(), - ) as start_discovery: - await async_setup_cast(hass) + await async_setup_cast(hass) + assert castbrowser_mock.start_discovery.call_count == 1 - assert start_discovery.call_count == 1 - - await async_setup_cast(hass) - assert start_discovery.call_count == 1 + await async_setup_cast(hass) + assert castbrowser_mock.start_discovery.call_count == 1 async def test_internal_discovery_callback_fill_out(hass): @@ -350,7 +384,6 @@ async def test_internal_discovery_callback_fill_out_fail(hass): # when called with incomplete info, it should use HTTP to get missing discover = signal.mock_calls[0][1][0] assert discover == full_info - # assert 1 == 2 async def test_internal_discovery_callback_fill_out_group(hass): @@ -384,27 +417,16 @@ async def test_internal_discovery_callback_fill_out_group(hass): assert discover == full_info -async def test_stop_discovery_called_on_stop(hass): +async def test_stop_discovery_called_on_stop(hass, castbrowser_mock): """Test pychromecast.stop_discovery called on shutdown.""" - browser = MagicMock(zc={}) + # start_discovery should be called with empty config + await async_setup_cast(hass, {}) + assert castbrowser_mock.start_discovery.call_count == 1 - with patch( - "homeassistant.components.cast.discovery.pychromecast.start_discovery", - return_value=browser, - ) as start_discovery: - # start_discovery should be called with empty config - await async_setup_cast(hass, {}) - - assert start_discovery.call_count == 1 - - with patch( - "homeassistant.components.cast.discovery.pychromecast.discovery.stop_discovery" - ) as stop_discovery: - # stop discovery should be called on shutdown - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - - stop_discovery.assert_called_once_with(browser) + # stop discovery should be called on shutdown + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert castbrowser_mock.stop_discovery.call_count == 1 async def test_create_cast_device_without_uuid(hass): @@ -539,7 +561,7 @@ async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): tmp2.uuid = FakeUUID2 dial_mock.get_multizone_status.return_value.dynamic_groups = [tmp1, tmp2] - pycast_mock.get_chromecast_from_service.assert_not_called() + pycast_mock.get_chromecast_from_cast_info.assert_not_called() discover_cast, remove_cast, add_dev1 = await async_setup_cast_internal_discovery( hass ) @@ -552,8 +574,8 @@ async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): discover_cast("service", cast_1) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs - pycast_mock.get_chromecast_from_service.assert_called() - pycast_mock.get_chromecast_from_service.reset_mock() + pycast_mock.get_chromecast_from_cast_info.assert_called() + pycast_mock.get_chromecast_from_cast_info.reset_mock() assert add_dev1.call_count == 0 assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None @@ -565,8 +587,8 @@ async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): discover_cast("service", cast_2) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs - pycast_mock.get_chromecast_from_service.assert_called() - pycast_mock.get_chromecast_from_service.reset_mock() + pycast_mock.get_chromecast_from_cast_info.assert_called() + pycast_mock.get_chromecast_from_cast_info.reset_mock() assert add_dev1.call_count == 0 assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None @@ -578,7 +600,7 @@ async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): discover_cast("service", cast_1) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs - pycast_mock.get_chromecast_from_service.assert_not_called() + pycast_mock.get_chromecast_from_cast_info.assert_not_called() assert add_dev1.call_count == 0 assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None