From d1a10219c77e8000e2a82e40376d08225f4e92f4 Mon Sep 17 00:00:00 2001 From: Anton Khirnov Date: Mon, 9 Aug 2010 18:18:01 +0200 Subject: mpdsocket: add a new asynchronous low-level MPD interface some parts are based on mpd library. --- nephilim/mpdsocket.py | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 nephilim/mpdsocket.py diff --git a/nephilim/mpdsocket.py b/nephilim/mpdsocket.py new file mode 100644 index 0000000..72d4ef9 --- /dev/null +++ b/nephilim/mpdsocket.py @@ -0,0 +1,288 @@ +# +# 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 PyQt4 import QtCore, QtNetwork +from PyQt4.QtCore import pyqtSignal as Signal + +class MPDError(Exception): + pass + +class ConnectionError(MPDError): + pass + +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 callbacks 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'%(unicode(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: + raise ConnectionError('Stale socket found.') + + 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(unicode, 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 + + self._cmd_queue.append(callback) + if self._is_idle: + self._sock.write('noidle\n') + self._sock.write(args[0]) + for arg in args[1:]: + self._sock.write((' "%s" '%self._escape(unicode(arg))).encode('utf-8')) + self._sock.write('\n') + def write_command_sync(self, *args): + """ + Send a command contained in args to MPD synchronously. An iterator over + response lines is returned. + """ + # XXX i don't really like this solution. can't it be done better? + self._logger.debug('Synchronously executing command:' + ' '.join(map(unicode, args))) + self._sock.blockSignals(True) + while not self._is_idle: + # finish all outstanding responses + if not self._sock.canReadLine(): + self._sock.waitForReadyRead() + self._handle_response() + + self._sock.write('noidle\n') + self._sock.waitForBytesWritten() + self._sock.waitForReadyRead() + self._parse_discard(self._read_response()) + + self._sock.write(args[0]) + for arg in args[1:]: + self._sock.write((' "%s" '%self._escape(unicode(arg))).encode('utf-8')) + self._sock.write('\n') + self._sock.waitForBytesWritten() + + while not self._sock.canReadLine(): + self._sock.waitForReadyRead() + for line in self._read_response(): + yield line + self._idle() + self._sock.blockSignals(False) + + 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): + """ + Read from the socket one line and return it, unless it's an + error or end of response. + """ + line = str(self._sock.readLine()).decode('utf-8').rstrip('\n') + if line.startswith(self.ERROR_PREFIX): + 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 = str(self._sock.readLine()) + 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('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(): + raise MPDError + 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](self._read_response()) + del self._cmd_queue[0] + self._idle() + + def _read_response(self): + """ + An iterator over all lines in one response. + """ + while self._sock.canReadLine(): + line = self._readline() + if not line: + raise StopIteration + yield line + if not self._sock.canReadLine() and not self._sock.waitForReadyRead(): + raise MPDError + + def _parse_discard(self, data): + """ + (Almost) silently discard response data. + """ + for line in data: + self._logger.debug('Ignoring line: %s.'%line) -- cgit v1.2.3