From e97ce5d6761e993d6aec942ec106dd52e59b998a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 6 Jan 2019 02:19:53 +0100 Subject: [PATCH] Added Snapcast plugin --- platypush/plugins/music/snapcast.py | 479 ++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 platypush/plugins/music/snapcast.py diff --git a/platypush/plugins/music/snapcast.py b/platypush/plugins/music/snapcast.py new file mode 100644 index 000000000..84e257e53 --- /dev/null +++ b/platypush/plugins/music/snapcast.py @@ -0,0 +1,479 @@ +import json +import socket +import threading + +from platypush.plugins import Plugin, action + + +class MusicSnapcastPlugin(Plugin): + """ + Plugin to interact with a [Snapcast](https://github.com/badaix/snapcast) + instance, control clients mute status, volume, playback etc. + + See https://github.com/badaix/snapcast/blob/master/doc/json_rpc_api/v2_0_0.md + for further reference about the returned API types. + """ + + _DEFAULT_SNAPCAST_PORT = 1705 + _SOCKET_EOL = '\r\n'.encode() + + def __init__(self, host='localhost', port=_DEFAULT_SNAPCAST_PORT, + *args, **kwargs): + """ + :param host: Default Snapcast server host (default: localhost) + :type host: str + + :param port: Default Snapcast server control port (default: 1705) + :type port: int + """ + + super().__init__(*args, **kwargs) + + self.host = host + self.port = port + self._latest_req_id = 0 + self._latest_req_id_lock = threading.RLock() + + def _get_req_id(self): + with self._latest_req_id_lock: + self._latest_req_id += 1 + return self._latest_req_id + + def _connect(self, host=None, port=None): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host or self.host, port or self.port)) + return sock + + @classmethod + def _send(cls, sock, req): + if isinstance(req, dict): + req = json.dumps(req) + if isinstance(req, str): + req = req.encode() + if not isinstance(req, bytes): + raise RuntimeError('Unsupported type {} for Snapcast request: {}'. + format(type(req), req)) + + sock.send(req + cls._SOCKET_EOL) + + @classmethod + def _recv(cls, sock): + buf = b'' + while buf[-2:] != cls._SOCKET_EOL: + buf += sock.recv(1) + return json.loads(buf.decode().strip()).get('result') + + + def _get_group_by_client(self, sock, client): + for g in self._status(sock).get('groups', []): + clients = g.get('clients', []) + + for c in clients: + if client == c.get('id') or \ + client == c.get('host', {}).get('name') or \ + client == c.get('host', {}).get('ip'): + return g + + def _get_group(self, sock, group): + for g in self._status(sock).get('groups', []): + if group == g.get('id') or group == g.get('name'): + return g + + def _get_client(self, sock, client): + group = self._get_group_by_client(sock, client) + if not group: + return None + clients = group.get('clients', []) + return None if len(clients) == 0 else clients[0] + + def _status(self, sock): + request = { + 'id': self._get_req_id(), + 'jsonrpc':'2.0', + 'method':'Server.GetStatus' + } + + self._send(sock, request) + return self._recv(sock).get('server', {}) + + @action + def status(self, host=None, port=None): + """ + Get the status either of a Snapcast server + + :param host: Snapcast server to query (default: default configured host) + :type host: str + + :param port: Snapcast server port (default: default configured port) + :type port: int + + :returns: dict. Example:: + + "output": { + "groups": [ + { + "clients": [ + { + "config": { + "instance": 1, + "latency": 0, + "name": "", + "volume": { + "muted": false, + "percent": 96 + } + }, + "connected": true, + "host": { + "arch": "x86_64", + "ip": "YOUR_IP", + "mac": "YOUR_MAC", + "name": "YOUR_NAME", + "os": "YOUR_OS" + }, + "id": "YOUR_ID", + "lastSeen": { + "sec": 1546648311, + "usec": 86011 + }, + "snapclient": { + "name": "Snapclient", + "protocolVersion": 2, + "version": "0.15.0" + } + } + ], + "id": "YOUR_ID", + "muted": false, + "name": "", + "stream_id": "mopidy" + } + ], + "server": { + "host": { + "arch": "armv7l", + "ip": "", + "mac": "", + "name": "YOUR_NAME", + "os": "YOUR_OS" + }, + "snapserver": { + "controlProtocolVersion": 1, + "name": "Snapserver", + "protocolVersion": 1, + "version": "0.15.0" + } + }, + "streams": [ + { + "id": "mopidy", + "meta": { + "STREAM": "mopidy" + }, + "status": "playing", + "uri": { + "fragment": "", + "host": "", + "path": "/tmp/snapfifo", + "query": { + "buffer_ms": "20", + "codec": "pcm", + "name": "mopidy", + "sampleformat": "48000:16:2" + }, + "raw": "pipe:////tmp/snapfifo?buffer_ms=20&codec=pcm&name=mopidy&sampleformat=48000:16:2", + "scheme": "pipe" + } + } + ] + } + """ + + sock = None + + try: + sock = self._connect(host or self.host, port or self.port) + return self._status(sock) + finally: + try: sock.close() + except: pass + + + @action + def mute(self, client=None, group=None, mute=None, host=None, port=None): + """ + Set the mute status of a connected client or group + + :param client: Client name or ID to mute + :type client: str + + :param group: Group ID to mute + :type group: str + + :param mute: Mute status. If not set, the mute status of the selected + client/group will be toggled. + :type mute: bool + + :param host: Snapcast server to query (default: default configured host) + :type host: str + + :param port: Snapcast server port (default: default configured port) + :type port: int + """ + + if not client and not group: + raise RuntimeError('Please specify either a client or a group') + + sock = None + + try: + sock = self._connect(host or self.host, port or self.port) + request = { + 'id': self._get_req_id(), + 'jsonrpc':'2.0', + 'method': 'Group.SetMute' if group else 'Client.SetVolume', + 'params': {} + } + + if group: + group = self._get_group(sock, group) + cur_muted = group['muted'] + request['params']['id'] = group['id'] + request['params']['mute'] = not cur_muted if mute is None else mute + else: + client = self._get_client(sock, client) + cur_muted = client['config']['volume']['muted'] + request['params']['id'] = client['id'] + request['params']['volume'] = {} + request['params']['volume']['percent'] = client['config']['volume']['percent'] + request['params']['volume']['muted'] = not cur_muted if mute is None else mute + + self._send(sock, request) + return self._recv(sock) + finally: + try: sock.close() + except: pass + + @action + def volume(self, client, volume=None, delta=None, mute=None, host=None, + port=None): + """ + Set the volume of a connected client + + :param client: Client name or ID + :type client: str + + :param volume: Absolute volume to set between 0 and 100 + :type volume: int + + :param delta: Relative volume change in percentage (e.g. +10 or -10) + :type delta: int + + :param mute: Set to true or false if you want to toggle the muted status + :type mute: bool + + :param host: Snapcast server (default: default configured host) + :type host: str + + :param port: Snapcast server port (default: default configured port) + :type port: int + """ + + if volume is None and delta is None and mute is None: + raise RuntimeError('Please specify either an absolute volume or ' + + 'relative delta') + + sock = None + + try: + sock = self._connect(host or self.host, port or self.port) + request = { + 'id': self._get_req_id(), + 'jsonrpc':'2.0', + 'method': 'Client.SetVolume', + 'params': {} + } + + client = self._get_client(sock, client) + cur_volume = int(client['config']['volume']['percent']) + cur_mute = bool(client['config']['volume']['muted']) + + if volume is not None: + volume = int(volume) + elif delta is not None: + volume = cur_volume + int(delta) + + if volume is not None: + if volume > 100: volume = 100 + if volume < 0: volume = 0 + else: + volume = cur_volume + + if mute is None: + mute = cur_mute + + request['params']['id'] = client['id'] + request['params']['volume'] = {} + request['params']['volume']['percent'] = volume + request['params']['volume']['muted'] = mute + self._send(sock, request) + return self._recv(sock) + finally: + try: sock.close() + except: pass + + @action + def set_client_name(self, client, name, host=None, port=None): + """ + Set/change the name of a connected client + + :param client: Current client name or ID to rename + :type client: str + + :param name: New name + :type name: str + + :param host: Snapcast server (default: default configured host) + :type host: str + + :param port: Snapcast server port (default: default configured port) + :type port: int + """ + + sock = None + + try: + sock = self._connect(host or self.host, port or self.port) + request = { + 'id': self._get_req_id(), + 'jsonrpc':'2.0', + 'method': 'Client.SetName', + 'params': {} + } + + client = self._get_client(sock, client) + request['params']['id'] = client['id'] + request['params']['name'] = name + self._send(sock, request) + return self._recv(sock) + finally: + try: sock.close() + except: pass + + @action + def set_latency(self, client, latency, host=None, port=None): + """ + Set/change the latency of a connected client + + :param client: Client name or ID + :type client: str + + :param latency: New latency in milliseconds + :type latency: float + + :param host: Snapcast server (default: default configured host) + :type host: str + + :param port: Snapcast server port (default: default configured port) + :type port: int + """ + + sock = None + + try: + sock = self._connect(host or self.host, port or self.port) + request = { + 'id': self._get_req_id(), + 'jsonrpc':'2.0', + 'method': 'Client.SetLatency', + 'params': { + 'latency': latency + } + } + + client = self._get_client(sock, client) + request['params']['id'] = client['id'] + self._send(sock, request) + return self._recv(sock) + finally: + try: sock.close() + except: pass + + @action + def delete_client(self, client, host=None, port=None): + """ + Delete a client from the Snapcast server + + :param client: Client name or ID + :type client: str + + :param host: Snapcast server (default: default configured host) + :type host: str + + :param port: Snapcast server port (default: default configured port) + :type port: int + """ + + sock = None + + try: + sock = self._connect(host or self.host, port or self.port) + request = { + 'id': self._get_req_id(), + 'jsonrpc':'2.0', + 'method': 'Server.DeleteClient', + 'params': {} + } + + client = self._get_client(sock, client) + request['params']['id'] = client['id'] + self._send(sock, request) + return self._recv(sock) + finally: + try: sock.close() + except: pass + + @action + def group_set_clients(self, group, clients, host=None, port=None): + """ + Sets the clients for a group on a Snapcast server + + :param group: Group name or ID + :type group: str + + :param clients: List of client names or IDs + :type clients: list[str] + + :param host: Snapcast server (default: default configured host) + :type host: str + + :param port: Snapcast server port (default: default configured port) + :type port: int + """ + + sock = None + + try: + sock = self._connect(host or self.host, port or self.port) + request = { + 'id': self._get_req_id(), + 'jsonrpc':'2.0', + 'method': 'Group.SetClients', + 'params': { + 'clients': [] + } + } + + for client in clients: + client = self._get_client(sock, client) + request['params']['clients'].append(client['id']) + + self._send(sock, request) + return self._recv(sock) + finally: + try: sock.close() + except: pass + + +# vim:sw=4:ts=4:et: +