From a38e881fa82a6dc2f72c43be0f3f04387fb3eba4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 15 Nov 2021 13:20:13 +0100 Subject: [PATCH] Refactor handling of discovered casts and device info (#556) * Refactor handling of discovered casts and device info * black * Tweak constructors for Chromecast and SocketClient * flake * Add models.py --- examples/dashcast_example.py | 2 +- examples/get_chromecasts.py | 2 +- examples/media_example2.py | 2 +- pychromecast/__init__.py | 142 ++++++++-------------------------- pychromecast/const.py | 16 ---- pychromecast/dial.py | 82 +++++++++++++++----- pychromecast/discovery.py | 93 ++++++++++++++++++---- pychromecast/models.py | 19 +++++ pychromecast/socket_client.py | 114 +++++++++++++-------------- 9 files changed, 248 insertions(+), 224 deletions(-) create mode 100644 pychromecast/models.py diff --git a/examples/dashcast_example.py b/examples/dashcast_example.py index c5d765ea1..09a02cca3 100644 --- a/examples/dashcast_example.py +++ b/examples/dashcast_example.py @@ -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/examples/get_chromecasts.py b/examples/get_chromecasts.py index 6bb1c0448..c2cbfb592 100644 --- a/examples/get_chromecasts.py +++ b/examples/get_chromecasts.py @@ -41,5 +41,5 @@ print("Found cast devices:") for cast in casts: print( - f' "{cast.name}" on mDNS service {cast._services} with UUID:{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/examples/media_example2.py b/examples/media_example2.py index e6824c6a0..1b0205274 100644 --- a/examples/media_example2.py +++ b/examples/media_example2.py @@ -65,7 +65,7 @@ cast.wait() print() -print(cast.device) +print(cast.cast_info) time.sleep(1) print() print(cast.status) diff --git a/pychromecast/__init__.py b/pychromecast/__init__.py index 97b199a36..5594b2e72 100644 --- a/pychromecast/__init__.py +++ b/pychromecast/__init__.py @@ -16,19 +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 ( # noqa: F401 - CAST_MANUFACTURERS, - CAST_TYPE_GROUP, - 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") @@ -47,25 +44,13 @@ def get_chromecast_from_host(host, tries=None, retry_wait=None, timeout=None): # later on. ip_address, port, uuid, model_name, friendly_name = host _LOGGER.debug("get_chromecast_from_host %s", host) - cast_type = None - manufacturer = None - multizone_supported = None - if model_name.lower() == "google cast group": - cast_type = CAST_TYPE_GROUP - manufacturer = "Google Inc." - multizone_supported = False - device = DeviceStatus( - friendly_name=friendly_name, - model_name=model_name, - manufacturer=manufacturer, - uuid=uuid, - cast_type=cast_type, - multizone_supported=multizone_supported, + 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, @@ -80,33 +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 = None - manufacturer = None - multizone_supported = None - if cast_info.model_name.lower() == "google cast group": - cast_type = CAST_TYPE_GROUP - manufacturer = "Google Inc." - multizone_supported = False - device = DeviceStatus( - friendly_name=cast_info.friendly_name, - model_name=cast_info.model_name, - manufacturer=manufacturer, - uuid=cast_info.uuid, - cast_type=cast_type, - multizone_supported=multizone_supported, - ) + _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, ) @@ -285,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 @@ -298,69 +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), - multizone_supported=( - device.multizone_supported or dev_status.multizone_supported - ), - ) - else: - self.device = device - else: - self.device = get_device_status(host, services, zconf) - - if not self.device: - raise ChromecastConnectionError( # noqa: F405 - f"Could not connect to {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, ) @@ -383,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 ) @@ -404,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): @@ -412,7 +332,7 @@ 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): @@ -422,7 +342,7 @@ def uri(self): @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): @@ -435,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): @@ -552,12 +472,12 @@ def __del__(self): def __repr__(self): return ( f"Chromecast({self.socket_client.host!r}, port={self.socket_client.port!r}, " - f"device={self.device!r})" + f"cast_info={self.cast_info!r})" ) def __unicode__(self): return ( f"Chromecast({self.socket_client.host}, {self.socket_client.port}, " - f"{self.device.friendly_name}, {self.device.model_name}, " - f"{self.device.manufacturer})" + f"{self.cast_info.friendly_name}, {self.cast_info.model_name}, " + f"{self.cast_info.manufacturer})" ) diff --git a/pychromecast/const.py b/pychromecast/const.py index 431f5143f..cff9f556e 100644 --- a/pychromecast/const.py +++ b/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/pychromecast/dial.py b/pychromecast/dial.py index c0adf4242..6a20af9f7 100644 --- a/pychromecast/dial.py +++ b/pychromecast/dial.py @@ -17,6 +17,7 @@ CAST_TYPE_GROUP, SERVICE_TYPE_HOST, ) +from .models import CastInfo, ServiceInfo XML_NS_UPNP_DEVICE = "{urn:schemas-upnp-org:device-1-0}" @@ -63,20 +64,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"} @@ -102,8 +97,57 @@ def get_ssl_context(): return context -def get_device_status( # pylint: disable=too-many-locals - host, services=None, zconf=None, timeout=30, 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, zconf=None, timeout=30, context=None ): """ :param host: Hostname or ip to fetch status from @@ -113,8 +157,8 @@ def get_device_status( # pylint: disable=too-many-locals """ try: + services = [ServiceInfo(SERVICE_TYPE_HOST, (host, 8009))] status = _get_status( - host, services, zconf, "/setup/eureka_info?params=device_info,name", @@ -144,8 +188,6 @@ def get_device_status( # pylint: disable=too-many-locals if not display_supported: cast_type = CAST_TYPE_AUDIO - if model_name.lower() == "google cast group": - cast_type = CAST_TYPE_GROUP uuid = None if udn: @@ -185,7 +227,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=30, context=None): +def get_multizone_status(host, zconf=None, timeout=30, context=None): """ :param host: Hostname or ip to fetch status from :type host: str @@ -194,8 +236,8 @@ def get_multizone_status(host, services=None, zconf=None, timeout=30, context=No """ try: + services = [ServiceInfo(SERVICE_TYPE_HOST, (host, 8009))] status = _get_status( - host, services, zconf, "/setup/eureka_info?params=multizone", diff --git a/pychromecast/discovery.py b/pychromecast/discovery.py index 27faf5407..0143bd1b5 100644 --- a/pychromecast/discovery.py +++ b/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,8 +9,14 @@ import zeroconf -from .const import CAST_TYPE_AUDIO, 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 CastInfo, ServiceInfo DISCOVER_TIMEOUT = 5 @@ -21,11 +26,6 @@ "JBL", # JBL speakers crash if polled: https://github.com/home-assistant/core/issues/52020 ] -ServiceInfo = namedtuple("ServiceInfo", ["type", "data"]) -CastInfo = namedtuple( - "CastInfo", ["services", "uuid", "model_name", "friendly_name", "host", "port"] -) - _LOGGER = logging.getLogger(__name__) @@ -139,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 @@ -173,9 +174,9 @@ def get_value(key): # Store the host, in case mDNS stops working 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( @@ -199,16 +200,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) @@ -295,7 +312,7 @@ def _poll_hosts(self): # This host should not be polled continue - device_status = get_device_status(host, timeout=30, context=self._context) + device_status = get_device_info(host, timeout=30, context=self._context) if not device_status: hoststatus.failcount += 1 @@ -326,6 +343,8 @@ 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) @@ -352,6 +371,8 @@ def _poll_hosts(self): group.friendly_name, "Google Cast Group", group.uuid, + CAST_TYPE_GROUP, + "Google Inc.", ) ) uuids.append(group.uuid) @@ -363,9 +384,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: @@ -381,7 +416,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 @@ -398,14 +443,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/pychromecast/models.py b/pychromecast/models.py new file mode 100644 index 000000000..9a3fd6fcc --- /dev/null +++ b/pychromecast/models.py @@ -0,0 +1,19 @@ +""" +Chromecast types +""" +from collections import namedtuple + +CastInfo = namedtuple( + "CastInfo", + [ + "services", + "uuid", + "model_name", + "friendly_name", + "host", + "port", + "cast_type", + "manufacturer", + ], +) +ServiceInfo = namedtuple("ServiceInfo", ["type", "data"]) diff --git a/pychromecast/socket_client.py b/pychromecast/socket_client.py index a8e5f941b..d51eed139 100644 --- a/pychromecast/socket_client.py +++ b/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, @@ -139,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. @@ -168,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 @@ -188,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() @@ -274,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() @@ -286,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",