diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index bc32b36c455..1aea4655e17 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -2,7 +2,7 @@ from homeassistant import config_entries from homeassistant.helpers import config_entry_flow -REQUIREMENTS = ['pychromecast==3.0.0'] +REQUIREMENTS = ['pychromecast==3.1.0'] DOMAIN = 'cast' diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 77332883a91..12f524f2121 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -48,6 +48,8 @@ KNOWN_CHROMECAST_INFO_KEY = 'cast_known_chromecasts' # Stores UUIDs of cast devices that were added as entities. Doesn't store # None UUIDs. ADDED_CAST_DEVICES_KEY = 'cast_added_cast_devices' +# Stores an audio group manager. +CAST_MULTIZONE_MANAGER_KEY = 'cast_multizone_manager' # Dispatcher signal fired with a ChromecastInfo every time we discover a new # Chromecast or receive it through configuration @@ -79,6 +81,7 @@ class ChromecastInfo: manufacturer = attr.ib(type=str, default='') model_name = attr.ib(type=str, default='') friendly_name = attr.ib(type=Optional[str], default=None) + is_dynamic_group = attr.ib(type=Optional[bool], default=None) @property def is_audio_group(self) -> bool: @@ -88,7 +91,13 @@ class ChromecastInfo: @property def is_information_complete(self) -> bool: """Return if all information is filled out.""" - return all(attr.astuple(self)) + 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 host_port(self) -> Tuple[str, int]: @@ -96,9 +105,16 @@ class ChromecastInfo: return self.host, self.port +def _is_matching_dynamic_group(our_info: ChromecastInfo, + new_info: ChromecastInfo,) -> bool: + return (our_info.is_audio_group and + new_info.is_dynamic_group and + our_info.friendly_name == new_info.friendly_name) + + def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo: """Fill out missing attributes of ChromecastInfo using blocking HTTP.""" - if info.is_information_complete or info.is_audio_group: + if info.is_information_complete: # We have all information, no need to check HTTP API. Or this is an # audio group, so checking via HTTP won't give us any new information. return info @@ -106,6 +122,28 @@ def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo: # Fill out missing information via HTTP dial. from pychromecast import dial + if info.is_audio_group: + is_dynamic_group = False + http_group_status = None + dynamic_groups = [] + if info.uuid: + http_group_status = dial.get_multizone_status( + info.host, services=[info.service], + zconf=ChromeCastZeroconf.get_zeroconf()) + if http_group_status is not None: + dynamic_groups = \ + [str(g.uuid) for g in http_group_status.dynamic_groups] + is_dynamic_group = info.uuid in dynamic_groups + + return ChromecastInfo( + service=info.service, host=info.host, port=info.port, + uuid=info.uuid, + friendly_name=info.friendly_name, + manufacturer=info.manufacturer, + model_name=info.model_name, + is_dynamic_group=is_dynamic_group + ) + http_device_status = dial.get_device_status( info.host, services=[info.service], zconf=ChromeCastZeroconf.get_zeroconf()) @@ -218,12 +256,17 @@ def _async_create_cast_device(hass: HomeAssistantType, Returns None if the cast device has already been added. """ + _LOGGER.debug("_async_create_cast_device: %s", info) if info.uuid is None: # Found a cast without UUID, we don't store it because we won't be able # to update it anyway. return CastDevice(info) # Found a cast with UUID + if info.is_dynamic_group: + # This is a dynamic group, do not add it. + return None + added_casts = hass.data[ADDED_CAST_DEVICES_KEY] if info.uuid in added_casts: # Already added this one, the entity will take care of moved hosts @@ -322,15 +365,22 @@ class CastStatusListener: potentially arrive. This class allows invalidating past chromecast objects. """ - def __init__(self, cast_device, chromecast): + def __init__(self, cast_device, chromecast, mz_mgr): """Initialize the status listener.""" self._cast_device = cast_device + self._uuid = chromecast.uuid self._valid = True + self._mz_mgr = mz_mgr chromecast.register_status_listener(self) chromecast.socket_client.media_controller.register_status_listener( self) chromecast.register_connection_listener(self) + # pylint: disable=protected-access + if cast_device._cast_info.is_audio_group: + self._mz_mgr.add_multizone(chromecast) + else: + self._mz_mgr.register_listener(chromecast.uuid, self) def new_cast_status(self, cast_status): """Handle reception of a new CastStatus.""" @@ -347,11 +397,85 @@ class CastStatusListener: if self._valid: self._cast_device.new_connection_status(connection_status) + @staticmethod + def added_to_multizone(group_uuid): + """Handle the cast added to a group.""" + pass + + def removed_from_multizone(self, group_uuid): + """Handle the cast removed from a group.""" + if self._valid: + self._cast_device.multizone_new_media_status(group_uuid, None) + self._cast_device.multizone_new_cast_status(group_uuid, None) + + def multizone_new_cast_status(self, group_uuid, cast_status): + """Handle reception of a new MediaStatus for a group.""" + if self._valid: + self._cast_device.multizone_new_cast_status( + group_uuid, cast_status) + + def multizone_new_media_status(self, group_uuid, media_status): + """Handle reception of a new MediaStatus for a group.""" + if self._valid: + self._cast_device.multizone_new_media_status( + group_uuid, media_status) + def invalidate(self): """Invalidate this status listener. All following callbacks won't be forwarded. """ + # pylint: disable=protected-access + if self._cast_device._cast_info.is_audio_group: + self._mz_mgr.remove_multizone(self._uuid) + else: + self._mz_mgr.deregister_listener(self._uuid, self) + self._valid = False + + +class DynamicGroupCastStatusListener: + """Helper class to handle pychromecast status callbacks. + + Necessary because a CastDevice entity can create a new socket client + and therefore callbacks from multiple chromecast connections can + potentially arrive. This class allows invalidating past chromecast objects. + """ + + def __init__(self, cast_device, chromecast, mz_mgr): + """Initialize the status listener.""" + self._cast_device = cast_device + self._uuid = chromecast.uuid + self._valid = True + self._mz_mgr = mz_mgr + + chromecast.register_status_listener(self) + chromecast.socket_client.media_controller.register_status_listener( + self) + chromecast.register_connection_listener(self) + self._mz_mgr.add_multizone(chromecast) + + def new_cast_status(self, cast_status): + """Handle reception of a new CastStatus.""" + if self._valid: + self._cast_device.new_dynamic_group_cast_status(cast_status) + + def new_media_status(self, media_status): + """Handle reception of a new MediaStatus.""" + if self._valid: + self._cast_device.new_dynamic_group_media_status(media_status) + + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + if self._valid: + self._cast_device.new_dynamic_group_connection_status( + connection_status) + + def invalidate(self): + """Invalidate this status listener. + + All following callbacks won't be forwarded. + """ + self._mz_mgr.remove_multizone(self._uuid) self._valid = False @@ -375,8 +499,20 @@ class CastDevice(MediaPlayerDevice): self.cast_status = None self.media_status = None self.media_status_received = None + self._dynamic_group_cast_info = None # type: ChromecastInfo + self._dynamic_group_cast = None \ + # type: Optional[pychromecast.Chromecast] + self.dynamic_group_cast_status = None + self.dynamic_group_media_status = None + self.dynamic_group_media_status_received = None + self.mz_cast_status = {} + self.mz_media_status = {} + self.mz_media_status_received = {} self._available = False # type: bool + self._dynamic_group_available = False # type: bool self._status_listener = None # type: Optional[CastStatusListener] + self._dynamic_group_status_listener = None \ + # type: Optional[CastStatusListener] self._add_remove_handler = None self._del_remove_handler = None @@ -388,6 +524,13 @@ class CastDevice(MediaPlayerDevice): if self._cast_info.uuid is None: # We can't handle empty UUIDs return + if _is_matching_dynamic_group(self._cast_info, discover): + _LOGGER.debug("Discovered matching dynamic group: %s", + discover) + self.hass.async_create_task( + self.async_set_dynamic_group(discover)) + return + if self._cast_info.uuid != discover.uuid: # Discovered is not our device. return @@ -405,6 +548,11 @@ class CastDevice(MediaPlayerDevice): if self._cast_info.uuid is None: # We can't handle empty UUIDs return + if (self._dynamic_group_cast_info is not None and + self._dynamic_group_cast_info.uuid == discover.uuid): + _LOGGER.debug("Removed matching dynamic group: %s", discover) + self.hass.async_create_task(self.async_del_dynamic_group()) + return if self._cast_info.uuid != discover.uuid: # Removed is not our device. return @@ -423,6 +571,14 @@ class CastDevice(MediaPlayerDevice): async_cast_removed) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) self.hass.async_create_task(self.async_set_cast_info(self._cast_info)) + for info in self.hass.data[KNOWN_CHROMECAST_INFO_KEY]: + if _is_matching_dynamic_group(self._cast_info, info): + _LOGGER.debug("[%s %s (%s:%s)] Found dynamic group: %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, info) + self.hass.async_create_task( + self.async_set_dynamic_group(info)) + break async def async_will_remove_from_hass(self) -> None: """Disconnect Chromecast object when removed.""" @@ -478,7 +634,14 @@ class CastDevice(MediaPlayerDevice): cast_info.friendly_name )) self._chromecast = chromecast - self._status_listener = CastStatusListener(self, chromecast) + + if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: + from pychromecast.controllers.multizone import MultizoneManager + self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager() + mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] + + self._status_listener = CastStatusListener( + self, chromecast, mz_mgr) self._available = False self.cast_status = chromecast.status self.media_status = chromecast.media_controller.status @@ -493,6 +656,57 @@ class CastDevice(MediaPlayerDevice): self._cast_info.host, self._cast_info.port, cast_info.service, self.services) + async def async_set_dynamic_group(self, cast_info): + """Set the cast information and set up the chromecast object.""" + import pychromecast + _LOGGER.debug( + "[%s %s (%s:%s)] Connecting to dynamic group by host %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, cast_info) + + self.async_del_dynamic_group() + self._dynamic_group_cast_info = cast_info + + # pylint: disable=protected-access + chromecast = await self.hass.async_add_executor_job( + pychromecast._get_chromecast_from_host, ( + cast_info.host, cast_info.port, cast_info.uuid, + cast_info.model_name, cast_info.friendly_name + )) + + self._dynamic_group_cast = chromecast + + if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: + from pychromecast.controllers.multizone import MultizoneManager + self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager() + mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] + + self._dynamic_group_status_listener = DynamicGroupCastStatusListener( + self, chromecast, mz_mgr) + self._dynamic_group_available = False + self.dynamic_group_cast_status = chromecast.status + self.dynamic_group_media_status = chromecast.media_controller.status + self._dynamic_group_cast.start() + self.async_schedule_update_ha_state() + + async def async_del_dynamic_group(self): + """Remove the dynamic group.""" + cast_info = self._dynamic_group_cast_info + _LOGGER.debug("[%s %s (%s:%s)] Remove dynamic group: %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + cast_info.service if cast_info else None) + + self._dynamic_group_available = False + self._dynamic_group_cast_info = None + if self._dynamic_group_cast is not None: + await self.hass.async_add_executor_job( + self._dynamic_group_cast.disconnect) + + self._dynamic_group_invalidate() + + self.async_schedule_update_ha_state() + async def _async_disconnect(self): """Disconnect Chromecast object if it is set.""" if self._chromecast is None: @@ -504,7 +718,10 @@ class CastDevice(MediaPlayerDevice): self._available = False self.async_schedule_update_ha_state() - await self.hass.async_add_job(self._chromecast.disconnect) + await self.hass.async_add_executor_job(self._chromecast.disconnect) + if self._dynamic_group_cast is not None: + await self.hass.async_add_executor_job( + self._dynamic_group_cast.disconnect) self._invalidate() @@ -516,10 +733,23 @@ class CastDevice(MediaPlayerDevice): self.cast_status = None self.media_status = None self.media_status_received = None + self.mz_cast_status = {} + self.mz_media_status = {} + self.mz_media_status_received = {} if self._status_listener is not None: self._status_listener.invalidate() self._status_listener = None + def _dynamic_group_invalidate(self): + """Invalidate some attributes.""" + self._dynamic_group_cast = None + self.dynamic_group_cast_status = None + self.dynamic_group_media_status = None + self.dynamic_group_media_status_received = None + if self._dynamic_group_status_listener is not None: + self._dynamic_group_status_listener.invalidate() + self._dynamic_group_status_listener = None + # ========== Callbacks ========== def new_cast_status(self, cast_status): """Handle updates of the cast status.""" @@ -565,6 +795,67 @@ class CastDevice(MediaPlayerDevice): self._available = new_available self.schedule_update_ha_state() + def new_dynamic_group_cast_status(self, cast_status): + """Handle updates of the cast status.""" + self.dynamic_group_cast_status = cast_status + self.schedule_update_ha_state() + + def new_dynamic_group_media_status(self, media_status): + """Handle updates of the media status.""" + self.dynamic_group_media_status = media_status + self.dynamic_group_media_status_received = dt_util.utcnow() + self.schedule_update_ha_state() + + def new_dynamic_group_connection_status(self, connection_status): + """Handle updates of connection status.""" + from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, \ + CONNECTION_STATUS_DISCONNECTED + + _LOGGER.debug( + "[%s %s (%s:%s)] Received dynamic group connection status: %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + connection_status.status) + if connection_status.status == CONNECTION_STATUS_DISCONNECTED: + self._dynamic_group_available = False + self._dynamic_group_invalidate() + self.schedule_update_ha_state() + return + + new_available = connection_status.status == CONNECTION_STATUS_CONNECTED + if new_available != self._dynamic_group_available: + # Connection status callbacks happen often when disconnected. + # Only update state when availability changed to put less pressure + # on state machine. + _LOGGER.debug( + "[%s %s (%s:%s)] Dynamic group availability changed: %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + connection_status.status) + self._dynamic_group_available = new_available + self.schedule_update_ha_state() + + def multizone_new_media_status(self, group_uuid, media_status): + """Handle updates of audio group media status.""" + _LOGGER.debug( + "[%s %s (%s:%s)] Multizone %s media status: %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + group_uuid, media_status) + self.mz_media_status[group_uuid] = media_status + self.mz_media_status_received[group_uuid] = dt_util.utcnow() + self.schedule_update_ha_state() + + def multizone_new_cast_status(self, group_uuid, cast_status): + """Handle updates of audio group status.""" + _LOGGER.debug( + "[%s %s (%s:%s)] Multizone %s cast status: %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + group_uuid, cast_status) + self.mz_cast_status[group_uuid] = cast_status + self.schedule_update_ha_state() + # ========== Service Calls ========== def turn_on(self): """Turn on the cast device.""" @@ -650,16 +941,42 @@ class CastDevice(MediaPlayerDevice): 'manufacturer': cast_info.manufacturer, } + def _media_status(self): + """ + Return media status. + + First try from our own cast, then dynamic groups and finally + groups which our cast is a member in. + """ + media_status = self.media_status + media_status_received = self.media_status_received + + if media_status is None or media_status.player_state == "UNKNOWN": + media_status = self.dynamic_group_media_status + media_status_received = self.dynamic_group_media_status_received + + if media_status is None or media_status.player_state == "UNKNOWN": + groups = self.mz_media_status + for k, val in groups.items(): + if val and val.player_state != "UNKNOWN": + media_status = val + media_status_received = self.mz_media_status_received[k] + break + + return (media_status, media_status_received) + @property def state(self): """Return the state of the player.""" - if self.media_status is None: + media_status, _ = self._media_status() + + if media_status is None: return None - if self.media_status.player_is_playing: + if media_status.player_is_playing: return STATE_PLAYING - if self.media_status.player_is_paused: + if media_status.player_is_paused: return STATE_PAUSED - if self.media_status.player_is_idle: + if media_status.player_is_idle: return STATE_IDLE if self._chromecast is not None and self._chromecast.is_idle: return STATE_OFF @@ -683,75 +1000,87 @@ class CastDevice(MediaPlayerDevice): @property def media_content_id(self): """Content ID of current playing media.""" - return self.media_status.content_id if self.media_status else None + media_status, _ = self._media_status() + return media_status.content_id if media_status else None @property def media_content_type(self): """Content type of current playing media.""" - if self.media_status is None: + media_status, _ = self._media_status() + if media_status is None: return None - if self.media_status.media_is_tvshow: + if media_status.media_is_tvshow: return MEDIA_TYPE_TVSHOW - if self.media_status.media_is_movie: + if media_status.media_is_movie: return MEDIA_TYPE_MOVIE - if self.media_status.media_is_musictrack: + if media_status.media_is_musictrack: return MEDIA_TYPE_MUSIC return None @property def media_duration(self): """Duration of current playing media in seconds.""" - return self.media_status.duration if self.media_status else None + media_status, _ = self._media_status() + return media_status.duration if media_status else None @property def media_image_url(self): """Image url of current playing media.""" - if self.media_status is None: + media_status, _ = self._media_status() + if media_status is None: return None - images = self.media_status.images + images = media_status.images return images[0].url if images and images[0].url else None @property def media_title(self): """Title of current playing media.""" - return self.media_status.title if self.media_status else None + media_status, _ = self._media_status() + return media_status.title if media_status else None @property def media_artist(self): """Artist of current playing media (Music track only).""" - return self.media_status.artist if self.media_status else None + media_status, _ = self._media_status() + return media_status.artist if media_status else None @property def media_album_name(self): """Album of current playing media (Music track only).""" - return self.media_status.album_name if self.media_status else None + media_status, _ = self._media_status() + return media_status.album_name if media_status else None @property def media_album_artist(self): """Album artist of current playing media (Music track only).""" - return self.media_status.album_artist if self.media_status else None + media_status, _ = self._media_status() + return media_status.album_artist if media_status else None @property def media_track(self): """Track number of current playing media (Music track only).""" - return self.media_status.track if self.media_status else None + media_status, _ = self._media_status() + return media_status.track if media_status else None @property def media_series_title(self): """Return the title of the series of current playing media.""" - return self.media_status.series_title if self.media_status else None + media_status, _ = self._media_status() + return media_status.series_title if media_status else None @property def media_season(self): """Season of current playing media (TV Show only).""" - return self.media_status.season if self.media_status else None + media_status, _ = self._media_status() + return media_status.season if media_status else None @property def media_episode(self): """Episode of current playing media (TV Show only).""" - return self.media_status.episode if self.media_status else None + media_status, _ = self._media_status() + return media_status.episode if media_status else None @property def app_id(self): @@ -771,12 +1100,13 @@ class CastDevice(MediaPlayerDevice): @property def media_position(self): """Position of current playing media in seconds.""" - if self.media_status is None or \ - not (self.media_status.player_is_playing or - self.media_status.player_is_paused or - self.media_status.player_is_idle): + media_status, _ = self._media_status() + if media_status is None or \ + not (media_status.player_is_playing or + media_status.player_is_paused or + media_status.player_is_idle): return None - return self.media_status.current_time + return media_status.current_time @property def media_position_updated_at(self): @@ -784,7 +1114,8 @@ class CastDevice(MediaPlayerDevice): Returns value from homeassistant.util.dt.utcnow(). """ - return self.media_status_received + _, media_status_recevied = self._media_status() + return media_status_recevied @property def unique_id(self) -> Optional[str]: diff --git a/requirements_all.txt b/requirements_all.txt index f105a8a711c..f5b8e86c129 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -977,7 +977,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==3.0.0 +pychromecast==3.1.0 # homeassistant.components.cmus.media_player pycmus==0.1.1 diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index ff81c056420..85012a4a710 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -24,12 +24,14 @@ def cast_mock(): """Mock pychromecast.""" with patch.dict('sys.modules', { 'pychromecast': MagicMock(), + 'pychromecast.controllers.multizone': MagicMock(), }): yield # pylint: disable=invalid-name FakeUUID = UUID('57355bce-9364-4aa6-ac1e-eb849dccf9e2') +FakeGroupUUID = UUID('57355bce-9364-4aa6-ac1e-eb849dccf9e3') def get_fake_chromecast(info: ChromecastInfo): @@ -300,6 +302,101 @@ async def test_entity_media_states(hass: HomeAssistantType): assert state.state == 'unknown' +async def test_group_media_states(hass: HomeAssistantType): + """Test media states are read from group if entity has no state.""" + info = get_fake_chromecast_info() + full_info = attr.evolve(info, model_name='google home', + friendly_name='Speaker', uuid=FakeUUID) + + with patch('pychromecast.dial.get_device_status', + return_value=full_info): + chromecast, entity = await async_setup_media_player_cast(hass, info) + + entity._available = True + entity.schedule_update_ha_state() + await hass.async_block_till_done() + + state = hass.states.get('media_player.speaker') + assert state is not None + assert state.name == 'Speaker' + assert state.state == 'unknown' + assert entity.unique_id == full_info.uuid + + group_media_status = MagicMock(images=None) + player_media_status = MagicMock(images=None) + + # Player has no state, group is playing -> Should report 'playing' + group_media_status.player_is_playing = True + entity.multizone_new_media_status(str(FakeGroupUUID), group_media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'playing' + + # Player is paused, group is playing -> Should report 'paused' + player_media_status.player_is_playing = False + player_media_status.player_is_paused = True + entity.new_media_status(player_media_status) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'paused' + + # Player is in unknown state, group is playing -> Should report 'playing' + player_media_status.player_state = "UNKNOWN" + entity.new_media_status(player_media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'playing' + + +async def test_dynamic_group_media_states(hass: HomeAssistantType): + """Test media states are read from group if entity has no state.""" + info = get_fake_chromecast_info() + full_info = attr.evolve(info, model_name='google home', + friendly_name='Speaker', uuid=FakeUUID) + + with patch('pychromecast.dial.get_device_status', + return_value=full_info): + chromecast, entity = await async_setup_media_player_cast(hass, info) + + entity._available = True + entity.schedule_update_ha_state() + await hass.async_block_till_done() + + state = hass.states.get('media_player.speaker') + assert state is not None + assert state.name == 'Speaker' + assert state.state == 'unknown' + assert entity.unique_id == full_info.uuid + + group_media_status = MagicMock(images=None) + player_media_status = MagicMock(images=None) + + # Player has no state, dynamic group is playing -> Should report 'playing' + group_media_status.player_is_playing = True + entity.new_dynamic_group_media_status(group_media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'playing' + + # Player is paused, dynamic group is playing -> Should report 'paused' + player_media_status.player_is_playing = False + player_media_status.player_is_paused = True + entity.new_media_status(player_media_status) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'paused' + + # Player is in unknown state, dynamic group is playing -> Should report + # 'playing' + player_media_status.player_state = "UNKNOWN" + entity.new_media_status(player_media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'playing' + + async def test_disconnect_on_stop(hass: HomeAssistantType): """Test cast device disconnects socket on stop.""" info = get_fake_chromecast_info()