Improve handling of audio groups (#22396)

* Improve handling of audio groups

* Review comments

* Fix tests

* Add tests

* Review comment

* Bump pychromecast
pull/22418/head
emontnemery 2019-03-30 18:19:18 +01:00 committed by Paulus Schoutsen
parent 64306922b1
commit 2e375aa802
4 changed files with 461 additions and 33 deletions

View File

@ -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'

View File

@ -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]:

View File

@ -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

View File

@ -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()