diff --git a/resources/pychromecast/.travis.yml b/resources/pychromecast/.travis.yml deleted file mode 100644 index 730ec45..0000000 --- a/resources/pychromecast/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -sudo: false -language: python -python: - - "3.4" - - "3.5" - - "3.6" -install: - - pip install -r requirements.txt - - pip install flake8==3.3.0 pylint==1.8.1 -script: - - flake8 --exclude cast_channel_pb2.py,authority_keys_pb2.py,logging_pb2.py pychromecast - - pylint pychromecast diff --git a/resources/pychromecast/__init__.py b/resources/pychromecast/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/resources/pychromecast/examples/bbciplayer_example.py b/resources/pychromecast/examples/bbciplayer_example.py index 1224a9d..18155af 100644 --- a/resources/pychromecast/examples/bbciplayer_example.py +++ b/resources/pychromecast/examples/bbciplayer_example.py @@ -76,17 +76,13 @@ friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] # Start socket client's worker thread and wait for initial status update cast.wait() -print( - 'Found chromecast with name "{}", attempting to play "{}"'.format( - args.cast, args.media_id - ) -) +print(f'Found chromecast with name "{args.cast}", attempting to play "{args.media_id}"') quick_play.quick_play(cast, app_name, app_data) diff --git a/resources/pychromecast/examples/bbcsounds_example.py b/resources/pychromecast/examples/bbcsounds_example.py index 77605a2..24c2c3d 100644 --- a/resources/pychromecast/examples/bbcsounds_example.py +++ b/resources/pychromecast/examples/bbcsounds_example.py @@ -76,17 +76,13 @@ friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] # Start socket client's worker thread and wait for initial status update cast.wait() -print( - 'Found chromecast with name "{}", attempting to play "{}"'.format( - args.cast, args.media_id - ) -) +print(f'Found chromecast with name "{args.cast}", attempting to play "{args.media_id}"') quick_play.quick_play(cast, app_name, app_data) diff --git a/resources/pychromecast/examples/bubbleupnp_example.py b/resources/pychromecast/examples/bubbleupnp_example.py index 85a50c1..9c67a2e 100644 --- a/resources/pychromecast/examples/bubbleupnp_example.py +++ b/resources/pychromecast/examples/bubbleupnp_example.py @@ -52,17 +52,13 @@ friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = list(chromecasts)[0] # Start socket client's worker thread and wait for initial status update cast.wait() -print( - 'Found chromecast with name "{}", attempting to play "{}"'.format( - args.cast, args.url - ) -) +print(f'Found chromecast with name "{args.cast}", attempting to play "{args.url}"') bubbleupnp = BubbleUPNPController() cast.register_handler(bubbleupnp) bubbleupnp.launch() diff --git a/resources/pychromecast/examples/custom_loop.py b/resources/pychromecast/examples/custom_loop.py index 981d080..e32f3fb 100644 --- a/resources/pychromecast/examples/custom_loop.py +++ b/resources/pychromecast/examples/custom_loop.py @@ -52,7 +52,7 @@ def callback(chromecast): if t > 50: break else: - print("=> Waiting for discovery of cast '{}'...".format(args.cast)) + print(f"=> Waiting for discovery of cast '{args.cast}'...") time.sleep(1) print("All done, shutting down discovery") diff --git a/resources/pychromecast/examples/dashcast_example.py b/resources/pychromecast/examples/dashcast_example.py index ff540d4..09a02cc 100644 --- a/resources/pychromecast/examples/dashcast_example.py +++ b/resources/pychromecast/examples/dashcast_example.py @@ -11,7 +11,7 @@ import zeroconf import pychromecast -import pychromecast.controllers.dashcast as dashcast +from pychromecast.controllers import dashcast # Change to the friendly name of your Chromecast CAST_NAME = "Living Room" @@ -43,7 +43,7 @@ friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] @@ -54,7 +54,7 @@ cast.register_handler(d) print() -print(cast.device) +print(cast.cast_info) time.sleep(1) print() print(cast.status) diff --git a/resources/pychromecast/examples/discovery_example.py b/resources/pychromecast/examples/discovery_example.py index 4430727..2ab582c 100644 --- a/resources/pychromecast/examples/discovery_example.py +++ b/resources/pychromecast/examples/discovery_example.py @@ -41,7 +41,7 @@ def list_devices(): """Print a list of known devices.""" print("Currently known cast devices:") for uuid, service in browser.services.items(): - print(" {} {}".format(uuid, service)) + print(f" {uuid} {service}") class MyCastListener(pychromecast.discovery.AbstractCastListener): @@ -49,17 +49,17 @@ class MyCastListener(pychromecast.discovery.AbstractCastListener): def add_cast(self, uuid, _service): """Called when a new cast has beeen discovered.""" - print("Found cast device with UUID {}".format(uuid)) + print(f"Found cast device with UUID {uuid}") list_devices() def remove_cast(self, uuid, _service, cast_info): """Called when a cast has beeen lost (MDNS info expired or host down).""" - print("Lost cast device with UUID {} {}".format(uuid, cast_info)) + print(f"Lost cast device with UUID {uuid} {cast_info}") list_devices() def update_cast(self, uuid, _service): """Called when a cast has beeen updated (MDNS info renewed or changed).""" - print("Updated cast device with UUID {}".format(uuid)) + print(f"Updated cast device with UUID {uuid}") list_devices() diff --git a/resources/pychromecast/examples/get_chromecasts.py b/resources/pychromecast/examples/get_chromecasts.py index 9c537d1..c2cbfb5 100644 --- a/resources/pychromecast/examples/get_chromecasts.py +++ b/resources/pychromecast/examples/get_chromecasts.py @@ -41,7 +41,5 @@ print("Found cast devices:") for cast in casts: print( - ' "{}" on mDNS service {} with UUID:{}'.format( - cast.name, cast._services, cast.uuid # pylint: disable=protected-access - ) + f' "{cast.name}" on mDNS/host service {cast.cast_info.services} with UUID:{cast.uuid}' # pylint: disable=protected-access ) diff --git a/resources/pychromecast/examples/homeassistant_media_example.py b/resources/pychromecast/examples/homeassistant_media_example.py new file mode 100644 index 0000000..d4993cf --- /dev/null +++ b/resources/pychromecast/examples/homeassistant_media_example.py @@ -0,0 +1,74 @@ +""" +Example on how to use the Home Assistant Media app to play an URL. + +""" +# pylint: disable=invalid-name + +import argparse +import logging +import sys +from time import sleep + +import zeroconf + +import pychromecast +from pychromecast import quick_play + + +# Change to the friendly name of your Chromecast +CAST_NAME = "Kitchen speaker" + +# Change to an audio or video url +MEDIA_URL = ( + "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" +) + +parser = argparse.ArgumentParser( + description="Example on how to use the Home Asssitant Media Controller to play an URL." +) +parser.add_argument( + "--cast", help='Name of cast device (default: "%(default)s")', default=CAST_NAME +) +parser.add_argument( + "--known-host", + help="Add known host (IP), can be used multiple times", + action="append", +) +parser.add_argument("--show-debug", help="Enable debug log", action="store_true") +parser.add_argument( + "--show-zeroconf-debug", help="Enable zeroconf debug log", action="store_true" +) +parser.add_argument( + "--url", help='Media url (default: "%(default)s")', default=MEDIA_URL +) +args = parser.parse_args() + +app_name = "homeassistant_media" +app_data = { + "media_id": args.url, +} + +if args.show_debug: + logging.basicConfig(level=logging.DEBUG) +if args.show_zeroconf_debug: + print("Zeroconf version: " + zeroconf.__version__) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) + +# pylint: disable=unbalanced-tuple-unpacking +chromecasts, browser = pychromecast.get_listed_chromecasts( + friendly_names=[args.cast], known_hosts=args.known_host +) +if not chromecasts: + print(f'No chromecast with name "{args.cast}" discovered') + sys.exit(1) + +cast = list(chromecasts)[0] +# Start socket client's worker thread and wait for initial status update +cast.wait() +print(f'Found chromecast with name "{args.cast}", attempting to play "{args.url}"') + +quick_play.quick_play(cast, app_name, app_data) + +sleep(10) + +browser.stop_discovery() diff --git a/resources/pychromecast/examples/media_enqueue.py b/resources/pychromecast/examples/media_enqueue.py index 6732c0f..9d7c35f 100644 --- a/resources/pychromecast/examples/media_enqueue.py +++ b/resources/pychromecast/examples/media_enqueue.py @@ -51,14 +51,14 @@ friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] # Start socket client's worker thread and wait for initial status update cast.wait() -print('Found chromecast with name "{}"'.format(args.cast)) +print(f'Found chromecast with name "{args.cast}"') cast.media_controller.play_media(MEDIA_URLS[0], "audio/mp3") diff --git a/resources/pychromecast/examples/media_example.py b/resources/pychromecast/examples/media_example.py index 7d1ff74..bc5cb65 100644 --- a/resources/pychromecast/examples/media_example.py +++ b/resources/pychromecast/examples/media_example.py @@ -49,17 +49,13 @@ friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] # Start socket client's worker thread and wait for initial status update cast.wait() -print( - 'Found chromecast with name "{}", attempting to play "{}"'.format( - args.cast, args.url - ) -) +print(f'Found chromecast with name "{args.cast}", attempting to play "{args.url}"') cast.media_controller.play_media(args.url, "audio/mp3") # Wait for player_state PLAYING diff --git a/resources/pychromecast/examples/media_example2.py b/resources/pychromecast/examples/media_example2.py index 206e0c8..1b02052 100644 --- a/resources/pychromecast/examples/media_example2.py +++ b/resources/pychromecast/examples/media_example2.py @@ -56,7 +56,7 @@ friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] @@ -65,7 +65,7 @@ cast.wait() print() -print(cast.device) +print(cast.cast_info) time.sleep(1) print() print(cast.status) @@ -84,7 +84,7 @@ time.sleep(0.1) t = t - 0.1 -print('Playing media "{}"'.format(args.url)) +print(f'Playing media "{args.url}"') cast.play_media(args.url, "video/mp4") t = 0 diff --git a/resources/pychromecast/examples/multizone_example.py b/resources/pychromecast/examples/multizone_example.py index 39132d9..00a5439 100644 --- a/resources/pychromecast/examples/multizone_example.py +++ b/resources/pychromecast/examples/multizone_example.py @@ -60,20 +60,20 @@ class MyMultiZoneControllerListener(MultiZoneControllerListener): """MultiZoneControllerListener""" def multizone_member_added(self, group_uuid): - print("New member: {}".format(group_uuid)) + print(f"New member: {group_uuid}") def multizone_member_removed(self, group_uuid): - print("Removed member: {}".format(group_uuid)) + print(f"Removed member: {group_uuid}") def multizone_status_received(self): - print("Members: {}".format(mz.members)) + print(f"Members: {mz.members}") chromecasts, browser = pychromecast.get_listed_chromecasts( friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] diff --git a/resources/pychromecast/examples/simple_listener_example.py b/resources/pychromecast/examples/simple_listener_example.py index c77b243..7d8e3f3 100644 --- a/resources/pychromecast/examples/simple_listener_example.py +++ b/resources/pychromecast/examples/simple_listener_example.py @@ -69,7 +69,7 @@ def new_media_status(self, status): friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) chromecast = chromecasts[0] diff --git a/resources/pychromecast/examples/supla_example.py b/resources/pychromecast/examples/supla_example.py index 5e83739..2f9f685 100644 --- a/resources/pychromecast/examples/supla_example.py +++ b/resources/pychromecast/examples/supla_example.py @@ -23,7 +23,7 @@ PROGRAM = "aamulypsy" -result = requests.get("https://www.supla.fi/ohjelmat/{}".format(PROGRAM)) +result = requests.get(f"https://www.supla.fi/ohjelmat/{PROGRAM}") soup = BeautifulSoup(result.content) MEDIA_ID = soup.select('a[title*="Koko Shitti"]')[0]["href"].split("/")[-1] print(MEDIA_ID) @@ -33,7 +33,7 @@ chromecasts, browser = pychromecast.get_listed_chromecasts(friendly_names=[CAST_NAME]) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(CAST_NAME)) + print(f'No chromecast with name "{CAST_NAME}" discovered') sys.exit(1) cast = chromecasts[0] diff --git a/resources/pychromecast/examples/yleareena_example.py b/resources/pychromecast/examples/yleareena_example.py index 13888a1..9aa2c3a 100644 --- a/resources/pychromecast/examples/yleareena_example.py +++ b/resources/pychromecast/examples/yleareena_example.py @@ -63,7 +63,7 @@ def get_kaltura_id(program_id): httpclient = HttpClient(None) stream_filters = StreamFilters() - url = "https://areena.yle.fi/{}".format(program_id) + url = f"https://areena.yle.fi/{program_id}" extractor = extractor_factory(url, stream_filters, language_chooser, httpclient) pid = extractor.program_id_from_url(url) @@ -77,7 +77,7 @@ def get_kaltura_id(program_id): friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] diff --git a/resources/pychromecast/examples/youtube_example.py b/resources/pychromecast/examples/youtube_example.py index 664224a..9fffb89 100644 --- a/resources/pychromecast/examples/youtube_example.py +++ b/resources/pychromecast/examples/youtube_example.py @@ -51,7 +51,7 @@ friendly_names=[args.cast], known_hosts=args.known_host ) if not chromecasts: - print('No chromecast with name "{}" discovered'.format(args.cast)) + print(f'No chromecast with name "{args.cast}" discovered') sys.exit(1) cast = chromecasts[0] diff --git a/resources/pychromecast/pychromecast/__init__.py b/resources/pychromecast/pychromecast/__init__.py index e673f60..5594b2e 100644 --- a/resources/pychromecast/pychromecast/__init__.py +++ b/resources/pychromecast/pychromecast/__init__.py @@ -16,14 +16,16 @@ DISCOVER_TIMEOUT, CastBrowser, CastListener, # Deprecated + ServiceInfo, SimpleCastListener, discover_chromecasts, start_discovery, stop_discovery, ) -from .dial import get_device_status, DeviceStatus -from .const import CAST_MANUFACTURERS, CAST_TYPES, CAST_TYPE_CHROMECAST +from .dial import get_cast_type +from .const import CAST_TYPE_CHROMECAST, SERVICE_TYPE_HOST from .controllers.media import STREAM_TYPE_BUFFERED # noqa: F401 +from .models import CastInfo __all__ = ("__version__", "__version_info__", "get_chromecasts", "Chromecast") __version_info__ = ("0", "7", "6") @@ -41,20 +43,14 @@ def get_chromecast_from_host(host, tries=None, retry_wait=None, timeout=None): # the primary source and the remaining will be fetched # later on. ip_address, port, uuid, model_name, friendly_name = host - _LOGGER.debug("_get_chromecast_from_host %s", host) - cast_type = CAST_TYPES.get(model_name.lower(), CAST_TYPE_CHROMECAST) - manufacturer = CAST_MANUFACTURERS.get(model_name.lower(), "Google Inc.") - device = DeviceStatus( - friendly_name=friendly_name, - model_name=model_name, - manufacturer=manufacturer, - uuid=uuid, - cast_type=cast_type, + _LOGGER.debug("get_chromecast_from_host %s", host) + port = port or 8009 + services = [ServiceInfo(SERVICE_TYPE_HOST, (ip_address, port))] + cast_info = CastInfo( + services, uuid, model_name, friendly_name, ip_address, port, None, None ) return Chromecast( - host=ip_address, - port=port, - device=device, + cast_info=cast_info, tries=tries, timeout=timeout, retry_wait=retry_wait, @@ -69,27 +65,12 @@ def get_chromecast_from_cast_info( cast_info, zconf, tries=None, retry_wait=None, timeout=None ): """Creates a Chromecast object from a zeroconf service.""" - # Build device status from the CastInfo, this - # information is the primary source and the remaining will be - # fetched later on. - services = cast_info.services - _LOGGER.debug("get_chromecast_from_cast_info %s", services) - cast_type = CAST_TYPES.get(cast_info.model_name.lower(), CAST_TYPE_CHROMECAST) - manufacturer = CAST_MANUFACTURERS.get(cast_info.model_name.lower(), "Google Inc.") - device = DeviceStatus( - friendly_name=cast_info.friendly_name, - model_name=cast_info.model_name, - manufacturer=manufacturer, - uuid=cast_info.uuid, - cast_type=cast_type, - ) + _LOGGER.debug("get_chromecast_from_cast_info %s", cast_info) return Chromecast( - host=None, - device=device, + cast_info=cast_info, tries=tries, timeout=timeout, retry_wait=retry_wait, - services=services, zconf=zconf, ) @@ -175,8 +156,7 @@ def get_chromecast_from_uuid(uuid): return (list(cc_list.values()), browser) -# pylint: disable=too-many-locals -def get_chromecasts( +def get_chromecasts( # pylint: disable=too-many-locals tries=None, retry_wait=None, timeout=None, @@ -269,12 +249,7 @@ class Chromecast: """ Class to interface with a ChromeCast. - :param host: The host to connect to. - :param port: The port to use when connecting to the device, set to None to - use the default of 8009. Special devices such as Cast Groups - may return a different port number so we need to use that. - :param device: DeviceStatus with initial information for the device. - :type device: pychromecast.dial.DeviceStatus + :param cast_info: CastInfo with information for the device. :param tries: Number of retries to perform if the connection fails. None for infinite retries. :param timeout: A floating point number specifying the socket timeout in @@ -282,66 +257,30 @@ class Chromecast: :param retry_wait: A floating point number specifying how many seconds to wait between each retry. None means to use the default which is 5 seconds. - :param services: A set of mDNS or host services to try to connect to. If present, - parameters host and port are ignored and host and port are - instead resolved through mDNS. The list of services may be - modified, for example if speaker group leadership is handed - over. SocketClient will catch modifications to the list when - attempting reconnect. - :param zconf: A zeroconf instance, needed if a list of services is passed. + :param zconf: A zeroconf instance, needed if a the services if cast info includes + mDNS services. The zeroconf instance may be obtained from the browser returned by pychromecast.start_discovery(). """ - def __init__(self, host, port=None, device=None, **kwargs): - tries = kwargs.pop("tries", None) - timeout = kwargs.pop("timeout", None) - retry_wait = kwargs.pop("retry_wait", None) - services = kwargs.pop("services", None) - zconf = kwargs.pop("zconf", None) - + def __init__( + self, cast_info, *, tries=None, timeout=None, retry_wait=None, zconf=None + ): self.logger = logging.getLogger(__name__) - # Resolve host to IP address - self._services = services - - self.logger.info("Querying device status") - self.device = device - if device: - dev_status = get_device_status(host, services, zconf) - if dev_status: - # Values from `device` have priority over `dev_status` - # as they come from the dial information. - # `dev_status` may add extra information such as `manufacturer` - # which dial does not supply - self.device = DeviceStatus( - friendly_name=(device.friendly_name or dev_status.friendly_name), - model_name=(device.model_name or dev_status.model_name), - manufacturer=(device.manufacturer or dev_status.manufacturer), - uuid=(device.uuid or dev_status.uuid), - cast_type=(device.cast_type or dev_status.cast_type), - ) - else: - self.device = device - else: - self.device = get_device_status(host, services, zconf) - - if not self.device: - raise ChromecastConnectionError( # noqa: F405 - "Could not connect to {}:{}".format(host, port or 8009) - ) + if not cast_info.cast_type: + cast_info = get_cast_type(cast_info, zconf) + self.cast_info = cast_info self.status = None self.status_event = threading.Event() self.socket_client = socket_client.SocketClient( - host, - port=port, - cast_type=self.device.cast_type, + cast_type=cast_info.cast_type, tries=tries, timeout=timeout, retry_wait=retry_wait, - services=services, + services=cast_info.services, zconf=zconf, ) @@ -364,8 +303,8 @@ def __init__(self, host, port=None, device=None, **kwargs): @property def ignore_cec(self): """Returns whether the CEC data should be ignored.""" - return self.device is not None and any( - fnmatch.fnmatchcase(self.device.friendly_name, pattern) + return self.cast_info.friendly_name is not None and any( + fnmatch.fnmatchcase(self.cast_info.friendly_name, pattern) for pattern in IGNORE_CEC ) @@ -385,7 +324,7 @@ def is_idle(self): @property def uuid(self): """Returns the unique UUID of the Chromecast device.""" - return self.device.uuid + return self.cast_info.uuid @property def name(self): @@ -393,17 +332,17 @@ def name(self): Returns the friendly name set for the Chromecast device. This is the name that the end-user chooses for the cast device. """ - return self.device.friendly_name + return self.cast_info.friendly_name @property def uri(self): """Returns the device URI (ip:port)""" - return "{}:{}".format(self.socket_client.host, self.socket_client.port) + return f"{self.socket_client.host}:{self.socket_client.port}" @property def model_name(self): """Returns the model name of the Chromecast device.""" - return self.device.model_name + return self.cast_info.model_name @property def cast_type(self): @@ -416,7 +355,7 @@ def cast_type(self): :rtype: str """ - return self.device.cast_type + return self.cast_info.cast_type @property def app_id(self): @@ -457,9 +396,7 @@ def volume_up(self, delta=0.1): """ if delta <= 0: - raise ValueError( - "volume delta must be greater than zero, not {}".format(delta) - ) + raise ValueError(f"volume delta must be greater than zero, not {delta}") return self.set_volume(self.status.volume_level + delta) def volume_down(self, delta=0.1): @@ -467,9 +404,7 @@ def volume_down(self, delta=0.1): Returns the new volume. """ if delta <= 0: - raise ValueError( - "volume delta must be greater than zero, not {}".format(delta) - ) + raise ValueError(f"volume delta must be greater than zero, not {delta}") return self.set_volume(self.status.volume_level - delta) def wait(self, timeout=None): @@ -535,16 +470,14 @@ def __del__(self): pass def __repr__(self): - txt = "Chromecast({!r}, port={!r}, device={!r})".format( - self.socket_client.host, self.socket_client.port, self.device + return ( + f"Chromecast({self.socket_client.host!r}, port={self.socket_client.port!r}, " + f"cast_info={self.cast_info!r})" ) - return txt def __unicode__(self): - return "Chromecast({}, {}, {}, {}, {})".format( - self.socket_client.host, - self.socket_client.port, - self.device.friendly_name, - self.device.model_name, - self.device.manufacturer, + return ( + f"Chromecast({self.socket_client.host}, {self.socket_client.port}, " + f"{self.cast_info.friendly_name}, {self.cast_info.model_name}, " + f"{self.cast_info.manufacturer})" ) diff --git a/resources/pychromecast/pychromecast/config.py b/resources/pychromecast/pychromecast/config.py index 7033fbf..0704b78 100644 --- a/resources/pychromecast/pychromecast/config.py +++ b/resources/pychromecast/pychromecast/config.py @@ -11,7 +11,8 @@ APP_PLEX = "06ee44ee-e7e3-4249-83b6-f5d0b6f07f34_1" APP_DASHCAST = "84912283" APP_SPOTIFY = "CC32E753" -APP_HOME_ASSISTANT = "B12CE3CA" +APP_HOMEASSISTANT_LOVELACE = "A078F6B0" +APP_HOMEASSISTANT_MEDIA = "B45F4572" APP_SUPLA = "A41B766D" APP_YLEAREENA = "A9BCCB7C" APP_BUBBLEUPNP = "3927FA74" @@ -39,9 +40,7 @@ def get_app_config(app_id): """Get specific configuration for 'app_id'.""" try: req = requests.get( - ("https://clients3.google.com/cast/chromecast/device/app?a={}").format( - app_id - ) + f"https://clients3.google.com/cast/chromecast/device/app?a={app_id}" ) return json.loads(req.text[4:]) if req.status_code == 200 else {} diff --git a/resources/pychromecast/pychromecast/const.py b/resources/pychromecast/pychromecast/const.py index 431f514..cff9f55 100644 --- a/resources/pychromecast/pychromecast/const.py +++ b/resources/pychromecast/pychromecast/const.py @@ -8,22 +8,6 @@ # Cast Audio group device, supports only audio CAST_TYPE_GROUP = "group" -MF_GOOGLE = "Google Inc." - -CAST_TYPES = { - "chromecast": CAST_TYPE_CHROMECAST, - "eureka dongle": CAST_TYPE_CHROMECAST, - "chromecast audio": CAST_TYPE_AUDIO, - "google home": CAST_TYPE_AUDIO, - "google home mini": CAST_TYPE_AUDIO, - "google nest mini": CAST_TYPE_AUDIO, - "nest audio": CAST_TYPE_AUDIO, - "google cast group": CAST_TYPE_GROUP, -} - -# Known models not manufactured by Google -CAST_MANUFACTURERS = {} - SERVICE_TYPE_HOST = "host" SERVICE_TYPE_MDNS = "mdns" diff --git a/resources/pychromecast/pychromecast/controllers/__init__.py b/resources/pychromecast/pychromecast/controllers/__init__.py index bbe1b5f..f968de6 100644 --- a/resources/pychromecast/pychromecast/controllers/__init__.py +++ b/resources/pychromecast/pychromecast/controllers/__init__.py @@ -38,12 +38,14 @@ def is_active(self): and self.namespace in self._socket_client.app_namespaces ) - def launch(self, callback_function=None): + def launch(self, callback_function=None, force_launch=False): """If set, launches app related to the controller.""" self._check_registered() self._socket_client.receiver_controller.launch_app( - self.supporting_app_id, callback_function=callback_function + self.supporting_app_id, + force_launch=force_launch, + callback_function=callback_function, ) def registered(self, socket_client): @@ -83,9 +85,7 @@ def send_message(self, data, inc_session_id=False, callback_function=None): return raise UnsupportedNamespace( - ("Namespace {} is not supported by running application.").format( - self.namespace - ) + f"Namespace {self.namespace} is not supported by running application." ) self.send_message_nocheck(data, inc_session_id, callback_function) diff --git a/resources/pychromecast/pychromecast/controllers/homeassistant.py b/resources/pychromecast/pychromecast/controllers/homeassistant.py index 272fbfa..a82538e 100644 --- a/resources/pychromecast/pychromecast/controllers/homeassistant.py +++ b/resources/pychromecast/pychromecast/controllers/homeassistant.py @@ -1,7 +1,10 @@ """ Controller to interface with Home Assistant """ -from ..config import APP_HOME_ASSISTANT +import threading + +from ..config import APP_HOMEASSISTANT_LOVELACE +from ..error import PyChromecastError from . import BaseController @@ -17,7 +20,7 @@ def __init__( client_id, refresh_token, app_namespace=APP_NAMESPACE, - app_id=APP_HOME_ASSISTANT, + app_id=APP_HOMEASSISTANT_LOVELACE, ): super().__init__(app_namespace, app_id) self.hass_url = hass_url @@ -28,8 +31,11 @@ def __init__( # showDemo: boolean; # hassUrl?: string; # lovelacePath?: string | number | null; + # urlPath?: string | null; # } self.status = None + self._hass_connecting_event = threading.Event() + self._hass_connecting_event.set() self._on_connect = [] @property @@ -49,6 +55,7 @@ def channel_connected(self): def channel_disconnected(self): """Called when a channel is disconnected.""" self.status = None + self._hass_connecting_event.set() def receive_message(self, _message, data: dict): """Called when a message is received.""" @@ -60,6 +67,7 @@ def receive_message(self, _message, data: dict): return True # We just got connected, call the callbacks. + self._hass_connecting_event.set() while self._on_connect: self._on_connect.pop()() @@ -67,17 +75,33 @@ def receive_message(self, _message, data: dict): return False - def connect_hass(self, callback_function=None): + def _connect_hass(self, callback_function=None): """Connect to Home Assistant.""" self._on_connect.append(callback_function) - self.send_message( - { - "type": "connect", - "refreshToken": self.refresh_token, - "clientId": self.client_id, - "hassUrl": self.hass_url, - } - ) + + if not self._hass_connecting_event.is_set(): + return + + self._hass_connecting_event.clear() + try: + self.send_message( + { + "type": "connect", + "refreshToken": self.refresh_token, + "clientId": self.client_id, + "hassUrl": self.hass_url, + } + ) + except Exception: # pylint: disable=broad-except + self._hass_connecting_event.set() + raise + + self._hass_connecting_event.wait(10) + try: + if not self._hass_connecting_event.is_set(): + raise PyChromecastError() + finally: + self._hass_connecting_event.set() def show_demo(self): """Show the demo.""" @@ -85,23 +109,23 @@ def show_demo(self): def get_status(self, callback_function=None): """Get status of Home Assistant Cast.""" - self.send_connected_message( + self._send_connected_message( {"type": "get_status"}, callback_function=callback_function ) def show_lovelace_view(self, view_path, url_path=None, callback_function=None): """Show a Lovelace UI.""" - self.send_connected_message( + self._send_connected_message( {"type": "show_lovelace_view", "viewPath": view_path, "urlPath": url_path}, callback_function=callback_function, ) - def send_connected_message(self, data, callback_function=None): + def _send_connected_message(self, data, callback_function=None): """Send a message to a connected Home Assistant Cast""" if self.hass_connected: self.send_message_nocheck(data, callback_function=callback_function) return - self.connect_hass( + self._connect_hass( lambda: self.send_message_nocheck(data, callback_function=callback_function) ) diff --git a/resources/pychromecast/pychromecast/controllers/homeassistant_media.py b/resources/pychromecast/pychromecast/controllers/homeassistant_media.py new file mode 100644 index 0000000..7efb40e --- /dev/null +++ b/resources/pychromecast/pychromecast/controllers/homeassistant_media.py @@ -0,0 +1,19 @@ +""" +Simple Controller to use the Home Assistant Media Player Cast App as a media controller. +""" + +from ..config import APP_HOMEASSISTANT_MEDIA +from .media import MediaController + + +class HomeAssistantMediaController(MediaController): + """Controller to interact with HomeAssistantMedia app namespace.""" + + def __init__(self): + super().__init__() + self.app_id = APP_HOMEASSISTANT_MEDIA + self.supporting_app_id = APP_HOMEASSISTANT_MEDIA + + def quick_play(self, media_id=None, media_type="video/mp4", **kwargs): + """Quick Play""" + self.play_media(media_id, media_type, **kwargs) diff --git a/resources/pychromecast/pychromecast/controllers/media.py b/resources/pychromecast/pychromecast/controllers/media.py index 308d541..03cf5f2 100644 --- a/resources/pychromecast/pychromecast/controllers/media.py +++ b/resources/pychromecast/pychromecast/controllers/media.py @@ -116,9 +116,9 @@ def metadata_type(self): @property def player_is_playing(self): """Return True if player is PLAYING.""" - return ( - self.player_state == MEDIA_PLAYER_STATE_PLAYING - or self.player_state == MEDIA_PLAYER_STATE_BUFFERING + return self.player_state in ( + MEDIA_PLAYER_STATE_PLAYING, + MEDIA_PLAYER_STATE_BUFFERING, ) @property @@ -306,7 +306,7 @@ def __repr__(self): "supports_skip_backward": self.supports_skip_backward, } info.update(self.__dict__) - return "".format(info) + return f"" class MediaStatusListener(abc.ABC): @@ -489,7 +489,7 @@ def _fire_status_changed(self): except Exception: # pylint: disable=broad-except _LOGGER.exception("Exception thrown when calling media status callback") - def play_media( + def play_media( # pylint: disable=too-many-locals self, url, content_type, @@ -535,7 +535,7 @@ def play_media( https://developers.google.com/cast/docs/reference/messages#MediaData https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages.MediaInformation """ - # pylint: disable=too-many-locals + def app_launched_callback(): """Plays media after chromecast has switched to requested app.""" self._send_start_play_media( @@ -558,7 +558,7 @@ def app_launched_callback(): receiver_ctrl = self._socket_client.receiver_controller receiver_ctrl.launch_app(self.app_id, callback_function=app_launched_callback) - def _send_start_play_media( + def _send_start_play_media( # pylint: disable=too-many-locals self, url, content_type, @@ -575,7 +575,6 @@ def _send_start_play_media( enqueue=False, media_info=None, ): - # pylint: disable=too-many-locals media_info = media_info or {} media = { "contentId": url, @@ -595,6 +594,12 @@ def _send_start_play_media( media["metadata"]["images"] = [] media["metadata"]["images"].append({"url": thumb}) + + # Need to set metadataType if not specified + # https://developers.google.com/cast/docs/reference/messages#MediaInformation + if media["metadata"] and "metadataType" not in media["metadata"]: + media["metadata"]["metadataType"] = METADATA_TYPE_GENERIC + if subtitles: sub_msg = [ { @@ -604,7 +609,7 @@ def _send_start_play_media( "subtype": "SUBTITLES", "type": "TEXT", "trackContentType": subtitles_mime, - "name": "{} - {} Subtitle".format(subtitles_lang, subtitle_id), + "name": f"{subtitles_lang} - {subtitle_id} Subtitle", } ] media["tracks"] = sub_msg diff --git a/resources/pychromecast/pychromecast/controllers/multizone.py b/resources/pychromecast/pychromecast/controllers/multizone.py index 1199b92..32a3ccb 100644 --- a/resources/pychromecast/pychromecast/controllers/multizone.py +++ b/resources/pychromecast/pychromecast/controllers/multizone.py @@ -65,9 +65,9 @@ def new_connection_status(self, conn_status): """Handle reception of a new ConnectionStatus.""" if conn_status.status == CONNECTION_STATUS_CONNECTED: self._mz.update_members() - if ( - conn_status.status == CONNECTION_STATUS_DISCONNECTED - or conn_status.status == CONNECTION_STATUS_LOST + if conn_status.status in ( + CONNECTION_STATUS_DISCONNECTED, + CONNECTION_STATUS_LOST, ): self._mz.reset_members() diff --git a/resources/pychromecast/pychromecast/controllers/plex.py b/resources/pychromecast/pychromecast/controllers/plex.py index 6d61b76..f610522 100644 --- a/resources/pychromecast/pychromecast/controllers/plex.py +++ b/resources/pychromecast/pychromecast/controllers/plex.py @@ -56,7 +56,7 @@ def media_to_chromecast_command( playQueueID=None, startItem=None, version="1.10.1.4602", - **kwargs + **kwargs, ): # pylint: disable=invalid-name, too-many-locals, protected-access """Create the message that chromecast requires. Use pass of plexapi media object or set all the needed kwargs manually. See the code for what to set. @@ -149,7 +149,7 @@ def media_to_chromecast_command( "accessToken": token, "user": {"username": username}, }, - "containerKey": "/playQueues/%s?own=1&window=200" % playQueueID, + "containerKey": f"/playQueues/{playQueueID}?own=1&window=200", }, "autoplay": autoplay, "currentTime": currentTime, @@ -311,9 +311,7 @@ def volume_up(self, delta=0.1): Returns the new volume. """ if delta <= 0: - raise ValueError( - "volume delta must be greater than zero, not {}".format(delta) - ) + raise ValueError(f"volume delta must be greater than zero, not {delta}") return self.set_volume(self.status.volume_level + delta) def volume_down(self, delta=0.1): @@ -321,9 +319,7 @@ def volume_down(self, delta=0.1): Returns the new volume. """ if delta <= 0: - raise ValueError( - "volume delta must be greater than zero, not {}".format(delta) - ) + raise ValueError(f"volume delta must be greater than zero, not {delta}") return self.set_volume(self.status.volume_level - delta) def mute(self, status=None): diff --git a/resources/pychromecast/pychromecast/controllers/receiver.py b/resources/pychromecast/pychromecast/controllers/receiver.py index 467dfde..d298483 100644 --- a/resources/pychromecast/pychromecast/controllers/receiver.py +++ b/resources/pychromecast/pychromecast/controllers/receiver.py @@ -5,7 +5,6 @@ import abc from collections import namedtuple -import threading from ..const import ( CAST_TYPE_AUDIO, @@ -82,7 +81,6 @@ def __init__(self, cast_type=CAST_TYPE_CHROMECAST): self.launch_failure = None self.app_to_launch = None self.cast_type = cast_type - self.app_launch_event = threading.Event() self.app_launch_event_function = None self._status_listeners = [] @@ -155,7 +153,6 @@ def _send_launch_message(self, app_id, force_launch=False, callback_function=Fal self.logger.info("Receiver:Launching app %s", app_id) self.app_to_launch = app_id - self.app_launch_event.clear() self.app_launch_event_function = callback_function self.launch_failure = None @@ -235,7 +232,6 @@ def _process_get_status(self, data): if is_new_app and self.app_to_launch == self.app_id: self.app_to_launch = None - self.app_launch_event.set() if self.app_launch_event_function: self.logger.debug("Start app_launch_event_function...") self.app_launch_event_function() @@ -272,7 +268,6 @@ def _process_launch_error(self, data): if self.app_to_launch: self.app_to_launch = None - self.app_launch_event.set() self.logger.debug("Launch status: %s", launch_failure) @@ -291,6 +286,5 @@ def tear_down(self): self.status = None self.launch_failure = None self.app_to_launch = None - self.app_launch_event.clear() self._status_listeners[:] = [] diff --git a/resources/pychromecast/pychromecast/controllers/yleareena.py b/resources/pychromecast/pychromecast/controllers/yleareena.py index cf95d8e..fe32d1d 100644 --- a/resources/pychromecast/pychromecast/controllers/yleareena.py +++ b/resources/pychromecast/pychromecast/controllers/yleareena.py @@ -14,7 +14,7 @@ def __init__(self): self.app_id = APP_YLEAREENA self.supporting_app_id = APP_YLEAREENA - def play_areena_media( + def play_areena_media( # pylint: disable=too-many-locals self, kaltura_id, audio_language="", @@ -29,7 +29,6 @@ def play_areena_media( And finding the kaltura player which has an id of yle-kaltura-player3430579305188-29-0_whwjqpry In this case the kaltura id is 0_whwjqpry """ - # pylint: disable=too-many-locals msg = { "media": { "streamType": stream_type, diff --git a/resources/pychromecast/pychromecast/controllers/youtube.py b/resources/pychromecast/pychromecast/controllers/youtube.py index e45649b..4f929a1 100644 --- a/resources/pychromecast/pychromecast/controllers/youtube.py +++ b/resources/pychromecast/pychromecast/controllers/youtube.py @@ -2,6 +2,7 @@ Controller to interface with the YouTube-app. Use the media controller to play, pause etc. """ +import logging import threading from casttube import YouTubeSession @@ -14,6 +15,7 @@ TYPE_GET_SCREEN_ID = "getMdxSessionStatus" TYPE_STATUS = "mdxSessionStatus" ATTR_SCREEN_ID = "screenId" +_LOGGER = logging.getLogger(__name__) class YouTubeController(BaseController): @@ -86,7 +88,9 @@ def update_screen_id(self): self.send_message({MESSAGE_TYPE: TYPE_GET_SCREEN_ID}) except UnsupportedNamespace: pass - self.status_update_event.wait() + status = self.status_update_event.wait(10) + if not status: + _LOGGER.warning("Failed to update screen_id") self.status_update_event.clear() def receive_message(self, _message, data: dict): diff --git a/resources/pychromecast/pychromecast/dial.py b/resources/pychromecast/pychromecast/dial.py index dd045aa..21da3ce 100644 --- a/resources/pychromecast/pychromecast/dial.py +++ b/resources/pychromecast/pychromecast/dial.py @@ -11,7 +11,13 @@ import zeroconf -from .const import CAST_TYPE_CHROMECAST, CAST_TYPES, SERVICE_TYPE_HOST +from .const import ( + CAST_TYPE_AUDIO, + CAST_TYPE_CHROMECAST, + CAST_TYPE_GROUP, + SERVICE_TYPE_HOST, +) +from .models import ZEROCONF_ERRORS, CastInfo, ServiceInfo XML_NS_UPNP_DEVICE = "{urn:schemas-upnp-org:device-1-0}" @@ -36,8 +42,15 @@ def get_host_from_service(service, zconf): service, service_info, ) - except IOError: - pass + else: + _LOGGER.debug( + "get_info_from_service failed to resolve service %s", + service, + ) + except ZEROCONF_ERRORS: + # We do not catch zeroconf.NotRunningException as it's + # an unrecoverable error. + _LOGGER.debug("get_info_from_service raised:", exc_info=True) return _get_host_from_zc_service_info(service_info) + (service_info,) @@ -58,20 +71,14 @@ def _get_host_from_zc_service_info(service_info: zeroconf.ServiceInfo): return (host, port) -def _get_status(host, services, zconf, path, secure, timeout, context): - """ - :param host: Hostname or ip to fetch status from - :type host: str - :return: The device status as a named tuple. - :rtype: pychromecast.dial.DeviceStatus or None - """ +def _get_status(services, zconf, path, secure, timeout, context): + """Query a cast device via http(s).""" - if not host: - for service in services.copy(): - host, _, _ = get_host_from_service(service, zconf) - if host: - _LOGGER.debug("Resolved service %s to %s", service, host) - break + for service in services.copy(): + host, _, _ = get_host_from_service(service, zconf) + if host: + _LOGGER.debug("Resolved service %s to %s", service, host) + break headers = {"content-type": "application/json"} @@ -97,7 +104,58 @@ def get_ssl_context(): return context -def get_device_status(host, services=None, zconf=None, timeout=10, context=None): +def get_cast_type(cast_info, zconf=None, timeout=30, context=None): + """ + :param cast_info: cast_info + :return: An updated cast_info with filled cast_type + :rtype: pychromecast.models.CastInfo + """ + cast_type = CAST_TYPE_CHROMECAST + manufacturer = "Unknown manufacturer" + if cast_info.port != 8009: + cast_type = CAST_TYPE_GROUP + manufacturer = "Google Inc." + else: + try: + display_supported = True + status = _get_status( + cast_info.services, + zconf, + "/setup/eureka_info?params=device_info,name", + True, + timeout, + context, + ) + if "device_info" in status: + device_info = status["device_info"] + + capabilities = device_info.get("capabilities", {}) + display_supported = capabilities.get("display_supported", True) + manufacturer = device_info.get("manufacturer", manufacturer) + + if not display_supported: + cast_type = CAST_TYPE_AUDIO + _LOGGER.debug("cast type: %s, manufacturer: %s", cast_type, manufacturer) + + except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError): + _LOGGER.warning("Failed to determine cast type") + cast_type = CAST_TYPE_CHROMECAST + + return CastInfo( + cast_info.services, + cast_info.uuid, + cast_info.model_name, + cast_info.friendly_name, + cast_info.host, + cast_info.port, + cast_type, + manufacturer, + ) + + +def get_device_info( # pylint: disable=too-many-locals + host, services=None, zconf=None, timeout=30, context=None +): """ :param host: Hostname or ip to fetch status from :type host: str @@ -106,32 +164,51 @@ def get_device_status(host, services=None, zconf=None, timeout=10, context=None) """ try: + if services is None: + services = [ServiceInfo(SERVICE_TYPE_HOST, (host, 8009))] status = _get_status( - host, services, zconf, - "/setup/eureka_info?options=detail", + "/setup/eureka_info?params=device_info,name", True, timeout, context, ) + cast_type = CAST_TYPE_CHROMECAST + display_supported = True friendly_name = status.get("name", "Unknown Chromecast") - model_name = "Unknown model name" manufacturer = "Unknown manufacturer" - if "detail" in status: - model_name = status["detail"].get("model_name", model_name) - manufacturer = status["detail"].get("manufacturer", manufacturer) + model_name = "Unknown model name" + multizone_supported = False + udn = None - udn = status.get("ssdp_udn", None) + if "device_info" in status: + device_info = status["device_info"] - cast_type = CAST_TYPES.get(model_name.lower(), CAST_TYPE_CHROMECAST) + capabilities = device_info.get("capabilities", {}) + display_supported = capabilities.get("display_supported", True) + multizone_supported = capabilities.get("multizone_supported", True) + friendly_name = device_info.get("name", friendly_name) + model_name = device_info.get("model_name", model_name) + manufacturer = device_info.get("manufacturer", manufacturer) + udn = device_info.get("ssdp_udn", None) + + if not display_supported: + cast_type = CAST_TYPE_AUDIO uuid = None if udn: uuid = UUID(udn.replace("-", "")) - return DeviceStatus(friendly_name, model_name, manufacturer, uuid, cast_type) + return DeviceStatus( + friendly_name, + model_name, + manufacturer, + uuid, + cast_type, + multizone_supported, + ) except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError): return None @@ -158,7 +235,7 @@ def _get_group_info(host, group): return MultizoneInfo(name, uuid, leader_host, leader_port) -def get_multizone_status(host, services=None, zconf=None, timeout=10, context=None): +def get_multizone_status(host, services=None, zconf=None, timeout=30, context=None): """ :param host: Hostname or ip to fetch status from :type host: str @@ -167,8 +244,9 @@ def get_multizone_status(host, services=None, zconf=None, timeout=10, context=No """ try: + if services is None: + services = [ServiceInfo(SERVICE_TYPE_HOST, (host, 8009))] status = _get_status( - host, services, zconf, "/setup/eureka_info?params=multizone", @@ -198,5 +276,13 @@ def get_multizone_status(host, services=None, zconf=None, timeout=10, context=No MultizoneStatus = namedtuple("MultizoneStatus", ["dynamic_groups", "groups"]) DeviceStatus = namedtuple( - "DeviceStatus", ["friendly_name", "model_name", "manufacturer", "uuid", "cast_type"] + "DeviceStatus", + [ + "friendly_name", + "model_name", + "manufacturer", + "uuid", + "cast_type", + "multizone_supported", + ], ) diff --git a/resources/pychromecast/pychromecast/discovery.py b/resources/pychromecast/pychromecast/discovery.py index e2718f2..625d330 100644 --- a/resources/pychromecast/pychromecast/discovery.py +++ b/resources/pychromecast/pychromecast/discovery.py @@ -1,6 +1,5 @@ """Discovers Chromecasts on the network using mDNS/zeroconf.""" import abc -from collections import namedtuple import functools import itertools import logging @@ -10,15 +9,22 @@ import zeroconf -from .const import SERVICE_TYPE_HOST, SERVICE_TYPE_MDNS -from .dial import get_device_status, get_multizone_status, get_ssl_context +from .const import ( + CAST_TYPE_AUDIO, + CAST_TYPE_GROUP, + SERVICE_TYPE_HOST, + SERVICE_TYPE_MDNS, +) +from .dial import get_device_info, get_multizone_status, get_ssl_context +from .models import ZEROCONF_ERRORS, CastInfo, ServiceInfo DISCOVER_TIMEOUT = 5 -ServiceInfo = namedtuple("ServiceInfo", ["type", "data"]) -CastInfo = namedtuple( - "CastInfo", ["services", "uuid", "model_name", "friendly_name", "host", "port"] -) +# Models matching this list will only be polled once by the HostBrowser +HOST_BROWSER_BLOCKED_MODEL_PREFIXES = [ + "HK", # Harman Kardon speakers crash if polled: https://github.com/home-assistant/core/issues/52020 + "JBL", # JBL speakers crash if polled: https://github.com/home-assistant/core/issues/52020 +] _LOGGER = logging.getLogger(__name__) @@ -53,6 +59,20 @@ def update_cast(self, uuid, service): """ +def _is_blocked_from_host_browser(item, block_list, item_type): + for blocked_prefix in block_list: + if item.startswith(blocked_prefix): + _LOGGER.debug("%s %s is blocked from host based polling", item_type, item) + return True + return False + + +def _is_model_blocked_from_host_browser(model): + return _is_blocked_from_host_browser( + model, HOST_BROWSER_BLOCKED_MODEL_PREFIXES, "Model" + ) + + class SimpleCastListener(AbstractCastListener): """Helper for backwards compatibility.""" @@ -119,6 +139,7 @@ def add_service(self, zconf, typ, name): _LOGGER.debug("add_service %s, %s", typ, name) self._add_update_service(zconf, typ, name, self._cast_listener.add_cast) + # pylint: disable-next=too-many-locals def _add_update_service(self, zconf, typ, name, callback): """Add or update a service.""" service = None @@ -129,9 +150,15 @@ def _add_update_service(self, zconf, typ, name, callback): while service is None and tries < 4: try: service = zconf.get_service_info(typ, name) - except IOError: + except ZEROCONF_ERRORS: # If the zeroconf fails to receive the necessary data we abort # adding the service + # We do not catch zeroconf.NotRunningException as it's + # an unrecoverable error. + _LOGGER.debug( + "get_info_from_service failed to resolve service %s", + service, + ) break tries += 1 @@ -151,12 +178,11 @@ def get_value(key): host = addresses[0] if addresses else service.server # Store the host, in case mDNS stops working - if self._host_browser: - self._host_browser.add_hosts([host]) + self._host_browser.add_hosts([host]) + friendly_name = get_value("fn") model_name = get_value("md") uuid = get_value("id") - friendly_name = get_value("fn") if not uuid: _LOGGER.debug( @@ -180,16 +206,32 @@ def get_value(key): # Lock because the HostBrowser may also add or remove items with self._services_lock: + cast_type = CAST_TYPE_GROUP if service.port != 8009 else None + manufacturer = "Google Inc." if service.port != 8009 else None if uuid not in self._devices: self._devices[uuid] = CastInfo( - {service_info}, uuid, model_name, friendly_name, host, service.port + {service_info}, + uuid, + model_name, + friendly_name, + host, + service.port, + cast_type, + manufacturer, ) else: # Update stored information services = self._devices[uuid].services services.add(service_info) self._devices[uuid] = CastInfo( - services, uuid, model_name, friendly_name, host, service.port + services, + uuid, + model_name, + friendly_name, + host, + service.port, + cast_type, + manufacturer, ) callback(uuid, name) @@ -200,9 +242,10 @@ class HostStatus: def __init__(self): self.failcount = 0 + self.no_polling = False -HOSTLISTENER_CYCLE_TIME = 5 +HOSTLISTENER_CYCLE_TIME = 30 HOSTLISTENER_MAX_FAIL = 5 @@ -240,7 +283,7 @@ def update_hosts(self, known_hosts): for host in list(self._known_hosts.keys()): if host not in known_hosts: - _LOGGER.debug("Removied host %s", host) + _LOGGER.debug("Removed host %s", host) self._known_hosts.pop(host) def run(self): @@ -265,13 +308,18 @@ def _poll_hosts(self): uuids = [] if self.stop.is_set(): break - device_status = get_device_status(host, timeout=4, context=self._context) try: hoststatus = self._known_hosts[host] except KeyError: # The host has been removed by another thread continue + if hoststatus.no_polling: + # This host should not be polled + continue + + device_status = get_device_info(host, timeout=30, context=self._context) + if not device_status: hoststatus.failcount += 1 if hoststatus.failcount == HOSTLISTENER_MAX_FAIL: @@ -281,6 +329,18 @@ def _poll_hosts(self): ) continue + if ( + device_status.cast_type != CAST_TYPE_AUDIO + or _is_model_blocked_from_host_browser(device_status.model_name) + ): + # Polling causes frame drops on some Android TVs, + # https://github.com/home-assistant/core/issues/55435 + # Keep polling audio chromecasts to detect new speaker groups, but + # exclude some devices which crash when polled + # Note: This will not work well the IP is recycled to another cast + # device. + hoststatus.no_polling = True + # We got device_status, try to get multizone status, then update devices hoststatus.failcount = 0 devices.append( @@ -289,27 +349,39 @@ def _poll_hosts(self): device_status.friendly_name, device_status.model_name, device_status.uuid, + device_status.cast_type, + device_status.manufacturer, ) ) uuids.append(device_status.uuid) - multizone_status = get_multizone_status(host, context=self._context) - if not multizone_status: - return + multizone_status = ( + get_multizone_status(host, context=self._context) + if device_status.multizone_supported + else None + ) - for group in itertools.chain( - multizone_status.dynamic_groups, multizone_status.groups - ): - # Note: This is currently (2021-02) not working for dynamic_groups, the - # ports of dynamic groups are not present in the eureka_info reply. - if group.host and group.host not in self._known_hosts: - self.add_hosts([group.host]) - if group.port is None or group.host != host: - continue - devices.append( - (group.port, group.friendly_name, "Google Cast Group", group.uuid) - ) - uuids.append(group.uuid) + if multizone_status: + for group in itertools.chain( + multizone_status.dynamic_groups, multizone_status.groups + ): + # Note: This is currently (2021-02) not working for dynamic_groups, the + # ports of dynamic groups are not present in the eureka_info reply. + if group.host and group.host not in self._known_hosts: + self.add_hosts([group.host]) + if group.port is None or group.host != host: + continue + devices.append( + ( + group.port, + group.friendly_name, + "Google Cast Group", + group.uuid, + CAST_TYPE_GROUP, + "Google Inc.", + ) + ) + uuids.append(group.uuid) self._update_devices(host, devices, uuids) @@ -318,9 +390,23 @@ def _update_devices(self, host, devices, host_uuids): # Lock because the ZeroConfListener may also add or remove items with self._services_lock: - for (port, friendly_name, model_name, uuid) in devices: + for ( + port, + friendly_name, + model_name, + uuid, + cast_type, + manufacturer, + ) in devices: self._add_host_service( - host, port, friendly_name, model_name, uuid, callbacks + host, + port, + friendly_name, + model_name, + uuid, + callbacks, + cast_type, + manufacturer, ) for uuid in self._devices: @@ -336,7 +422,17 @@ def _update_devices(self, host, devices, host_uuids): for callback in callbacks: callback() - def _add_host_service(self, host, port, friendly_name, model_name, uuid, callbacks): + def _add_host_service( + self, + host, + port, + friendly_name, + model_name, + uuid, + callbacks, + cast_type, + manufacturer, + ): service_info = ServiceInfo(SERVICE_TYPE_HOST, (host, port)) callback = self._cast_listener.add_cast @@ -353,14 +449,28 @@ def _add_host_service(self, host, port, friendly_name, model_name, uuid, callbac if uuid not in self._devices: self._devices[uuid] = CastInfo( - {service_info}, uuid, model_name, friendly_name, host, port + {service_info}, + uuid, + model_name, + friendly_name, + host, + port, + cast_type, + manufacturer, ) else: # Update stored information services = self._devices[uuid].services services.add(service_info) self._devices[uuid] = CastInfo( - services, uuid, model_name, friendly_name, host, port + services, + uuid, + model_name, + friendly_name, + host, + port, + cast_type, + manufacturer, ) name = f"{host}:{port}" diff --git a/resources/pychromecast/pychromecast/models.py b/resources/pychromecast/pychromecast/models.py new file mode 100644 index 0000000..e5e653f --- /dev/null +++ b/resources/pychromecast/pychromecast/models.py @@ -0,0 +1,27 @@ +""" +Chromecast types +""" +import asyncio +from collections import namedtuple + +import zeroconf + +ZEROCONF_ERRORS = (IOError, asyncio.TimeoutError) +if hasattr(zeroconf, "EventLoopBlocked"): + # Added in zeroconf 0.37.0 + ZEROCONF_ERRORS = (*ZEROCONF_ERRORS, zeroconf.EventLoopBlocked) + +CastInfo = namedtuple( + "CastInfo", + [ + "services", + "uuid", + "model_name", + "friendly_name", + "host", + "port", + "cast_type", + "manufacturer", + ], +) +ServiceInfo = namedtuple("ServiceInfo", ["type", "data"]) diff --git a/resources/pychromecast/pychromecast/quick_play.py b/resources/pychromecast/pychromecast/quick_play.py index 3a97b14..1226801 100644 --- a/resources/pychromecast/pychromecast/quick_play.py +++ b/resources/pychromecast/pychromecast/quick_play.py @@ -3,10 +3,10 @@ from .controllers.youtube import YouTubeController from .controllers.supla import SuplaController from .controllers.yleareena import YleAreenaController -from .controllers.spotify import SpotifyController from .controllers.bubbleupnp import BubbleUPNPController from .controllers.bbciplayer import BbcIplayerController from .controllers.bbcsounds import BbcSoundsController +from .controllers.homeassistant_media import HomeAssistantMediaController def quick_play(cast, app_name, data): @@ -58,21 +58,17 @@ def quick_play(cast, app_name, data): controller = SuplaController() elif app_name == "yleareena": controller = YleAreenaController() - elif app_name == "spotify": - controller = SpotifyController() elif app_name == "bubbleupnp": controller = BubbleUPNPController() elif app_name == "bbciplayer": controller = BbcIplayerController() elif app_name == "bbcsounds": controller = BbcSoundsController() + elif app_name == "homeassistant_media": + controller = HomeAssistantMediaController() else: raise NotImplementedError() cast.register_handler(controller) - def app_launched_callback(): - """Plays media after chromecast has switched to requested app.""" - controller.quick_play(**data) - - controller.launch(callback_function=app_launched_callback) + controller.quick_play(**data) diff --git a/resources/pychromecast/pychromecast/socket_client.py b/resources/pychromecast/pychromecast/socket_client.py index f1e582c..d51eed1 100644 --- a/resources/pychromecast/pychromecast/socket_client.py +++ b/resources/pychromecast/pychromecast/socket_client.py @@ -24,7 +24,7 @@ from .controllers import BaseController from .controllers.media import MediaController from .controllers.receiver import ReceiverController -from .const import CAST_TYPE_CHROMECAST, MESSAGE_TYPE, REQUEST_ID, SESSION_ID +from .const import MESSAGE_TYPE, REQUEST_ID, SESSION_ID from .dial import get_host_from_service from .error import ( ChromecastConnectionError, @@ -97,11 +97,9 @@ def _message_to_string(message, data=None): if data is None: data = _dict_from_message_payload(message) - return "Message {} from {} to {}: {}".format( - message.namespace, - message.source_id, - message.destination_id, - data or message.payload_utf8, + return ( + f"Message {message.namespace} from {message.source_id} to " + f"{message.destination_id}: {data or message.payload_utf8}" ) @@ -141,7 +139,7 @@ def new_connection_status(self, status: ConnectionStatus): """Updated connection status.""" -# pylint: disable=too-many-instance-attributes +# pylint: disable-next=too-many-instance-attributes class SocketClient(threading.Thread): """ Class to interact with a Chromecast through a socket. @@ -170,13 +168,17 @@ class SocketClient(threading.Thread): pychromecast.start_discovery(). """ - def __init__(self, host, port=None, cast_type=CAST_TYPE_CHROMECAST, **kwargs): - tries = kwargs.pop("tries", None) - timeout = kwargs.pop("timeout", None) - retry_wait = kwargs.pop("retry_wait", None) - services = kwargs.pop("services", None) - zconf = kwargs.pop("zconf", None) - + # pylint: disable-next=too-many-arguments + def __init__( + self, + *, + cast_type, + tries, + timeout, + retry_wait, + services, + zconf, + ): super().__init__() self.daemon = True @@ -190,10 +192,11 @@ def __init__(self, host, port=None, cast_type=CAST_TYPE_CHROMECAST, **kwargs): self.tries = tries self.timeout = timeout or TIMEOUT_TIME self.retry_wait = retry_wait or RETRY_TIME - self.host = host - self.services = services or [None] + self.services = services self.zconf = zconf - self.port = port or 8009 + + self.host = "unknown" + self.port = 8009 self.source_id = "sender-0" self.stop = threading.Event() @@ -276,8 +279,7 @@ def mdns_backoff(service, retry): retry = retries.get( service, {"delay": self.retry_wait, "next_retry": now} ) - # If we're connecting to a named service, check if it's time - if service and now < retry["next_retry"]: + if now < retry["next_retry"]: continue try: self.socket = new_socket() @@ -288,51 +290,47 @@ def mdns_backoff(service, retry): NetworkAddress(self.host, self.port), ) ) - # Resolve the service name. If service is None, we're - # connecting directly to a host name or IP-address - if service: - host = None - port = None - host, port, service_info = get_host_from_service( - service, self.zconf + # Resolve the service name. + host = None + port = None + host, port, service_info = get_host_from_service( + service, self.zconf + ) + if host and port: + if service_info: + try: + self.fn = service_info.properties[b"fn"].decode("utf-8") + except (AttributeError, KeyError, UnicodeError): + pass + self.logger.debug( + "[%s(%s):%s] Resolved service %s to %s:%s", + self.fn or "", + self.host, + self.port, + service, + host, + port, ) - if host and port: - if service_info: - try: - self.fn = service_info.properties[b"fn"].decode( - "utf-8" - ) - except (AttributeError, KeyError, UnicodeError): - pass - self.logger.debug( - "[%s(%s):%s] Resolved service %s to %s:%s", - self.fn or "", - self.host, - self.port, - service, - host, - port, - ) - self.host = host - self.port = port - else: - self.logger.debug( - "[%s(%s):%s] Failed to resolve service %s", - self.fn or "", - self.host, - self.port, - service, - ) - self._report_connection_status( - ConnectionStatus( - CONNECTION_STATUS_FAILED_RESOLVE, - NetworkAddress(service, None), - ) + self.host = host + self.port = port + else: + self.logger.debug( + "[%s(%s):%s] Failed to resolve service %s", + self.fn or "", + self.host, + self.port, + service, + ) + self._report_connection_status( + ConnectionStatus( + CONNECTION_STATUS_FAILED_RESOLVE, + NetworkAddress(service, None), ) - mdns_backoff(service, retry) - # If zeroconf fails to receive the necessary data, - # try next service - continue + ) + mdns_backoff(service, retry) + # If zeroconf fails to receive the necessary data, + # try next service + continue self.logger.debug( "[%s(%s):%s] Connecting to %s:%s", @@ -910,9 +908,7 @@ def send_message( self.port, ) else: - raise NotConnected( - "Chromecast {}:{} is connecting...".format(self.host, self.port) - ) + raise NotConnected("Chromecast {self.host}:{self.port} is connecting...") def send_platform_message( self, namespace, message, inc_session_id=False, callback_function_param=False @@ -932,9 +928,8 @@ def send_app_message( """Helper method to send a message to current running app.""" if namespace not in self.app_namespaces: raise UnsupportedNamespace( - ( - "Namespace {} is not supported by current app. Supported are {}" - ).format(namespace, ", ".join(self.app_namespaces)) + f"Namespace {namespace} is not supported by current app. " + f"Supported are {', '.join(self.app_namespaces)}" ) return self.send_message( diff --git a/resources/pychromecast/requirements-test.txt b/resources/pychromecast/requirements-test.txt index 8a2646b..94598fc 100644 --- a/resources/pychromecast/requirements-test.txt +++ b/resources/pychromecast/requirements-test.txt @@ -1,3 +1,3 @@ -flake8==3.9.2 -pylint==2.8.3 -black==21.5b2 +flake8==4.0.1 +pylint==2.12.1 +black==21.11b1 diff --git a/resources/pychromecast/setup.py b/resources/pychromecast/setup.py index 92c4dae..97c640a 100644 --- a/resources/pychromecast/setup.py +++ b/resources/pychromecast/setup.py @@ -5,7 +5,7 @@ setup( name="PyChromecast", - version="9.2.0", + version="10.1.1", license="MIT", url="https://github.com/balloob/pychromecast", author="Paulus Schoutsen",