Improve handling of audio groups (#22396)
* Improve handling of audio groups * Review comments * Fix tests * Add tests * Review comment * Bump pychromecastpull/22418/head
parent
64306922b1
commit
2e375aa802
|
@ -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'
|
||||
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue