"""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) 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