""" Module to interact with the ChromeCast via protobuf-over-socket. Big thanks goes out to Fred Clift who build the first version of this code: https://github.com/minektur/chromecast-python-poc. Without him this would not have been possible. """ # Pylint does not understand the protobuf objects correctly # pylint: disable=no-member import logging import select import socket import ssl import json import threading import time from collections import namedtuple from struct import pack, unpack import sys from . import cast_channel_pb2 from .dial import CAST_TYPE_CHROMECAST, CAST_TYPE_AUDIO, CAST_TYPE_GROUP from .controllers import BaseController from .controllers.media import MediaController from .error import ( ChromecastConnectionError, UnsupportedNamespace, NotConnected, PyChromecastStopped, LaunchError, ) NS_CONNECTION = 'urn:x-cast:com.google.cast.tp.connection' NS_RECEIVER = 'urn:x-cast:com.google.cast.receiver' NS_HEARTBEAT = 'urn:x-cast:com.google.cast.tp.heartbeat' PLATFORM_DESTINATION_ID = "receiver-0" MESSAGE_TYPE = 'type' TYPE_PING = "PING" TYPE_RECEIVER_STATUS = "RECEIVER_STATUS" TYPE_PONG = "PONG" TYPE_CONNECT = "CONNECT" TYPE_CLOSE = "CLOSE" TYPE_GET_STATUS = "GET_STATUS" TYPE_LAUNCH = "LAUNCH" TYPE_LAUNCH_ERROR = "LAUNCH_ERROR" TYPE_LOAD = "LOAD" # The socket connection is being setup CONNECTION_STATUS_CONNECTING = "CONNECTING" # The socket connection was complete CONNECTION_STATUS_CONNECTED = "CONNECTED" # The socket connection has been disconnected CONNECTION_STATUS_DISCONNECTED = "DISCONNECTED" # Connecting to socket failed (after a CONNECTION_STATUS_CONNECTING) CONNECTION_STATUS_FAILED = "FAILED" # The socket connection was lost and needs to be retried CONNECTION_STATUS_LOST = "LOST" APP_ID = 'appId' REQUEST_ID = "requestId" SESSION_ID = "sessionId" ERROR_REASON = 'reason' HB_PING_TIME = 10 HB_PONG_TIME = 10 POLL_TIME = 5 TIMEOUT_TIME = 30 RETRY_TIME = 5 class InterruptLoop(Exception): """ The chromecast has been manually stopped. """ pass def _json_from_message(message): """ Parses a PB2 message into JSON format. """ return json.loads(message.payload_utf8) def _message_to_string(message, data=None): """ Gives a string representation of a PB2 message. """ if data is None: data = _json_from_message(message) return "Message {} from {} to {}: {}".format( message.namespace, message.source_id, message.destination_id, data) if sys.version_info >= (3, 0): def _json_to_payload(data): """ Encodes a python value into JSON format. """ return json.dumps(data, ensure_ascii=False).encode("utf8") else: def _json_to_payload(data): """ Encodes a python value into JSON format. """ return json.dumps(data, ensure_ascii=False) def _is_ssl_timeout(exc): """ Returns True if the exception is for an SSL timeout """ return exc.message in ("The handshake operation timed out", "The write operation timed out", "The read operation timed out") NetworkAddress = namedtuple('NetworkAddress', ['address', 'port']) ConnectionStatus = namedtuple('ConnectionStatus', ['status', 'address']) CastStatus = namedtuple('CastStatus', ['is_active_input', 'is_stand_by', 'volume_level', 'volume_muted', 'app_id', 'display_name', 'namespaces', 'session_id', 'transport_id', 'status_text']) LaunchFailure = namedtuple('LaunchStatus', ['reason', 'app_id', 'request_id']) # pylint: disable=too-many-instance-attributes class SocketClient(threading.Thread): """ Class to interact with a Chromecast through a socket. :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 cast_type: The type of chromecast to connect to, see dial.CAST_TYPE_* for types. :param tries: Number of retries to perform if the connection fails. None for inifinite retries. :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. """ 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) super(SocketClient, self).__init__() self.daemon = True self.logger = logging.getLogger(__name__) self._force_recon = False self.cast_type = cast_type self.tries = tries self.timeout = timeout or TIMEOUT_TIME self.retry_wait = retry_wait or RETRY_TIME self.host = host self.port = port or 8009 self.source_id = "sender-0" self.stop = threading.Event() self.app_namespaces = [] self.destination_id = None self.session_id = None self._request_id = 0 # dict mapping requestId on threading.Event objects self._request_callbacks = {} self._open_channels = [] self.connecting = True self.socket = None # dict mapping namespace on Controller objects self._handlers = {} self._connection_listeners = [] self.receiver_controller = ReceiverController(cast_type) self.media_controller = MediaController() self.heartbeat_controller = HeartbeatController() self.register_handler(self.heartbeat_controller) self.register_handler(ConnectionController()) self.register_handler(self.receiver_controller) self.register_handler(self.media_controller) self.receiver_controller.register_status_listener(self) try: self.initialize_connection() except ChromecastConnectionError: self._report_connection_status( ConnectionStatus(CONNECTION_STATUS_DISCONNECTED, NetworkAddress(self.host, self.port))) raise def initialize_connection(self): """Initialize a socket to a Chromecast, retrying as necessary.""" tries = self.tries if self.socket is not None: self.socket.close() self.socket = None # Make sure nobody is blocking. for callback in self._request_callbacks.values(): callback['event'].set() self.app_namespaces = [] self.destination_id = None self.session_id = None self._request_id = 0 self._request_callbacks = {} self._open_channels = [] self.connecting = True retry_log_fun = self.logger.exception while not self.stop.is_set() and (tries is None or tries > 0): try: self.socket = ssl.wrap_socket(socket.socket()) self.socket.settimeout(self.timeout) self._report_connection_status( ConnectionStatus(CONNECTION_STATUS_CONNECTING, NetworkAddress(self.host, self.port))) self.socket.connect((self.host, self.port)) self.connecting = False self._force_recon = False self._report_connection_status( ConnectionStatus(CONNECTION_STATUS_CONNECTED, NetworkAddress(self.host, self.port))) self.receiver_controller.update_status() self.heartbeat_controller.ping() self.heartbeat_controller.reset() self.logger.debug("Connected!") break except socket.error: self.connecting = True if self.stop.is_set(): self.logger.exception( "Failed to connect, aborting due to stop signal.") raise ChromecastConnectionError("Failed to connect") self._report_connection_status( ConnectionStatus(CONNECTION_STATUS_FAILED, NetworkAddress(self.host, self.port))) retry_log_fun("Failed to connect, retrying in %fs", self.retry_wait) retry_log_fun = self.logger.debug time.sleep(self.retry_wait) if tries: tries -= 1 else: self.stop.set() self.logger.error("Failed to connect. No retries.") raise ChromecastConnectionError("Failed to connect") def disconnect(self): """ Disconnect socket connection to Chromecast device """ self.stop.set() def register_handler(self, handler): """ Register a new namespace handler. """ self._handlers[handler.namespace] = handler handler.registered(self) def new_cast_status(self, cast_status): """ Called when a new cast status has been received. """ new_channel = self.destination_id != cast_status.transport_id if new_channel: self._disconnect_channel(self.destination_id) self.app_namespaces = cast_status.namespaces self.destination_id = cast_status.transport_id self.session_id = cast_status.session_id if new_channel: # If any of the namespaces of the new app are supported # we will automatically connect to it to receive updates for namespace in self.app_namespaces: if namespace in self._handlers: self._ensure_channel_connected(self.destination_id) self._handlers[namespace].channel_connected() def _gen_request_id(self): """ Generates a unique request id. """ self._request_id += 1 return self._request_id @property def is_connected(self): """ Returns True if the client is connected, False if it is stopped (or trying to connect). """ return not self.connecting @property def is_stopped(self): """ Returns True if the connection has been stopped, False if it is running. """ return self.stop.is_set() def run(self): """ Start polling the socket. """ # pylint: disable=too-many-branches self.heartbeat_controller.reset() self._force_recon = False while not self.stop.is_set(): try: if not self._check_connection(): continue except ChromecastConnectionError: break # poll the socket can_read, _, _ = select.select([self.socket], [], [], POLL_TIME) # read messages from chromecast message = data = None if self.socket in can_read and not self._force_recon: try: message = self._read_message() except InterruptLoop as exc: if self.stop.is_set(): self.logger.info( "Stopped while reading message, disconnecting.") break else: self.logger.exception( "Interruption caught without being stopped %s", exc) break except ssl.SSLError as exc: if exc.errno == ssl.SSL_ERROR_EOF: if self.stop.is_set(): break raise except socket.error: self._force_recon = True self.logger.info('Error reading from socket.') else: data = _json_from_message(message) if not message: continue # If we are stopped after receiving a message we skip the message # and tear down the connection if self.stop.is_set(): break # See if any handlers will accept this message self._route_message(message, data) if REQUEST_ID in data: callback = self._request_callbacks.pop(data[REQUEST_ID], None) if callback is not None: event = callback['event'] callback['response'] = data event.set() # Clean up self._cleanup() def _check_connection(self): """ Checks if the connection is active, and if not reconnect :return: True if the connection is active, False if the connection was reset. """ # check if connection is expired reset = False if self._force_recon: self.logger.warning( "Error communicating with socket, resetting connection") reset = True elif self.heartbeat_controller.is_expired(): self.logger.warning("Heartbeat timeout, resetting connection") reset = True if reset: self._report_connection_status( ConnectionStatus(CONNECTION_STATUS_LOST, NetworkAddress(self.host, self.port))) try: self.initialize_connection() except ChromecastConnectionError: self.stop.set() return False return True def _route_message(self, message, data): """ Route message to any handlers on the message namespace """ # route message to handlers if message.namespace in self._handlers: # debug messages if message.namespace != NS_HEARTBEAT: self.logger.debug( "Received: %s", _message_to_string(message, data)) # message handlers try: handled = \ self._handlers[message.namespace].receive_message( message, data) if not handled: if data.get(REQUEST_ID) not in self._request_callbacks: self.logger.debug( "Message unhandled: %s", _message_to_string(message, data)) except Exception: # pylint: disable=broad-except self.logger.exception( (u"Exception caught while sending message to " u"controller %s: %s"), type(self._handlers[message.namespace]).__name__, _message_to_string(message, data)) else: self.logger.debug( "Received unknown namespace: %s", _message_to_string(message, data)) def _cleanup(self): """ Cleanup open channels and handlers """ for channel in self._open_channels: try: self._disconnect_channel(channel) except Exception: # pylint: disable=broad-except pass for handler in self._handlers.values(): try: handler.tear_down() except Exception: # pylint: disable=broad-except pass self.socket.close() self._report_connection_status( ConnectionStatus(CONNECTION_STATUS_DISCONNECTED, NetworkAddress(self.host, self.port))) self.connecting = True def _report_connection_status(self, status): """ Report a change in the connection status to any listeners """ for listener in self._connection_listeners: try: self.logger.debug("connection listener: %x (%s)", id(listener), type(listener).__name__) listener.new_connection_status(status) except Exception: # pylint: disable=broad-except pass def _read_bytes_from_socket(self, msglen): """ Read bytes from the socket. """ chunks = [] bytes_recd = 0 while bytes_recd < msglen: if self.stop.is_set(): raise InterruptLoop("Stopped while reading from socket") try: chunk = self.socket.recv(min(msglen - bytes_recd, 2048)) if chunk == b'': raise socket.error("socket connection broken") chunks.append(chunk) bytes_recd += len(chunk) except socket.timeout: continue except ssl.SSLError as exc: # Support older ssl implementations which does not raise # socket.timeout on timeouts if _is_ssl_timeout(exc): continue raise return b''.join(chunks) def _read_message(self): """ Reads a message from the socket and converts it to a message. """ # first 4 bytes is Big-Endian payload length payload_info = self._read_bytes_from_socket(4) read_len = unpack(">I", payload_info)[0] # now read the payload payload = self._read_bytes_from_socket(read_len) # pylint: disable=no-member message = cast_channel_pb2.CastMessage() message.ParseFromString(payload) return message # pylint: disable=too-many-arguments def send_message(self, destination_id, namespace, data, inc_session_id=False, wait_for_response=False, no_add_request_id=False, force=False): """ Send a message to the Chromecast. """ # namespace is a string containing namespace # data is a dict that will be converted to json # wait_for_response only works if we have a request id # If channel is not open yet, connect to it. self._ensure_channel_connected(destination_id) request_id = None if not no_add_request_id: request_id = self._gen_request_id() data[REQUEST_ID] = request_id if inc_session_id: data[SESSION_ID] = self.session_id # pylint: disable=no-member msg = cast_channel_pb2.CastMessage() msg.protocol_version = msg.CASTV2_1_0 msg.source_id = self.source_id msg.destination_id = destination_id msg.payload_type = cast_channel_pb2.CastMessage.STRING msg.namespace = namespace msg.payload_utf8 = _json_to_payload(data) # prepend message with Big-Endian 4 byte payload size be_size = pack(">I", msg.ByteSize()) # Log all messages except heartbeat if msg.namespace != NS_HEARTBEAT: self.logger.debug("Sending: %s", _message_to_string(msg, data)) if not force and self.stop.is_set(): raise PyChromecastStopped("Socket client's thread is stopped.") if not self.connecting and not self._force_recon: try: self.socket.sendall(be_size + msg.SerializeToString()) except socket.error: self._force_recon = True self.logger.info('Error writing to socket.') else: raise NotConnected("Chromecast is connecting...") response = None if not no_add_request_id and wait_for_response: callback = self._request_callbacks[request_id] = { 'event': threading.Event(), 'response': None, } callback['event'].wait() response = callback.get('response') return response def send_platform_message(self, namespace, message, inc_session_id=False, wait_for_response=False): """ Helper method to send a message to the platform. """ return self.send_message(PLATFORM_DESTINATION_ID, namespace, message, inc_session_id, wait_for_response) def send_app_message(self, namespace, message, inc_session_id=False, wait_for_response=False): """ 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))) return self.send_message(self.destination_id, namespace, message, inc_session_id, wait_for_response) def register_connection_listener(self, listener): """ Register a connection listener for when the socket connection changes. Listeners will be called with listener.new_connection_status(status) """ self._connection_listeners.append(listener) def _ensure_channel_connected(self, destination_id): """ Ensure we opened a channel to destination_id. """ if destination_id not in self._open_channels: self._open_channels.append(destination_id) self.send_message( destination_id, NS_CONNECTION, {MESSAGE_TYPE: TYPE_CONNECT, 'origin': {}, 'userAgent': 'PyChromecast', 'senderInfo': { 'sdkType': 2, 'version': '15.605.1.3', 'browserVersion': "44.0.2403.30", 'platform': 4, 'systemVersion': 'Macintosh; Intel Mac OS X10_10_3', 'connectionType': 1}}, no_add_request_id=True) def _disconnect_channel(self, destination_id): """ Disconnect a channel with destination_id. """ if destination_id in self._open_channels: self.send_message( destination_id, NS_CONNECTION, {MESSAGE_TYPE: TYPE_CLOSE, 'origin': {}}, no_add_request_id=True, force=True) self._open_channels.remove(destination_id) self.handle_channel_disconnected() def handle_channel_disconnected(self): """ Handles a channel being disconnected. """ for namespace in self.app_namespaces: if namespace in self._handlers: self._handlers[namespace].channel_disconnected() self.app_namespaces = [] self.destination_id = None self.session_id = None class ConnectionController(BaseController): """ Controller to respond to connection messages. """ def __init__(self): super(ConnectionController, self).__init__(NS_CONNECTION) def receive_message(self, message, data): """ Called when a connection message is received. """ if self._socket_client.is_stopped: return True if data[MESSAGE_TYPE] == TYPE_CLOSE: self._socket_client.handle_channel_disconnected() return True else: return False class HeartbeatController(BaseController): """ Controller to respond to heartbeat messages. """ def __init__(self): super(HeartbeatController, self).__init__( NS_HEARTBEAT, target_platform=True) self.last_ping = 0 self.last_pong = time.time() def receive_message(self, message, data): """ Called when a heartbeat message is received. """ if self._socket_client.is_stopped: return True if data[MESSAGE_TYPE] == TYPE_PING: try: self._socket_client.send_message( PLATFORM_DESTINATION_ID, self.namespace, {MESSAGE_TYPE: TYPE_PONG}, no_add_request_id=True) except PyChromecastStopped: self._socket_client.logger.exception( "Heartbeat error when sending response, " "Chromecast connection has stopped") return True elif data[MESSAGE_TYPE] == TYPE_PONG: self.reset() return True else: return False def ping(self): """ Send a ping message. """ self.last_ping = time.time() try: self.send_message({MESSAGE_TYPE: TYPE_PING}) except NotConnected: self._socket_client.logger.error("Chromecast is disconnected. " + "Cannot ping until reconnected.") def reset(self): """ Reset expired counter. """ self.last_pong = time.time() def is_expired(self): """ Indicates if connection has expired. """ if time.time() - self.last_ping > HB_PING_TIME: self.ping() return (time.time() - self.last_pong) > HB_PING_TIME + HB_PONG_TIME class ReceiverController(BaseController): """ Controller to interact with the Chromecast platform. :param cast_type: Type of Chromecast device. """ def __init__(self, cast_type=CAST_TYPE_CHROMECAST): super(ReceiverController, self).__init__( NS_RECEIVER, target_platform=True) self.status = None self.launch_failure = None self.app_to_launch = None self.cast_type = cast_type self.app_launch_event = threading.Event() self._status_listeners = [] self._launch_error_listeners = [] @property def app_id(self): """ Convenience method to retrieve current app id. """ return self.status.app_id if self.status else None def receive_message(self, message, data): """ Called when a receiver-message has been received. """ if data[MESSAGE_TYPE] == TYPE_RECEIVER_STATUS: self._process_get_status(data) return True elif data[MESSAGE_TYPE] == TYPE_LAUNCH_ERROR: self._process_launch_error(data) return True else: return False def register_status_listener(self, listener): """ Register a status listener for when a new Chromecast status has been received. Listeners will be called with listener.new_cast_status(status) """ self._status_listeners.append(listener) def register_launch_error_listener(self, listener): """ Register a listener for when a new launch error message has been received. Listeners will be called with listener.new_launch_error(launch_failure) """ self._launch_error_listeners.append(listener) def update_status(self, blocking=False): """ Sends a message to the Chromecast to update the status. """ self.logger.debug("Receiver:Updating status") self.send_message({MESSAGE_TYPE: TYPE_GET_STATUS}, wait_for_response=blocking) def launch_app(self, app_id, force_launch=False, block_till_launched=True): """ Launches an app on the Chromecast. Will only launch if it is not currently running unless force_launch=True. """ # If this is called too quickly after launch, we don't have the info. # We need the info if we are not force launching to check running app. if not force_launch and self.app_id is None: self.update_status(True) if force_launch or self.app_id != app_id: self.logger.info("Receiver:Launching app %s", app_id) # If we are blocking we need to wait for the status update or # launch failure before returning self.launch_failure = None if block_till_launched: self.app_to_launch = app_id self.app_launch_event.clear() response = self.send_message({MESSAGE_TYPE: TYPE_LAUNCH, APP_ID: app_id}, wait_for_response=block_till_launched) if block_till_launched: is_app_started = False if response: response_type = response[MESSAGE_TYPE] if response_type == TYPE_RECEIVER_STATUS: new_status = self._parse_status(response, self.cast_type) new_app_id = new_status.app_id if new_app_id == app_id: is_app_started = True self.app_to_launch = None self.app_launch_event.clear() elif response_type == TYPE_LAUNCH_ERROR: self.app_to_launch = None self.app_launch_event.clear() launch_error = self._parse_launch_error(response) raise LaunchError( "Failed to launch app: {}, Reason: {}".format( app_id, launch_error.reason)) if not is_app_started: self.app_launch_event.wait() if self.launch_failure: raise LaunchError( "Failed to launch app: {}, Reason: {}".format( app_id, self.launch_failure.reason)) else: self.logger.info( "Not launching app %s - already running", app_id) def stop_app(self, block_till_stopped=True): """ Stops the current running app on the Chromecast. """ self.logger.info("Receiver:Stopping current app '%s'", self.app_id) return self.send_message( {MESSAGE_TYPE: 'STOP'}, inc_session_id=True, wait_for_response=block_till_stopped) def set_volume(self, volume): """ Allows to set volume. Should be value between 0..1. Returns the new volume. """ volume = min(max(0, volume), 1) self.logger.info("Receiver:setting volume to %.1f", volume) self.send_message({MESSAGE_TYPE: 'SET_VOLUME', 'volume': {'level': volume}}) return volume def set_volume_muted(self, muted): """ Allows to mute volume. """ self.send_message( {MESSAGE_TYPE: 'SET_VOLUME', 'volume': {'muted': muted}}) @staticmethod def _parse_status(data, cast_type): """ Parses a STATUS message and returns a CastStatus object. :type data: dict :param cast_type: Type of Chromecast. :rtype: CastStatus """ data = data.get('status', {}) volume_data = data.get('volume', {}) try: app_data = data['applications'][0] except KeyError: app_data = {} is_audio = cast_type in (CAST_TYPE_AUDIO, CAST_TYPE_GROUP) status = CastStatus( data.get('isActiveInput', None if is_audio else False), data.get('isStandBy', None if is_audio else True), volume_data.get('level', 1.0), volume_data.get('muted', False), app_data.get(APP_ID), app_data.get('displayName'), [item['name'] for item in app_data.get('namespaces', [])], app_data.get(SESSION_ID), app_data.get('transportId'), app_data.get('statusText', '') ) return status def _process_get_status(self, data): """ Processes a received STATUS message and notifies listeners. """ status = self._parse_status(data, self.cast_type) is_new_app = self.app_id != status.app_id and self.app_to_launch self.status = status if is_new_app and self.app_to_launch == self.app_id: self.app_to_launch = None self.app_launch_event.set() self.logger.debug("Received status: %s", self.status) self._report_status() def _report_status(self): """ Reports the current status to all listeners. """ for listener in self._status_listeners: try: listener.new_cast_status(self.status) except Exception: # pylint: disable=broad-except pass @staticmethod def _parse_launch_error(data): """ Parses a LAUNCH_ERROR message and returns a LaunchFailure object. :type data: dict :rtype: LaunchFailure """ return LaunchFailure( data.get(ERROR_REASON, None), data.get(APP_ID), data.get(REQUEST_ID), ) def _process_launch_error(self, data): """ Processes a received LAUNCH_ERROR message and notifies listeners. """ launch_failure = self._parse_launch_error(data) self.launch_failure = launch_failure if self.app_to_launch: self.app_to_launch = None self.app_launch_event.set() self.logger.debug("Launch status: %s", launch_failure) for listener in self._launch_error_listeners: try: listener.new_launch_error(launch_failure) except Exception: # pylint: disable=broad-except pass def tear_down(self): """ Called when controller is destroyed. """ super(ReceiverController, self).tear_down() self.status = None self.launch_failure = None self.app_to_launch = None self.app_launch_event.clear() self._report_status() self._status_listeners[:] = []