From 7468cc21bed278e8abc79c8ad28ed1e251806d81 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 Sep 2019 13:05:46 -0700 Subject: [PATCH] Refactor Cast (#26550) * Refactor Cast * Fix tests & address comments * Update reqs --- homeassistant/components/cast/const.py | 20 + homeassistant/components/cast/discovery.py | 99 ++++ homeassistant/components/cast/helpers.py | 246 +++++++++ homeassistant/components/cast/media_player.py | 519 +++--------------- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 35 +- tests/components/cast/test_media_player.py | 75 ++- 7 files changed, 526 insertions(+), 471 deletions(-) create mode 100644 homeassistant/components/cast/discovery.py create mode 100644 homeassistant/components/cast/helpers.py diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index e9f9ba4c39d..a493c322f14 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,3 +1,23 @@ """Consts for Cast integration.""" DOMAIN = "cast" +DEFAULT_PORT = 8009 + +# Stores a threading.Lock that is held by the internal pychromecast discovery. +INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running" +# Stores all ChromecastInfo we encountered through discovery or config as a set +# If we find a chromecast with a new host, the old one will be removed again. +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 +SIGNAL_CAST_DISCOVERED = "cast_discovered" + +# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is +# removed +SIGNAL_CAST_REMOVED = "cast_removed" diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py new file mode 100644 index 00000000000..d3097b3cc29 --- /dev/null +++ b/homeassistant/components/cast/discovery.py @@ -0,0 +1,99 @@ +"""Deal with Cast discovery.""" +import logging +import threading + +import pychromecast + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import ( + KNOWN_CHROMECAST_INFO_KEY, + SIGNAL_CAST_DISCOVERED, + INTERNAL_DISCOVERY_RUNNING_KEY, + SIGNAL_CAST_REMOVED, +) +from .helpers import ChromecastInfo, ChromeCastZeroconf + +_LOGGER = logging.getLogger(__name__) + + +def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo): + """Discover a Chromecast.""" + if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]: + _LOGGER.debug("Discovered previous chromecast %s", info) + + # Either discovered completely new chromecast or a "moved" one. + info = info.fill_out_missing_chromecast_info() + _LOGGER.debug("Discovered chromecast %s", info) + + if info.uuid is not None: + # Remove previous cast infos with same uuid from known chromecasts. + same_uuid = set( + x for x in hass.data[KNOWN_CHROMECAST_INFO_KEY] if info.uuid == x.uuid + ) + hass.data[KNOWN_CHROMECAST_INFO_KEY] -= same_uuid + + hass.data[KNOWN_CHROMECAST_INFO_KEY].add(info) + dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info) + + +def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo): + # Removed chromecast + _LOGGER.debug("Removed chromecast %s", info) + + dispatcher_send(hass, SIGNAL_CAST_REMOVED, info) + + +def setup_internal_discovery(hass: HomeAssistant) -> None: + """Set up the pychromecast internal discovery.""" + if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data: + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock() + + if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): + # Internal discovery is already running + return + + def internal_add_callback(name): + """Handle zeroconf discovery of a new chromecast.""" + mdns = listener.services[name] + discover_chromecast( + hass, + ChromecastInfo( + service=name, + host=mdns[0], + port=mdns[1], + uuid=mdns[2], + model_name=mdns[3], + friendly_name=mdns[4], + ), + ) + + def internal_remove_callback(name, mdns): + """Handle zeroconf discovery of a removed chromecast.""" + _remove_chromecast( + hass, + ChromecastInfo( + service=name, + host=mdns[0], + port=mdns[1], + uuid=mdns[2], + model_name=mdns[3], + friendly_name=mdns[4], + ), + ) + + _LOGGER.debug("Starting internal pychromecast discovery.") + listener, browser = pychromecast.start_discovery( + internal_add_callback, internal_remove_callback + ) + ChromeCastZeroconf.set_zeroconf(browser.zc) + + def stop_discovery(event): + """Stop discovery of new chromecasts.""" + _LOGGER.debug("Stopping internal pychromecast discovery.") + pychromecast.stop_discovery(browser) + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py new file mode 100644 index 00000000000..ea5c77ebc1a --- /dev/null +++ b/homeassistant/components/cast/helpers.py @@ -0,0 +1,246 @@ +"""Helpers to deal with Cast devices.""" +from typing import Optional, Tuple + +import attr +from pychromecast import dial + +from .const import DEFAULT_PORT + + +@attr.s(slots=True, frozen=True) +class ChromecastInfo: + """Class to hold all data about a chromecast for creating connections. + + This also has the same attributes as the mDNS fields by zeroconf. + """ + + host = attr.ib(type=str) + port = attr.ib(type=int) + service = attr.ib(type=Optional[str], default=None) + uuid = attr.ib( + type=Optional[str], converter=attr.converters.optional(str), default=None + ) # always convert UUID to string if not None + 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: + """Return if this is an audio group.""" + return self.port != DEFAULT_PORT + + @property + def is_information_complete(self) -> bool: + """Return if all information is filled out.""" + 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]: + """Return the host+port tuple.""" + return self.host, self.port + + def fill_out_missing_chromecast_info(self) -> "ChromecastInfo": + """Return a new ChromecastInfo object with missing attributes filled in. + + Uses blocking HTTP. + """ + if self.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 self + + # Fill out missing information via HTTP dial. + if self.is_audio_group: + is_dynamic_group = False + http_group_status = None + dynamic_groups = [] + if self.uuid: + http_group_status = dial.get_multizone_status( + self.host, + services=[self.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 = self.uuid in dynamic_groups + + return ChromecastInfo( + service=self.service, + host=self.host, + port=self.port, + uuid=self.uuid, + friendly_name=self.friendly_name, + manufacturer=self.manufacturer, + model_name=self.model_name, + is_dynamic_group=is_dynamic_group, + ) + + http_device_status = dial.get_device_status( + self.host, services=[self.service], zconf=ChromeCastZeroconf.get_zeroconf() + ) + if http_device_status is None: + # HTTP dial didn't give us any new information. + return self + + return ChromecastInfo( + service=self.service, + 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), + ) + + def same_dynamic_group(self, other: "ChromecastInfo") -> bool: + """Test chromecast info is same dynamic group.""" + return ( + self.is_audio_group + and other.is_dynamic_group + and self.friendly_name == other.friendly_name + ) + + +class ChromeCastZeroconf: + """Class to hold a zeroconf instance.""" + + __zconf = None + + @classmethod + def set_zeroconf(cls, zconf): + """Set zeroconf.""" + cls.__zconf = zconf + + @classmethod + def get_zeroconf(cls): + """Get zeroconf.""" + return cls.__zconf + + +class CastStatusListener: + """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) + # 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.""" + if self._valid: + self._cast_device.new_cast_status(cast_status) + + def new_media_status(self, media_status): + """Handle reception of a new MediaStatus.""" + if self._valid: + self._cast_device.new_media_status(media_status) + + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + 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) + + def multizone_new_cast_status(self, group_uuid, cast_status): + """Handle reception of a new CastStatus for a group.""" + pass + + 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.""" + pass + + 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 diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 26bcaccc247..7b750c3fe0c 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -1,10 +1,14 @@ """Provide functionality to interact with Cast devices on the network.""" import asyncio import logging -import threading -from typing import Optional, Tuple +from typing import Optional -import attr +import pychromecast +from pychromecast.socket_client import ( + CONNECTION_STATUS_CONNECTED, + CONNECTION_STATUS_DISCONNECTED, +) +from pychromecast.controllers.multizone import MultizoneManager import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice @@ -35,22 +39,33 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util from homeassistant.util.logging import async_create_catching_coro -from . import DOMAIN as CAST_DOMAIN - -DEPENDENCIES = ("cast",) +from .const import ( + DOMAIN as CAST_DOMAIN, + ADDED_CAST_DEVICES_KEY, + SIGNAL_CAST_DISCOVERED, + KNOWN_CHROMECAST_INFO_KEY, + CAST_MULTIZONE_MANAGER_KEY, + DEFAULT_PORT, + SIGNAL_CAST_REMOVED, +) +from .helpers import ( + ChromecastInfo, + CastStatusListener, + DynamicGroupCastStatusListener, + ChromeCastZeroconf, +) +from .discovery import setup_internal_discovery, discover_chromecast _LOGGER = logging.getLogger(__name__) CONF_IGNORE_CEC = "ignore_cec" CAST_SPLASH = "https://home-assistant.io/images/cast/splash.png" -DEFAULT_PORT = 8009 - SUPPORT_CAST = ( SUPPORT_PAUSE | SUPPORT_PLAY @@ -62,24 +77,6 @@ SUPPORT_CAST = ( | SUPPORT_VOLUME_SET ) -# Stores a threading.Lock that is held by the internal pychromecast discovery. -INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running" -# Stores all ChromecastInfo we encountered through discovery or config as a set -# If we find a chromecast with a new host, the old one will be removed again. -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 -SIGNAL_CAST_DISCOVERED = "cast_discovered" - -# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is -# removed -SIGNAL_CAST_REMOVED = "cast_removed" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -89,212 +86,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -@attr.s(slots=True, frozen=True) -class ChromecastInfo: - """Class to hold all data about a chromecast for creating connections. - - This also has the same attributes as the mDNS fields by zeroconf. - """ - - host = attr.ib(type=str) - port = attr.ib(type=int) - service = attr.ib(type=Optional[str], default=None) - uuid = attr.ib( - type=Optional[str], converter=attr.converters.optional(str), default=None - ) # always convert UUID to string if not None - 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: - """Return if this is an audio group.""" - return self.port != DEFAULT_PORT - - @property - def is_information_complete(self) -> bool: - """Return if all information is filled out.""" - 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]: - """Return the host+port tuple.""" - 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: - # 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 - - # 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() - ) - if http_device_status is None: - # HTTP dial didn't give us any new information. - return info - - return ChromecastInfo( - service=info.service, - host=info.host, - port=info.port, - uuid=(info.uuid or http_device_status.uuid), - friendly_name=(info.friendly_name or http_device_status.friendly_name), - manufacturer=(info.manufacturer or http_device_status.manufacturer), - model_name=(info.model_name or http_device_status.model_name), - ) - - -def _discover_chromecast(hass: HomeAssistantType, info: ChromecastInfo): - if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]: - _LOGGER.debug("Discovered previous chromecast %s", info) - - # Either discovered completely new chromecast or a "moved" one. - info = _fill_out_missing_chromecast_info(info) - _LOGGER.debug("Discovered chromecast %s", info) - - if info.uuid is not None: - # Remove previous cast infos with same uuid from known chromecasts. - same_uuid = set( - x for x in hass.data[KNOWN_CHROMECAST_INFO_KEY] if info.uuid == x.uuid - ) - hass.data[KNOWN_CHROMECAST_INFO_KEY] -= same_uuid - - hass.data[KNOWN_CHROMECAST_INFO_KEY].add(info) - dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info) - - -def _remove_chromecast(hass: HomeAssistantType, info: ChromecastInfo): - # Removed chromecast - _LOGGER.debug("Removed chromecast %s", info) - - dispatcher_send(hass, SIGNAL_CAST_REMOVED, info) - - -class ChromeCastZeroconf: - """Class to hold a zeroconf instance.""" - - __zconf = None - - @classmethod - def set_zeroconf(cls, zconf): - """Set zeroconf.""" - cls.__zconf = zconf - - @classmethod - def get_zeroconf(cls): - """Get zeroconf.""" - return cls.__zconf - - -def _setup_internal_discovery(hass: HomeAssistantType) -> None: - """Set up the pychromecast internal discovery.""" - if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data: - hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock() - - if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): - # Internal discovery is already running - return - - import pychromecast - - def internal_add_callback(name): - """Handle zeroconf discovery of a new chromecast.""" - mdns = listener.services[name] - _discover_chromecast( - hass, - ChromecastInfo( - service=name, - host=mdns[0], - port=mdns[1], - uuid=mdns[2], - model_name=mdns[3], - friendly_name=mdns[4], - ), - ) - - def internal_remove_callback(name, mdns): - """Handle zeroconf discovery of a removed chromecast.""" - _remove_chromecast( - hass, - ChromecastInfo( - service=name, - host=mdns[0], - port=mdns[1], - uuid=mdns[2], - model_name=mdns[3], - friendly_name=mdns[4], - ), - ) - - _LOGGER.debug("Starting internal pychromecast discovery.") - listener, browser = pychromecast.start_discovery( - internal_add_callback, internal_remove_callback - ) - ChromeCastZeroconf.set_zeroconf(browser.zc) - - def stop_discovery(event): - """Stop discovery of new chromecasts.""" - _LOGGER.debug("Stopping internal pychromecast discovery.") - pychromecast.stop_discovery(browser) - hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) - - @callback def _async_create_cast_device(hass: HomeAssistantType, info: ChromecastInfo): """Create a CastDevice Entity from the chromecast object. @@ -357,8 +148,6 @@ async def _async_setup_platform( hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info ): """Set up the cast platform.""" - import pychromecast - # Import CEC IGNORE attributes pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set()) @@ -390,9 +179,9 @@ async def _async_setup_platform( if info is None or info.is_audio_group: # If we were a) explicitly told to enable discovery or # b) have an audio group cast device, we need internal discovery. - hass.async_add_job(_setup_internal_discovery, hass) + hass.async_add_executor_job(setup_internal_discovery, hass) else: - info = await hass.async_add_job(_fill_out_missing_chromecast_info, info) + info = await hass.async_add_executor_job(info.fill_out_missing_chromecast_info) if info.friendly_name is None: _LOGGER.debug( "Cannot retrieve detail information for chromecast" @@ -400,121 +189,7 @@ async def _async_setup_platform( info, ) - hass.async_add_job(_discover_chromecast, hass, info) - - -class CastStatusListener: - """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) - # 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.""" - if self._valid: - self._cast_device.new_cast_status(cast_status) - - def new_media_status(self, media_status): - """Handle reception of a new MediaStatus.""" - if self._valid: - self._cast_device.new_media_status(media_status) - - def new_connection_status(self, connection_status): - """Handle reception of a new ConnectionStatus.""" - 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) - - def multizone_new_cast_status(self, group_uuid, cast_status): - """Handle reception of a new CastStatus for a group.""" - pass - - 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.""" - pass - - 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 + hass.async_add_executor_job(discover_chromecast, hass, info) class CastDevice(MediaPlayerDevice): @@ -527,7 +202,6 @@ class CastDevice(MediaPlayerDevice): def __init__(self, cast_info: ChromecastInfo): """Initialize the cast device.""" - import pychromecast self._cast_info = cast_info self.services = None @@ -557,75 +231,18 @@ class CastDevice(MediaPlayerDevice): async def async_added_to_hass(self): """Create chromecast object when added to hass.""" - - @callback - def async_cast_discovered(discover: ChromecastInfo): - """Handle discovery of new Chromecast.""" - 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( - async_create_catching_coro(self.async_set_dynamic_group(discover)) - ) - return - - if self._cast_info.uuid != discover.uuid: - # Discovered is not our device. - return - if self.services is None: - _LOGGER.warning( - "[%s %s (%s:%s)] Received update for manually added Cast", - self.entity_id, - self._cast_info.friendly_name, - self._cast_info.host, - self._cast_info.port, - ) - return - _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) - self.hass.async_create_task( - async_create_catching_coro(self.async_set_cast_info(discover)) - ) - - def async_cast_removed(discover: ChromecastInfo): - """Handle removal of Chromecast.""" - 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( - async_create_catching_coro(self.async_del_dynamic_group()) - ) - return - if self._cast_info.uuid != discover.uuid: - # Removed is not our device. - return - _LOGGER.debug("Removed chromecast with same UUID: %s", discover) - self.hass.async_create_task( - async_create_catching_coro(self.async_del_cast_info(discover)) - ) - - async def async_stop(event): - """Disconnect socket on Home Assistant stop.""" - await self._async_disconnect() - self._add_remove_handler = async_dispatcher_connect( - self.hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered + self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered ) self._del_remove_handler = async_dispatcher_connect( - self.hass, SIGNAL_CAST_REMOVED, async_cast_removed + self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed ) - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop) self.hass.async_create_task( async_create_catching_coro(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): + if self._cast_info.same_dynamic_group(info): _LOGGER.debug( "[%s %s (%s:%s)] Found dynamic group: %s", self.entity_id, @@ -653,7 +270,6 @@ class CastDevice(MediaPlayerDevice): async def async_set_cast_info(self, cast_info): """Set the cast information and set up the chromecast object.""" - import pychromecast self._cast_info = cast_info @@ -718,9 +334,8 @@ class CastDevice(MediaPlayerDevice): self._chromecast = 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() + self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr) @@ -745,7 +360,6 @@ class CastDevice(MediaPlayerDevice): 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", @@ -774,9 +388,8 @@ class CastDevice(MediaPlayerDevice): 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( @@ -867,11 +480,6 @@ class CastDevice(MediaPlayerDevice): def new_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 cast device connection status: %s", self.entity_id, @@ -902,7 +510,7 @@ class CastDevice(MediaPlayerDevice): info = self._cast_info if info.friendly_name is None and not info.is_audio_group: # We couldn't find friendly_name when the cast was added, retry - self._cast_info = _fill_out_missing_chromecast_info(info) + self._cast_info = info.fill_out_missing_chromecast_info() self._available = new_available self.schedule_update_ha_state() @@ -914,11 +522,6 @@ class CastDevice(MediaPlayerDevice): 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, @@ -992,7 +595,6 @@ class CastDevice(MediaPlayerDevice): def turn_on(self): """Turn on the cast device.""" - import pychromecast if not self._chromecast.is_idle: # Already turned on @@ -1277,3 +879,56 @@ class CastDevice(MediaPlayerDevice): def unique_id(self) -> Optional[str]: """Return a unique ID.""" return self._cast_info.uuid + + 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.same_dynamic_group(discover): + _LOGGER.debug("Discovered matching dynamic group: %s", discover) + await self.async_set_dynamic_group(discover) + return + + if self._cast_info.uuid != discover.uuid: + # Discovered is not our device. + return + + if self.services is None: + _LOGGER.warning( + "[%s %s (%s:%s)] Received update for manually added Cast", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + ) + return + + _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) + await self.async_set_cast_info(discover) + + async def _async_cast_removed(self, discover: ChromecastInfo): + """Handle removal of Chromecast.""" + 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) + await self.async_del_dynamic_group() + return + + if self._cast_info.uuid != discover.uuid: + # Removed is not our device. + return + + _LOGGER.debug("Removed chromecast with same UUID: %s", discover) + await self.async_del_cast_info(discover) + + async def _async_stop(self, event): + """Disconnect socket on Home Assistant stop.""" + await self._async_disconnect() diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8582683d5f2..1fc8ba3ebfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -279,6 +279,9 @@ pyMetno==0.4.6 # homeassistant.components.blackbird pyblackbird==0.5 +# homeassistant.components.cast +pychromecast==3.2.2 + # homeassistant.components.deconz pydeconz==62 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ad6507b4e9e..ff2943a583b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -10,8 +10,8 @@ import sys from script.hassfest.model import Integration COMMENT_REQUIREMENTS = ( - "Adafruit-DHT", "Adafruit_BBIO", + "Adafruit-DHT", "avion", "beacontools", "blinkt", @@ -26,7 +26,6 @@ COMMENT_REQUIREMENTS = ( "i2csense", "opencv-python-headless", "py_noaa", - "VL53L1X2", "pybluez", "pycups", "PySwitchbot", @@ -39,11 +38,11 @@ COMMENT_REQUIREMENTS = ( "RPi.GPIO", "smbus-cffi", "tensorflow", + "VL53L1X2", ) TEST_REQUIREMENTS = ( "adguardhome", - "ambiclimate", "aio_geojson_geonetnz_quakes", "aioambient", "aioautomatic", @@ -52,14 +51,16 @@ TEST_REQUIREMENTS = ( "aiohttp_cors", "aiohue", "aionotion", - "aiounifi", "aioswitcher", + "aiounifi", "aiowwlln", + "ambiclimate", "androidtv", "apns2", "aprslib", "av", "axis", + "bellows-homeassistant", "caldav", "coinmarketcap", "defusedxml", @@ -99,7 +100,6 @@ TEST_REQUIREMENTS = ( "libpurecool", "libsoundtouch", "luftdaten", - "pyMetno", "mbddns", "mficlient", "minio", @@ -115,44 +115,49 @@ TEST_REQUIREMENTS = ( "ptvsd", "pushbullet.py", "py-canary", + "py17track", "pyblackbird", + "pychromecast", "pydeconz", "pydispatcher", "pyheos", "pyhomematic", + "pyHS100", "pyiqvia", "pylinky", "pylitejet", + "pyMetno", "pymfy", "pymonoprice", + "PyNaCl", "pynws", "pynx584", "pyopenuv", "pyotp", "pyps4-homeassistant", + "pyqwikswitch", + "PyRMVtransport", "pysma", "pysmartapp", "pysmartthings", "pysonos", - "pyqwikswitch", - "PyRMVtransport", - "PyTransportNSW", "pyspcwebgw", + "python_awair", "python-forecastio", "python-nest", - "python_awair", "python-velbus", + "pythonwhois", "pytradfri[async]", + "PyTransportNSW", "pyunifi", "pyupnp-async", "pyvesync", "pywebpush", - "pyHS100", - "PyNaCl", "regenmaschine", "restrictedpython", "rflink", "ring_doorbell", + "ruamel.yaml", "rxv", "simplisafe-python", "sleepyq", @@ -166,16 +171,12 @@ TEST_REQUIREMENTS = ( "twentemilieu", "uvcclient", "vsure", - "warrant", - "pythonwhois", - "wakeonlan", "vultr", + "wakeonlan", + "warrant", "YesssSMS", - "ruamel.yaml", "zeroconf", "zigpy-homeassistant", - "bellows-homeassistant", - "py17track", ) IGNORE_PIN = ("colorlog>2.1,<3", "keyring>=9.3,<10.0", "urllib3") diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 7995ba8f781..8f33709fb2d 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -22,12 +22,16 @@ from tests.common import MockConfigEntry, mock_coro @pytest.fixture(autouse=True) def cast_mock(): """Mock pychromecast.""" - with patch.dict( - "sys.modules", - { - "pychromecast": MagicMock(), - "pychromecast.controllers.multizone": MagicMock(), - }, + pycast_mock = MagicMock() + + with patch( + "homeassistant.components.cast.media_player.pychromecast", pycast_mock + ), patch( + "homeassistant.components.cast.discovery.pychromecast", pycast_mock + ), patch( + "homeassistant.components.cast.helpers.dial", MagicMock() + ), patch( + "homeassistant.components.cast.media_player.MultizoneManager", MagicMock() ): yield @@ -73,7 +77,8 @@ async def async_setup_cast_internal_discovery(hass, config=None, discovery_info= browser = MagicMock(zc={}) with patch( - "pychromecast.start_discovery", return_value=(listener, browser) + "homeassistant.components.cast.discovery.pychromecast.start_discovery", + return_value=(listener, browser), ) as start_discovery: add_entities = await async_setup_cast(hass, config, discovery_info) await hass.async_block_till_done() @@ -104,7 +109,8 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas cast.CastStatusListener = MagicMock() with patch( - "pychromecast._get_chromecast_from_host", return_value=chromecast + "homeassistant.components.cast.discovery.pychromecast._get_chromecast_from_host", + return_value=chromecast, ) as get_chromecast: await async_setup_component( hass, @@ -122,7 +128,8 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas def test_start_discovery_called_once(hass): """Test pychromecast.start_discovery called exactly once.""" with patch( - "pychromecast.start_discovery", return_value=(None, None) + "homeassistant.components.cast.discovery.pychromecast.start_discovery", + return_value=(None, None), ) as start_discovery: yield from async_setup_cast(hass) @@ -138,14 +145,17 @@ def test_stop_discovery_called_on_stop(hass): browser = MagicMock(zc={}) with patch( - "pychromecast.start_discovery", return_value=(None, browser) + "homeassistant.components.cast.discovery.pychromecast.start_discovery", + return_value=(None, browser), ) as start_discovery: # start_discovery should be called with empty config yield from async_setup_cast(hass, {}) assert start_discovery.call_count == 1 - with patch("pychromecast.stop_discovery") as stop_discovery: + with patch( + "homeassistant.components.cast.discovery.pychromecast.stop_discovery" + ) as stop_discovery: # stop discovery should be called on shutdown hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) yield from hass.async_block_till_done() @@ -153,7 +163,8 @@ def test_stop_discovery_called_on_stop(hass): stop_discovery.assert_called_once_with(browser) with patch( - "pychromecast.start_discovery", return_value=(None, browser) + "homeassistant.components.cast.discovery.pychromecast.start_discovery", + return_value=(None, browser), ) as start_discovery: # start_discovery should be called again on re-startup yield from async_setup_cast(hass) @@ -173,7 +184,10 @@ async def test_internal_discovery_callback_fill_out(hass): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): signal = MagicMock() async_dispatcher_connect(hass, "cast_discovered", signal) @@ -210,7 +224,7 @@ async def test_normal_chromecast_not_starting_discovery(hass): """Test cast platform not starting discovery when not required.""" # pylint: disable=no-member with patch( - "homeassistant.components.cast.media_player." "_setup_internal_discovery" + "homeassistant.components.cast.media_player.setup_internal_discovery" ) as setup_discovery: # normal (non-group) chromecast shouldn't start discovery. add_entities = await async_setup_cast(hass, {"host": "host1"}) @@ -275,7 +289,10 @@ async def test_entity_media_states(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True @@ -330,7 +347,10 @@ async def test_group_media_states(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True @@ -377,7 +397,10 @@ async def test_dynamic_group_media_states(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True @@ -426,12 +449,14 @@ async def test_group_media_control(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.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() + entity.async_write_ha_state() state = hass.states.get("media_player.speaker") assert state is not None @@ -480,7 +505,10 @@ async def test_dynamic_group_media_control(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True @@ -529,7 +557,10 @@ async def test_disconnect_on_stop(hass: HomeAssistantType): """Test cast device disconnects socket on stop.""" info = get_fake_chromecast_info() - with patch("pychromecast.dial.get_device_status", return_value=info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=info, + ): chromecast, _ = await async_setup_media_player_cast(hass, info) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)