diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py index 341ba0c4c5e..4858d37f732 100644 --- a/homeassistant/components/cast/discovery.py +++ b/homeassistant/components/cast/discovery.py @@ -25,6 +25,7 @@ def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo): _LOGGER.error("Discovered chromecast without uuid %s", info) return + info = info.fill_out_missing_chromecast_info() if info.uuid in hass.data[KNOWN_CHROMECAST_INFO_KEY]: _LOGGER.debug("Discovered update for known chromecast %s", info) else: diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 0ad13d137d1..e7db380406b 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -1,7 +1,8 @@ """Helpers to deal with Cast devices.""" -from typing import Optional, Tuple +from typing import Optional import attr +from pychromecast import dial from pychromecast.const import CAST_MANUFACTURERS from .const import DEFAULT_PORT @@ -20,8 +21,10 @@ class ChromecastInfo: 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_dynamic_group = attr.ib(type=Optional[bool], default=None) @property def is_audio_group(self) -> bool: @@ -29,17 +32,84 @@ class ChromecastInfo: return self.port != DEFAULT_PORT @property - def host_port(self) -> Tuple[str, int]: - """Return the host+port tuple.""" - return self.host, self.port + def is_information_complete(self) -> bool: + """Return if all information is filled out.""" + want_dynamic_group = self.is_audio_group + have_dynamic_group = self.is_dynamic_group is not None + have_all_except_dynamic_group = all( + attr.astuple( + self, + filter=attr.filters.exclude( + attr.fields(ChromecastInfo).is_dynamic_group + ), + ) + ) + return have_all_except_dynamic_group and ( + not want_dynamic_group or have_dynamic_group + ) @property def manufacturer(self) -> str: """Return the manufacturer.""" + if self._manufacturer: + return self._manufacturer if not self.model_name: return None return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.") + def fill_out_missing_chromecast_info(self) -> "ChromecastInfo": + """Return a new ChromecastInfo object with missing attributes filled in. + + Uses blocking HTTP / HTTPS. + """ + if self.is_information_complete: + # We have all information, no need to check HTTP API. + return self + + # Fill out missing group information via HTTP API. + if self.is_audio_group: + is_dynamic_group = False + http_group_status = None + if self.uuid: + http_group_status = dial.get_multizone_status( + self.host, + services=self.services, + zconf=ChromeCastZeroconf.get_zeroconf(), + ) + if http_group_status is not None: + is_dynamic_group = any( + str(g.uuid) == self.uuid + for g in http_group_status.dynamic_groups + ) + + 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_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() + ) + if http_device_status is None: + # HTTP dial didn't give us any new information. + return self + + 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), + model_name=(self.model_name or http_device_status.model_name), + ) + class ChromeCastZeroconf: """Class to hold a zeroconf instance.""" @@ -65,19 +135,22 @@ class CastStatusListener: potentially arrive. This class allows invalidating past chromecast objects. """ - def __init__(self, cast_device, chromecast, mz_mgr): + def __init__(self, cast_device, chromecast, mz_mgr, mz_only=False): """Initialize the status listener.""" self._cast_device = cast_device self._uuid = chromecast.uuid self._valid = True self._mz_mgr = mz_mgr + if cast_device._cast_info.is_audio_group: + self._mz_mgr.add_multizone(chromecast) + if mz_only: + return + chromecast.register_status_listener(self) chromecast.socket_client.media_controller.register_status_listener(self) chromecast.register_connection_listener(self) - if cast_device._cast_info.is_audio_group: - self._mz_mgr.add_multizone(chromecast) - else: + if not cast_device._cast_info.is_audio_group: self._mz_mgr.register_listener(chromecast.uuid, self) def new_cast_status(self, cast_status): diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 8072e06c2e5..c1c24a4cda8 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==7.6.0"], + "requirements": ["pychromecast==7.7.1"], "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 e68800efb44..6bedae1cac5 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -63,6 +63,7 @@ from .const import ( DOMAIN as CAST_DOMAIN, KNOWN_CHROMECAST_INFO_KEY, SIGNAL_CAST_DISCOVERED, + SIGNAL_CAST_REMOVED, SIGNAL_HASS_CAST_SHOW_VIEW, ) from .discovery import setup_internal_discovery @@ -115,6 +116,13 @@ def _async_create_cast_device(hass: HomeAssistantType, info: ChromecastInfo): return None # -> New cast device added_casts.add(info.uuid) + + if info.is_dynamic_group: + # This is a dynamic group, do not add it but connect to the service. + group = DynamicCastGroup(hass, info) + group.async_setup() + return None + return CastDevice(info) @@ -206,8 +214,9 @@ class CastDevice(MediaPlayerEntity): self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered ) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop) + self.async_set_cast_info(self._cast_info) self.hass.async_create_task( - async_create_catching_coro(self.async_set_cast_info(self._cast_info)) + async_create_catching_coro(self.async_connect_to_chromecast()) ) self._cast_view_remove_handler = async_dispatcher_connect( @@ -228,15 +237,13 @@ class CastDevice(MediaPlayerEntity): 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.""" + def async_set_cast_info(self, cast_info): + """Set the cast information.""" self._cast_info = cast_info - if self._chromecast is not None: - # Only setup the chromecast once, added elements to services - # will automatically be picked up. - return + async def async_connect_to_chromecast(self): + """Set up the chromecast object.""" _LOGGER.debug( "[%s %s] Connecting to cast device by service %s", @@ -248,9 +255,9 @@ class CastDevice(MediaPlayerEntity): pychromecast.get_chromecast_from_service, ( self.services, - cast_info.uuid, - cast_info.model_name, - cast_info.friendly_name, + self._cast_info.uuid, + self._cast_info.model_name, + self._cast_info.friendly_name, None, None, ), @@ -777,16 +784,12 @@ class CastDevice(MediaPlayerEntity): async def _async_cast_discovered(self, discover: ChromecastInfo): """Handle discovery of new Chromecast.""" - if self._cast_info.uuid is None: - # We can't handle empty UUIDs - return - if self._cast_info.uuid != discover.uuid: # Discovered is not our device. return _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) - await self.async_set_cast_info(discover) + self.async_set_cast_info(discover) async def _async_stop(self, event): """Disconnect socket on Home Assistant stop.""" @@ -808,3 +811,131 @@ class CastDevice(MediaPlayerEntity): self._chromecast.register_handler(controller) self._hass_cast_controller.show_lovelace_view(view_path, url_path) + + +class DynamicCastGroup: + """Representation of a Cast device on the network - for dynamic cast groups.""" + + def __init__(self, hass, cast_info: ChromecastInfo): + """Initialize the cast device.""" + + self.hass = hass + self._cast_info = cast_info + self.services = cast_info.services + self._chromecast: Optional[pychromecast.Chromecast] = None + self.mz_mgr = None + self._status_listener: Optional[CastStatusListener] = None + + self._add_remove_handler = None + self._del_remove_handler = None + + def async_setup(self): + """Create chromecast object.""" + self._add_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered + ) + self._del_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed + ) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop) + self.async_set_cast_info(self._cast_info) + self.hass.async_create_task( + async_create_catching_coro(self.async_connect_to_chromecast()) + ) + + async def async_tear_down(self) -> None: + """Disconnect Chromecast object.""" + await self._async_disconnect() + if self._cast_info.uuid is not None: + # Remove the entity from the added casts so that it can dynamically + # be re-added again. + 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 + + def async_set_cast_info(self, cast_info): + """Set the cast information and set up the chromecast object.""" + + self._cast_info = cast_info + + async def async_connect_to_chromecast(self): + """Set the cast information and set up the chromecast object.""" + + _LOGGER.debug( + "[%s %s] Connecting to cast device by service %s", + "Dynamic group", + self._cast_info.friendly_name, + self.services, + ) + chromecast = await self.hass.async_add_executor_job( + pychromecast.get_chromecast_from_service, + ( + self.services, + self._cast_info.uuid, + self._cast_info.model_name, + self._cast_info.friendly_name, + None, + None, + ), + ChromeCastZeroconf.get_zeroconf(), + ) + self._chromecast = chromecast + + if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: + self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager() + + self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] + + self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr, True) + self._chromecast.start() + + async def _async_disconnect(self): + """Disconnect Chromecast object if it is set.""" + if self._chromecast is None: + # Can't disconnect if not connected. + return + _LOGGER.debug( + "[%s %s] Disconnecting from chromecast socket", + "Dynamic group", + self._cast_info.friendly_name, + ) + + await self.hass.async_add_executor_job(self._chromecast.disconnect) + + self._invalidate() + + def _invalidate(self): + """Invalidate some attributes.""" + self._chromecast = None + self.mz_mgr = None + if self._status_listener is not None: + self._status_listener.invalidate() + self._status_listener = None + + async def _async_cast_discovered(self, discover: ChromecastInfo): + """Handle discovery of new Chromecast.""" + if self._cast_info.uuid != discover.uuid: + # Discovered is not our device. + return + + _LOGGER.debug("Discovered dynamic group with same UUID: %s", discover) + self.async_set_cast_info(discover) + + async def _async_cast_removed(self, discover: ChromecastInfo): + """Handle removal of Chromecast.""" + if self._cast_info.uuid != discover.uuid: + # Removed is not our device. + return + + if not discover.services: + # Clean up the dynamic group + _LOGGER.debug("Clean up dynamic group: %s", discover) + await self.async_tear_down() + + async def _async_stop(self, event): + """Disconnect socket on Home Assistant stop.""" + await self._async_disconnect() diff --git a/requirements_all.txt b/requirements_all.txt index dcc95d14465..3ab3b02d995 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1304,7 +1304,7 @@ pycfdns==1.2.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==7.6.0 +pychromecast==7.7.1 # homeassistant.components.pocketcasts pycketcasts==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27447d735a3..97143c68fa4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -664,7 +664,7 @@ pybotvac==0.0.19 pycfdns==1.2.1 # homeassistant.components.cast -pychromecast==7.6.0 +pychromecast==7.7.1 # homeassistant.components.coolmaster pycoolmasternet-async==0.1.2 diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index c04dc87ad11..050d6a6932d 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -14,6 +14,7 @@ from homeassistant.components.cast.media_player import ChromecastInfo from homeassistant.config import async_process_ha_core_config from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType from homeassistant.setup import async_setup_component @@ -21,12 +22,32 @@ from tests.common import MockConfigEntry, assert_setup_component from tests.components.media_player import common +@pytest.fixture() +def dial_mock(): + """Mock pychromecast dial.""" + dial_mock = MagicMock() + dial_mock.get_device_status.return_value.uuid = "fake_uuid" + dial_mock.get_device_status.return_value.manufacturer = "fake_manufacturer" + dial_mock.get_device_status.return_value.model_name = "fake_model_name" + dial_mock.get_device_status.return_value.friendly_name = "fake_friendly_name" + dial_mock.get_multizone_status.return_value.dynamic_groups = [] + return dial_mock + + @pytest.fixture() def mz_mock(): """Mock pychromecast MultizoneManager.""" return MagicMock() +@pytest.fixture() +def pycast_mock(): + """Mock pychromecast.""" + pycast_mock = MagicMock() + pycast_mock.start_discovery.return_value = (None, Mock()) + return pycast_mock + + @pytest.fixture() def quick_play_mock(): """Mock pychromecast quick_play.""" @@ -34,20 +55,14 @@ def quick_play_mock(): @pytest.fixture(autouse=True) -def cast_mock(mz_mock, quick_play_mock): +def cast_mock(dial_mock, mz_mock, pycast_mock, quick_play_mock): """Mock pychromecast.""" - pycast_mock = MagicMock() - pycast_mock.start_discovery.return_value = (None, Mock()) - dial_mock = MagicMock(name="XXX") - dial_mock.get_device_status.return_value.uuid = "fake_uuid" - dial_mock.get_device_status.return_value.manufacturer = "fake_manufacturer" - dial_mock.get_device_status.return_value.model_name = "fake_model_name" - dial_mock.get_device_status.return_value.friendly_name = "fake_friendly_name" - with patch( "homeassistant.components.cast.media_player.pychromecast", pycast_mock ), patch( "homeassistant.components.cast.discovery.pychromecast", pycast_mock + ), patch( + "homeassistant.components.cast.helpers.dial", dial_mock ), patch( "homeassistant.components.cast.media_player.MultizoneManager", return_value=mz_mock, @@ -130,6 +145,7 @@ async def async_setup_cast_internal_discovery(hass, config=None): assert start_discovery.call_count == 1 discovery_callback = cast_listener.call_args[0][0] + remove_callback = cast_listener.call_args[0][1] def discover_chromecast(service_name: str, info: ChromecastInfo) -> None: """Discover a chromecast device.""" @@ -141,7 +157,15 @@ async def async_setup_cast_internal_discovery(hass, config=None): ) discovery_callback(info.uuid, service_name) - return discover_chromecast, add_entities + def remove_chromecast(service_name: str, info: ChromecastInfo) -> None: + """Remove a chromecast device.""" + remove_callback( + info.uuid, + service_name, + (set(), info.uuid, info.model_name, info.friendly_name), + ) + + return discover_chromecast, remove_chromecast, add_entities async def async_setup_media_player_cast(hass: HomeAssistantType, info: ChromecastInfo): @@ -183,7 +207,18 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas await hass.async_block_till_done() await hass.async_block_till_done() assert get_chromecast.call_count == 1 - return chromecast + + def discover_chromecast(service_name: str, info: ChromecastInfo) -> None: + """Discover a chromecast device.""" + listener.services[info.uuid] = ( + {service_name}, + info.uuid, + info.model_name, + info.friendly_name, + ) + discovery_callback(info.uuid, service_name) + + return chromecast, discover_chromecast def get_status_callbacks(chromecast_mock, mz_mock=None): @@ -219,6 +254,123 @@ async def test_start_discovery_called_once(hass): assert start_discovery.call_count == 1 +async def test_internal_discovery_callback_fill_out(hass): + """Test internal discovery automatically filling out information.""" + discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) + info = get_fake_chromecast_info(host="host1") + zconf = get_fake_zconf(host="host1", port=8009) + full_info = attr.evolve( + info, + model_name="google home", + friendly_name="Speaker", + uuid=FakeUUID, + manufacturer="Nabu Casa", + ) + + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ), patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf, + ): + signal = MagicMock() + + async_dispatcher_connect(hass, "cast_discovered", signal) + discover_cast("the-service", info) + await hass.async_block_till_done() + + # when called with incomplete info, it should use HTTP to get missing + discover = signal.mock_calls[0][1][0] + assert discover == full_info + + +async def test_internal_discovery_callback_fill_out_default_manufacturer(hass): + """Test internal discovery automatically filling out information.""" + discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) + info = get_fake_chromecast_info(host="host1") + zconf = get_fake_zconf(host="host1", port=8009) + full_info = attr.evolve( + info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID + ) + + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ), patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf, + ): + signal = MagicMock() + + async_dispatcher_connect(hass, "cast_discovered", signal) + discover_cast("the-service", info) + await hass.async_block_till_done() + + # when called with incomplete info, it should use HTTP to get missing + discover = signal.mock_calls[0][1][0] + assert discover == attr.evolve(full_info, manufacturer="Google Inc.") + + +async def test_internal_discovery_callback_fill_out_fail(hass): + """Test internal discovery automatically filling out information.""" + discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) + info = get_fake_chromecast_info(host="host1") + zconf = get_fake_zconf(host="host1", port=8009) + full_info = ( + info # attr.evolve(info, model_name="", friendly_name="Speaker", uuid=FakeUUID) + ) + + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=None, + ), patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf, + ): + signal = MagicMock() + + async_dispatcher_connect(hass, "cast_discovered", signal) + discover_cast("the-service", info) + await hass.async_block_till_done() + + # 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): + """Test internal discovery automatically filling out information.""" + discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) + info = get_fake_chromecast_info(host="host1", port=12345) + zconf = get_fake_zconf(host="host1", port=12345) + full_info = attr.evolve( + info, + model_name="", + friendly_name="Speaker", + uuid=FakeUUID, + is_dynamic_group=False, + ) + + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ), patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf, + ): + signal = MagicMock() + + async_dispatcher_connect(hass, "cast_discovered", signal) + discover_cast("the-service", info) + await hass.async_block_till_done() + + # when called with incomplete info, it should use HTTP to get missing + discover = signal.mock_calls[0][1][0] + assert discover == full_info + + async def test_stop_discovery_called_on_stop(hass): """Test pychromecast.stop_discovery called on shutdown.""" browser = MagicMock(zc={}) @@ -272,7 +424,7 @@ async def test_replay_past_chromecasts(hass): zconf_1 = get_fake_zconf(host="host1", port=8009) zconf_2 = get_fake_zconf(host="host2", port=8009) - discover_cast, add_dev1 = await async_setup_cast_internal_discovery( + discover_cast, _, add_dev1 = await async_setup_cast_internal_discovery( hass, config={"uuid": FakeUUID} ) @@ -308,7 +460,7 @@ async def test_manual_cast_chromecasts_uuid(hass): zconf_2 = get_fake_zconf(host="host_2") # Manual configuration of media player with host "configured_host" - discover_cast, add_dev1 = await async_setup_cast_internal_discovery( + discover_cast, _, add_dev1 = await async_setup_cast_internal_discovery( hass, config={"uuid": FakeUUID} ) with patch( @@ -338,7 +490,7 @@ async def test_auto_cast_chromecasts(hass): zconf_2 = get_fake_zconf(host="other_host") # Manual configuration of media player with host "configured_host" - discover_cast, add_dev1 = await async_setup_cast_internal_discovery(hass) + discover_cast, _, add_dev1 = await async_setup_cast_internal_discovery(hass) with patch( "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_1, @@ -358,6 +510,79 @@ async def test_auto_cast_chromecasts(hass): assert add_dev1.call_count == 2 +async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): + """Test dynamic group does not create device or entity.""" + cast_1 = get_fake_chromecast_info(host="host_1", port=23456, uuid=FakeUUID) + cast_2 = get_fake_chromecast_info(host="host_2", port=34567, uuid=FakeUUID2) + zconf_1 = get_fake_zconf(host="host_1", port=23456) + zconf_2 = get_fake_zconf(host="host_2", port=34567) + + reg = await hass.helpers.entity_registry.async_get_registry() + + # Fake dynamic group info + tmp1 = MagicMock() + tmp1.uuid = FakeUUID + tmp2 = MagicMock() + tmp2.uuid = FakeUUID2 + dial_mock.get_multizone_status.return_value.dynamic_groups = [tmp1, tmp2] + + pycast_mock.get_chromecast_from_service.assert_not_called() + discover_cast, remove_cast, add_dev1 = await async_setup_cast_internal_discovery( + hass + ) + + # Discover cast service + with patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf_1, + ): + 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() + assert add_dev1.call_count == 0 + assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + + # Discover other dynamic group cast service + with patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf_2, + ): + 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() + assert add_dev1.call_count == 0 + assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + + # Get update for cast service + with patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf_1, + ): + 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() + assert add_dev1.call_count == 0 + assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + + # Remove cast service + assert "Disconnecting from chromecast" not in caplog.text + + with patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf_1, + ): + remove_cast("service", cast_1) + await hass.async_block_till_done() + await hass.async_block_till_done() # having tasks that add jobs + + assert "Disconnecting from chromecast" in caplog.text + + async def test_update_cast_chromecasts(hass): """Test discovery of same UUID twice only adds one cast.""" cast_1 = get_fake_chromecast_info(host="old_host") @@ -366,7 +591,7 @@ async def test_update_cast_chromecasts(hass): zconf_2 = get_fake_zconf(host="new_host") # Manual configuration of media player with host "configured_host" - discover_cast, add_dev1 = await async_setup_cast_internal_discovery(hass) + discover_cast, _, add_dev1 = await async_setup_cast_internal_discovery(hass) with patch( "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", @@ -392,7 +617,7 @@ async def test_entity_availability(hass: HomeAssistantType): entity_id = "media_player.speaker" info = get_fake_chromecast_info() - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, _ = get_status_callbacks(chromecast) state = hass.states.get(entity_id) @@ -423,7 +648,7 @@ async def test_entity_cast_status(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) cast_status_cb, conn_status_cb, _ = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -466,7 +691,7 @@ async def test_entity_play_media(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, _ = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -495,7 +720,7 @@ async def test_entity_play_media_cast(hass: HomeAssistantType, quick_play_mock): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, _ = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -528,7 +753,7 @@ async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, _ = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -575,7 +800,7 @@ async def test_entity_play_media_sign_URL(hass: HomeAssistantType): info = get_fake_chromecast_info() - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, _ = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -601,7 +826,7 @@ async def test_entity_media_content_type(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -655,7 +880,7 @@ async def test_entity_control(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -738,7 +963,7 @@ async def test_entity_media_states(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -797,7 +1022,7 @@ async def test_group_media_states(hass, mz_mock): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, media_status_cb, group_media_status_cb = get_status_callbacks( chromecast, mz_mock ) @@ -841,7 +1066,7 @@ async def test_group_media_states(hass, mz_mock): async def test_group_media_control(hass, mz_mock): - """Test media states are read from group if entity has no state.""" + """Test media controls are handled by group if entity has no state.""" entity_id = "media_player.speaker" reg = await hass.helpers.entity_registry.async_get_registry() @@ -850,7 +1075,7 @@ async def test_group_media_control(hass, mz_mock): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, media_status_cb, group_media_status_cb = get_status_callbacks( chromecast, mz_mock @@ -904,7 +1129,7 @@ async def test_group_media_control(hass, mz_mock): async def test_failed_cast_on_idle(hass, caplog): """Test no warning when unless player went idle with reason "ERROR".""" info = get_fake_chromecast_info() - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, _, media_status_cb = get_status_callbacks(chromecast) media_status = MagicMock(images=None) @@ -939,7 +1164,7 @@ async def test_failed_cast_other_url(hass, caplog): ) info = get_fake_chromecast_info() - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, _, media_status_cb = get_status_callbacks(chromecast) media_status = MagicMock(images=None) @@ -962,7 +1187,7 @@ async def test_failed_cast_internal_url(hass, caplog): ) info = get_fake_chromecast_info() - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, _, media_status_cb = get_status_callbacks(chromecast) media_status = MagicMock(images=None) @@ -990,7 +1215,7 @@ async def test_failed_cast_external_url(hass, caplog): ) info = get_fake_chromecast_info() - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, _, media_status_cb = get_status_callbacks(chromecast) media_status = MagicMock(images=None) @@ -1014,7 +1239,7 @@ async def test_failed_cast_tts_base_url(hass, caplog): ) info = get_fake_chromecast_info() - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) _, _, media_status_cb = get_status_callbacks(chromecast) media_status = MagicMock(images=None) @@ -1032,7 +1257,7 @@ async def test_disconnect_on_stop(hass: HomeAssistantType): """Test cast device disconnects socket on stop.""" info = get_fake_chromecast_info() - chromecast = await async_setup_media_player_cast(hass, info) + chromecast, _ = await async_setup_media_player_cast(hass, info) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done()