# # Copyright (C) 2010 Anton Khirnov # Copyright (C) 2008 J. Alexander Treuman # # Nephilim is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Nephilim is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Nephilim. If not, see . # import logging from PyQt5 import QtCore, QtNetwork from PyQt5.QtCore import pyqtSignal as Signal class MPDSocket(QtCore.QObject): """ A dumb TCP/domain socket wrapper -- has a very basic understanding of MPD protocol. The caller should make sure that all returned iterators are exhausted or the world will explode. """ #### PUBLIC #### # signals # """True is emitted when the socket succesfully connectes to MPD. False is emitted when it's disconnected.""" connect_changed = Signal(bool) """Emitted whenever MPD signals that some of its subsystems have changed. For a list of subsystems see MPD protocol documentation.""" subsystems_changed = Signal(list) # read-only """A tuple of (major, minor, micro).""" version = None #### PRIVATE #### _logger = None _sock = None """A list of (callback, report_errors) that are waiting for a response from MPD.""" _cmd_queue = None _is_idle = False # MPD strings SEPARATOR = ': ' HELLO_PREFIX = "OK MPD " ERROR_PREFIX = "ACK " SUCCESS = "OK" #### PUBLIC #### def __init__(self, parent = None): QtCore.QObject.__init__(self, parent) self._logger = logging.getLogger('%smpdsocket'%(str(parent) + "." if parent else "")) self._cmd_queue = [] self.version = (0, 0, 0) def connect_mpd(self, host, port = None): """ Connect to MPD at given host:port. If port is omitted, then connection using Unix domain sockets is used. """ self._logger.info('Connecting to MPD.') if self._sock: if self._sock.state() == QtNetwork.QAbstractSocket.ConnectedState: self._logger.warning('Already connected, disconnect first.') else: self._logger.warning('Stale socket found, discarding.') if not port: #assume Unix domain socket self._sock = QtNetwork.QLocalSocket(self) c = lambda host, port: self._sock.connectToServer(host) else: self._sock = QtNetwork.QTcpSocket(self) c = self._sock.connectToHost self._sock.disconnected.connect(self._finish_disconnect) self._sock.error.connect( self._handle_error) self._sock.readyRead.connect( self._finish_connect) c(host, port) def disconnect_mpd(self): """ Disconnect from MPD. """ self._logger.info('Disconnecting from MPD.') if self._sock: try: self._sock.disconnectFromHost() except AttributeError: self._sock.disconnectFromServer() def write_command(self, *args, **kwargs): """ Send a command contained in args to MPD. If a response from MPD is desired, then kwargs must contain a 'callback' memeber, which shall be called with an iterator over response lines as an argument. Otherwise the response is silently discarded. """ self._logger.debug('Executing command:' + ' '.join(map(str, args))) if 'callback' in kwargs: if callable(kwargs['callback']): callback = kwargs['callback'] else: self._logger.error('Supplied callback is not callable. Will discard data instead.') callback = self._parse_discard else: callback = self._parse_discard if 'report_errors' in kwargs: report_errors = bool(kwargs['report_errors']) else: report_errors = True self._cmd_queue.append((callback, report_errors)) if self._is_idle: self._sock.write(b'noidle\n') self._sock.write(args[0].encode('utf-8')) for arg in args[1:]: self._sock.write((' "%s" ' % self._escape(str(arg))).encode('utf-8')) self._sock.write(b'\n') def state(self): """ Return a socket state as one of the values in QtNetwork.QAbstractSocket.SocketState enum. """ if self._sock: return self._sock.state() return QtNetwork.QAbstractSocket.UnconnectedState #### PRIVATE #### def _escape(self, text): """ Escape backslashes and quotes before sending. """ return text.replace('\\', '\\\\').replace('"', '\\"') def _readline(self, report_errors = True): """ Read from the socket one line and return it, unless it's an error or end of response. """ line = self._sock.readLine().data().decode('utf-8').rstrip('\n') if line.startswith(self.ERROR_PREFIX): if report_errors: self._logger.error('MPD returned error: %s'%line) return if line == self.SUCCESS: return return line def _handle_error(self, error): """ Called on socket error signal, print it and disconnect. """ self._logger.error(self._sock.errorString()) self.disconnect_mpd() def _finish_connect(self): """ Called when a connection is established. Read hello and emit a corresponding signal. """ # wait until we can read MPD hello if not self._sock.canReadLine(): return line = self._sock.readLine().data().decode('utf-8') if not line.startswith(self.HELLO_PREFIX): self._logger.error('Got invalid MPD hello: %s' % line) return self.disconnect_mpd() self.version = tuple(map(int, line[len(self.HELLO_PREFIX):].strip().split('.'))) self._sock.readyRead.disconnect(self._finish_connect) self._logger.debug('Successfully connected to MPD, protocol version %s.'%('.'.join(map(str,self.version)))) self._sock.readyRead.connect(self._handle_response) self._idle() self.connect_changed.emit(True) def _finish_disconnect(self): """ Called when a socket has been disconnected. Reset the socket state and emit corresponding signal. """ self._sock = None self.version = None self._logger.debug('Disconnected from MPD.') self.connect_changed.emit(False) def _idle(self): """ Enter idle mode. """ self._logger.debug('Entering idle mode.') self._sock.write(b'idle\n') self._is_idle = True def _handle_response(self): """ Called when some data has arrived on the socket. Parse it according to current state either as change subsystems or a response to a command. Then reenter idle mode. """ if not self._sock.canReadLine(): return if self._is_idle: self._logger.debug('Exited idle mode, reading changed subsystems.') self._is_idle = False subsystems = [] line = self._readline() while line: parts = line.partition(self.SEPARATOR) if parts[1]: subsystems.append(parts[2]) self._logger.debug('Subsystem changed: %s'%parts[2]) else: self._logger.error('Malformed line: %s.'%line) if not self._sock.canReadLine() and not self._sock.waitForReadyRead(): self._logger.error('Reading server response timed out, disconnecting.') return self.disconnect_mpd() line = self._readline() if subsystems: self.subsystems_changed.emit(subsystems) while self._cmd_queue: # wait for more data if not self._sock.canReadLine(): return self._cmd_queue[0][0](self._read_response(self._cmd_queue[0][1])) del self._cmd_queue[0] self._idle() def _read_response(self, report_errors = True): """ An iterator over all lines in one response. """ while self._sock.canReadLine(): line = self._readline(report_errors) if not line: return yield line if not self._sock.canReadLine() and not self._sock.waitForReadyRead(): self._logger.error('Reading server response timed out, disconnecting.') self.disconnect_mpd() def _parse_discard(self, data): """ (Almost) silently discard response data. """ for line in data: self._logger.debug('Ignoring line: %s.'%line)