mirror of
https://github.com/CCOSTAN/Home-AssistantConfig.git
synced 2025-08-18 03:15:45 +00:00
426 lines
13 KiB
Python
426 lines
13 KiB
Python
"""
|
|
Implements the WEBSOCKET protocol to communicate with the Chromecast.
|
|
"""
|
|
# pylint: disable=invalid-name
|
|
|
|
import threading
|
|
import datetime as dt
|
|
import json
|
|
import logging
|
|
import socket
|
|
import weakref
|
|
import atexit
|
|
|
|
import requests
|
|
|
|
from ws4py.client.threadedclient import WebSocketClient
|
|
|
|
from . import error
|
|
|
|
_DEBUG = False
|
|
|
|
RAMP_ENABLED = ['HBO_App',
|
|
'18a8aeaa-8e3d-4c24-b05d-da68394a3476_1',
|
|
'aa35235e-a960-4402-a87e-807ae8b2ac79',
|
|
'06ee44ee-e7e3-4249-83b6-f5d0b6f07f34',
|
|
'YouTube',
|
|
'GoogleMusic',
|
|
'06ee44ee-e7e3-4249-83b6-f5d0b6f07f34_1',
|
|
'edaded98-5119-4c8a-afc1-de722da03562',
|
|
'1812335e-441c-4e1e-a61a-312ca1ead90e',
|
|
'Hulu_Plus',
|
|
'Post_TV_App',
|
|
'PlayMovies',
|
|
'Songza_App',
|
|
'Revision3_App']
|
|
|
|
PROTOCOL_RAMP = "ramp"
|
|
PROTOCOL_COMMAND = "cm"
|
|
|
|
COMMAND_ATTR_TYPE = "type"
|
|
COMMAND_TYPE_PING = "ping"
|
|
COMMAND_TYPE_PONG = "pong"
|
|
|
|
RAMP_ATTR_TYPE = "type"
|
|
RAMP_ATTR_CMD_ID = "cmd_id"
|
|
RAMP_ATTR_TITLE = "title"
|
|
RAMP_ATTR_SRC = "src"
|
|
RAMP_ATTR_AUTOPLAY = "autoplay"
|
|
RAMP_ATTR_STATUS = "status"
|
|
RAMP_ATTR_CONTENT_INFO = "content_info"
|
|
RAMP_ATTR_TIME_PROGRESS = "time_progress"
|
|
RAMP_ATTR_VOLUME = "volume"
|
|
RAMP_ATTR_POSITION = "position"
|
|
|
|
RAMP_STATUS_ATTR_ERROR = "error"
|
|
RAMP_STATUS_ATTR_CONTENT_ID = "content_id"
|
|
RAMP_STATUS_ATTR_TITLE = "title"
|
|
RAMP_STATUS_ATTR_CURRENT_TIME = "current_time"
|
|
RAMP_STATUS_ATTR_DURATION = "duration"
|
|
RAMP_STATUS_ATTR_IMAGE_URL = "image_url"
|
|
RAMP_STATUS_ATTR_VOLUME = "volume"
|
|
RAMP_STATUS_ATTR_MUTED = "muted"
|
|
RAMP_STATUS_ATTR_STATE = "state"
|
|
RAMP_STATUS_CONTENT_INFO_ATTR_ALBUM_TITLE = "album_title"
|
|
RAMP_STATUS_CONTENT_INFO_ATTR_ARTIST = "artist"
|
|
RAMP_STATUS_ERROR_ATTR_CODE = "code"
|
|
RAMP_STATUS_ERROR_ATTR_DOMAIN = "domain"
|
|
RAMP_STATUS_ERROR_ATTR_EVENT_SEQUENCE = "event_sequence"
|
|
|
|
RAMP_TYPE_PLAY = "PLAY"
|
|
RAMP_TYPE_STOP = "STOP"
|
|
RAMP_TYPE_LOAD = "LOAD"
|
|
RAMP_TYPE_STATUS = "STATUS"
|
|
RAMP_TYPE_RESPONSE = "RESPONSE"
|
|
RAMP_TYPE_VOLUME = "VOLUME"
|
|
RAMP_TYPE_INFO = "INFO"
|
|
|
|
RAMP_STATE_UNKNOWN = 0
|
|
RAMP_STATE_PLAYING = 2
|
|
RAMP_STATE_STOPPED = 1
|
|
|
|
RAMP_VALUE_TRUE = "true"
|
|
RAMP_VALUE_FALSE = "false"
|
|
|
|
_OPEN_CLIENTS = []
|
|
|
|
|
|
def create_websocket_client(app_status):
|
|
"""
|
|
Creates and returns a RAMP client based on the supplied app status.
|
|
Will return None if RAMP client is not supported.
|
|
Will raise ValueError if unable to retrieve the websocket url.
|
|
"""
|
|
|
|
# Check if current app has no service url or no protocols.
|
|
if not app_status.service_url or not app_status.service_protocols:
|
|
return None
|
|
|
|
req = requests.post(app_status.service_url,
|
|
data="{}".encode("ascii"),
|
|
headers={"Content-Type": "application/json"})
|
|
|
|
if req.status_code != 200:
|
|
raise error.ChromecastConnectionError(
|
|
"Could not retrieve websocket url ({}).".format(req.status_code))
|
|
|
|
conn_data = json.loads(req.text)
|
|
|
|
client = ChromecastWebSocketClient(conn_data['URL'],
|
|
app_status.service_protocols)
|
|
|
|
client.connect()
|
|
|
|
atexit.register(_clean_open_clients)
|
|
|
|
return client
|
|
|
|
|
|
def _clean_open_clients():
|
|
""" Called on exit of Python to close open clients. """
|
|
for client_weakref in list(_OPEN_CLIENTS):
|
|
client = client_weakref()
|
|
|
|
if client and not client.terminated:
|
|
client.close_connection()
|
|
|
|
|
|
# pylint: disable=too-many-public-methods
|
|
class ChromecastWebSocketClient(WebSocketClient):
|
|
""" A Client to remote control a Chromecast via the RAMP protocol. """
|
|
|
|
def __init__(self, url, supported_protocols):
|
|
WebSocketClient.__init__(self, url)
|
|
|
|
self.supported_protocols = supported_protocols
|
|
self.logger = logging.getLogger(__name__)
|
|
self.handlers = {}
|
|
self._weakref = weakref.ref(self)
|
|
|
|
def opened(self):
|
|
""" When connection is opened initiate the protocol handlers. """
|
|
_OPEN_CLIENTS.append(self._weakref)
|
|
|
|
self.handlers[PROTOCOL_COMMAND] = CommandSubprotocol(self)
|
|
|
|
_known_prot = KNOWN_PROTOCOLS
|
|
|
|
# Instantiate supported subprotocols.
|
|
for protocol in self.supported_protocols:
|
|
handler = _known_prot.get(protocol)
|
|
|
|
if handler:
|
|
self.handlers[protocol] = handler(self)
|
|
else:
|
|
self.logger.warning(
|
|
"Unsupported protocol: {}".format(protocol))
|
|
|
|
def closed(self, code, reason=None):
|
|
""" Clear protocol handlers when connection is lost. """
|
|
# Clear reference to client
|
|
_OPEN_CLIENTS.remove(self._weakref)
|
|
|
|
for handler in self.handlers.values():
|
|
handler.client = None
|
|
|
|
self.handlers.clear()
|
|
|
|
def received_message(self, message):
|
|
""" When a new message is received. """
|
|
|
|
# We do not support binary message
|
|
if message.is_binary:
|
|
return False
|
|
|
|
try:
|
|
protocol, data = json.loads(message.data.decode('utf8'))
|
|
except ValueError:
|
|
# If error while parsing JSON
|
|
# if unpack error: more then 2 items in the list
|
|
logging.getLogger(__name__).exception(
|
|
"Error parsing incoming message: {}".format(
|
|
message.data.decode("utf8")))
|
|
|
|
return
|
|
|
|
if _DEBUG:
|
|
logging.getLogger(__name__).info("Receiving {}".format(data))
|
|
|
|
handler = self.handlers.get(protocol)
|
|
|
|
if handler:
|
|
handler._receive_protocol(data) # pylint: disable=protected-access
|
|
else:
|
|
logging.getLogger(__name__).warning(
|
|
"Unknown protocol received: {}, {}".format(protocol, data))
|
|
|
|
|
|
# pylint: disable=too-few-public-methods
|
|
class BaseSubprotocol(object):
|
|
""" Abstract implementation for a subprotocol. """
|
|
|
|
def __init__(self, protocol, client):
|
|
self.protocol = protocol
|
|
self.client = client
|
|
self.logger = logging.getLogger(__name__)
|
|
|
|
def _send_protocol(self, data):
|
|
""" Default handler for sending messages as subprotocol. """
|
|
if _DEBUG:
|
|
self.logger.info("Sending {}".format(data))
|
|
|
|
if not self.client:
|
|
raise error.ChromecastConnectionError(
|
|
"Not connected to Chromecast")
|
|
|
|
try:
|
|
self.client.send(json.dumps([self.protocol, data]).encode("utf8"))
|
|
|
|
except socket.error:
|
|
# if an error occured sending data over the socket
|
|
raise error.ChromecastConnectionError(
|
|
"Error communicating with Chromecast")
|
|
|
|
def _receive_protocol(self, data):
|
|
""" Default handler for receiving messages as subprotocol. """
|
|
self.logger.warning(
|
|
"Unhandled {} message: {}".format(self.protocol, data))
|
|
|
|
@property
|
|
def is_active(self):
|
|
""" Returns if this subprotocol is active. """
|
|
return not self.client.terminated
|
|
|
|
|
|
class CommandSubprotocol(BaseSubprotocol):
|
|
""" Implements the Command subprotocol. """
|
|
|
|
def __init__(self, client):
|
|
BaseSubprotocol.__init__(self, PROTOCOL_COMMAND, client)
|
|
|
|
def _receive_protocol(self, data):
|
|
""" Handles an incoming COMMAND message. """
|
|
|
|
if data[COMMAND_ATTR_TYPE] == COMMAND_TYPE_PING:
|
|
self._send_protocol({COMMAND_ATTR_TYPE: COMMAND_TYPE_PONG})
|
|
else:
|
|
BaseSubprotocol._receive_protocol(self, data)
|
|
|
|
|
|
# pylint: disable=too-many-instance-attributes, attribute-defined-outside-init
|
|
class RampSubprotocol(BaseSubprotocol):
|
|
""" Implements the Ramp subprotocol. """
|
|
|
|
def __init__(self, client):
|
|
BaseSubprotocol.__init__(self, PROTOCOL_RAMP, client)
|
|
|
|
self.command_id = 0
|
|
self.commands = {}
|
|
|
|
self._update_status({})
|
|
|
|
def _receive_protocol(self, data):
|
|
""" Handles an incoming Ramp message. """
|
|
message_type = data[RAMP_ATTR_TYPE]
|
|
|
|
if message_type == RAMP_TYPE_STATUS:
|
|
self._update_status(data[RAMP_ATTR_STATUS])
|
|
|
|
elif message_type == RAMP_TYPE_RESPONSE:
|
|
# Match it with the command that we send
|
|
try:
|
|
cmd_type, cmd_event = \
|
|
self.commands.pop(data[RAMP_ATTR_CMD_ID])
|
|
|
|
except KeyError:
|
|
# If CMD_ID did not exist or we do not recognize command
|
|
return
|
|
|
|
# Handle response, currently no response handlers
|
|
if cmd_type in (RAMP_TYPE_PLAY, RAMP_TYPE_VOLUME,
|
|
RAMP_TYPE_INFO):
|
|
|
|
self._update_status(data[RAMP_ATTR_STATUS])
|
|
|
|
else:
|
|
self.logger.warning(
|
|
"Unhandled response for command {}: {}".format(
|
|
cmd_type, data))
|
|
|
|
# Alert code that is waiting for this command to get response
|
|
if cmd_event:
|
|
cmd_event.set()
|
|
|
|
else:
|
|
BaseSubprotocol._receive_protocol(self, data)
|
|
|
|
# pylint: disable=arguments-differ
|
|
def _send_ramp(self, data, blocking=False):
|
|
"""
|
|
Sends a RAMP message.
|
|
Set blocking=True to wait till the Chromecast sends a response
|
|
to the command.
|
|
"""
|
|
data[RAMP_ATTR_CMD_ID] = self.command_id
|
|
|
|
event = threading.Event() if blocking else None
|
|
|
|
# Save type to match later with response
|
|
self.commands[self.command_id] = (data[RAMP_ATTR_TYPE], event)
|
|
|
|
self._send_protocol(data)
|
|
|
|
self.command_id += 1
|
|
|
|
if blocking:
|
|
event.wait()
|
|
|
|
@property
|
|
def is_playing(self):
|
|
""" Property that represents if content is being played. """
|
|
return self.is_active and self.state == RAMP_STATE_PLAYING
|
|
|
|
def play(self):
|
|
""" Send the PLAY-command to the RAMP-target. """
|
|
self._send_ramp({RAMP_ATTR_TYPE: RAMP_TYPE_PLAY})
|
|
|
|
def pause(self):
|
|
""" Send the PAUSE-command to the RAMP-target. """
|
|
# The STOP command actually pauses the media
|
|
self._send_ramp({RAMP_ATTR_TYPE: RAMP_TYPE_STOP})
|
|
|
|
def playpause(self):
|
|
""" Plays if paused, pauses if playing. """
|
|
if self.state == RAMP_STATE_PLAYING:
|
|
self.pause()
|
|
else:
|
|
self.play()
|
|
|
|
def seek(self, seconds):
|
|
""" Seek within the content played at RAMP-target. """
|
|
self._send_ramp({RAMP_ATTR_TYPE: RAMP_TYPE_PLAY,
|
|
RAMP_ATTR_POSITION: seconds})
|
|
|
|
def rewind(self):
|
|
""" Rewinds current media item. """
|
|
self.seek(0)
|
|
|
|
def next(self):
|
|
""" Skip to the next content at the RAMP-target. """
|
|
if self.duration != 0:
|
|
self.seek(self.duration-.1)
|
|
|
|
def set_volume(self, volume):
|
|
""" Set volume at the RAMP-target. """
|
|
# volume is double between 0 and 1
|
|
self._send_ramp({RAMP_ATTR_TYPE: RAMP_TYPE_VOLUME,
|
|
RAMP_ATTR_VOLUME: volume})
|
|
|
|
def volume_up(self):
|
|
""" Increases volume. """
|
|
if self.volume < 1:
|
|
self.set_volume(min(self.volume+.1, 1))
|
|
|
|
def volume_down(self):
|
|
""" Decreases volume. """
|
|
if self.volume > 0:
|
|
self.set_volume(max(self.volume-.1, 0))
|
|
|
|
def refresh(self):
|
|
""" Refresh data at the RAMP-target. """
|
|
self._send_ramp({RAMP_ATTR_TYPE: RAMP_TYPE_INFO})
|
|
|
|
def _update_status(self, status):
|
|
""" Updates the RAMP status. """
|
|
con_inf = status.get(RAMP_ATTR_CONTENT_INFO, {})
|
|
|
|
self.state = status.get(RAMP_STATUS_ATTR_STATE, 0)
|
|
self.volume = status.get(RAMP_STATUS_ATTR_VOLUME, 1)
|
|
self.muted = status.get(RAMP_STATUS_ATTR_MUTED, False)
|
|
self.content_id = status.get(RAMP_STATUS_ATTR_CONTENT_ID)
|
|
self.title = status.get(RAMP_STATUS_ATTR_TITLE)
|
|
self.artist = con_inf.get(RAMP_STATUS_CONTENT_INFO_ATTR_ARTIST)
|
|
self.album = con_inf.get(RAMP_STATUS_CONTENT_INFO_ATTR_ALBUM_TITLE)
|
|
self._current_time = status.get(RAMP_STATUS_ATTR_CURRENT_TIME, 0)
|
|
self.duration = status.get(RAMP_STATUS_ATTR_DURATION, 0)
|
|
self.image_url = status.get(RAMP_STATUS_ATTR_IMAGE_URL)
|
|
self.time_progress = status.get(RAMP_ATTR_TIME_PROGRESS, False)
|
|
|
|
self.last_updated = dt.datetime.now()
|
|
|
|
@property
|
|
def current_time(self):
|
|
""" Returns current time of the content. """
|
|
|
|
# If time is progressing we have to calculate the current time based on
|
|
# time the status was retrieved and the then current time.
|
|
if self.time_progress:
|
|
timediff = dt.datetime.now() - self.last_updated
|
|
|
|
# pylint: disable=maybe-no-member
|
|
return min(self._current_time + timediff.seconds, self.duration)
|
|
|
|
else:
|
|
return self._current_time
|
|
|
|
def __repr__(self):
|
|
if self.state == RAMP_STATE_PLAYING:
|
|
state = "playing"
|
|
elif self.state == RAMP_STATE_STOPPED:
|
|
state = "stopped"
|
|
else:
|
|
state = "unknown state"
|
|
|
|
if self.title:
|
|
return "RampSubprotocol({}, {}/{}, {})".format(
|
|
self.title.encode("ascii", "replace"),
|
|
self.current_time, self.duration, state)
|
|
else:
|
|
return "RampSubprotocol({})".format(state)
|
|
|
|
|
|
KNOWN_PROTOCOLS = {
|
|
PROTOCOL_COMMAND: CommandSubprotocol,
|
|
PROTOCOL_RAMP: RampSubprotocol,
|
|
}
|