"""mems.instrument.network_server

Implements the network server that listens for incoming TCP/IP connections.

"""

# created 2001/09/24, AMK

import os, select, signal, socket, sys, time
import cStringIO, rfc822, asyncore

from email.MIMEBase import MIMEBase
from email.MIMEImage import MIMEImage
from email.MIMEText import MIMEText
from PIL import Image

from mems.instrument import protocols
from mems.instrument.server import token, util, codecs
from mems.instrument.server.configuration import get_configuration

__revision__ = "$Id: network_server.py,v 1.29 2002/04/19 16:29:16 akuchlin Exp $"


# Globals used to store the e-mail address and comments for the next
# high-resolution image to arrive.
email_address = None
email_comments = None


def send_mail (address, message):
    cmd = "/usr/lib/sendmail '%s'" % address
    pipe = os.popen(cmd, 'w')
    pipe.write(message)
    pipe.close()
    
class ErrorLogging:

    """Mix-in class to handle exceptions.  The exception is simply
    re-raised, terminating the asyncore main loop and propagating up
    to the logging code in process_controller.py.
    """
    
    def handle_error (self):
        t,v,tb = sys.exc_info()
        raise
    

class Client(ErrorLogging, asyncore.dispatcher_with_send):
    """Class representing a single client connection.

    Instance attributes:
      user_id : string
        ID of user on this connection
      authenticated : boolean
        whether the user has been authenticated
      privileges : [string]
        list of privileges provided in the authentication token
      frames_per_second : int
        number of frames per second that this client can handle (0 if
        there's no limit)
      last_sent : float
        time an image was last sent to this client
      image_sock : socket
        TCP connection for image data
  
    """
    
    def __init__ (self, network_server, connection):
        asyncore.dispatcher_with_send.__init__(self, connection)

        self.user_id = None
        self.authenticated = 0
        self.frames_per_second = 1
        self.privileges = []
        self.image_sock = None
        self.last_sent = None
        
        self.network_server = network_server
        self._line = ""


    def __repr__ (self):
        s = '<Client: %r, %r fps>' % (self.user_id, self.frames_per_second)
        return s
    
    def handle_command (self, line):
        # Handle a command sent by a client.
        config = get_configuration()
        cmd, args = protocols.parse_message_line(line)
        if config.get_debug() and cmd!='send_image':
            util.log('%s: Received command %s %r' % (__name__, cmd, args))
        if not self.authenticated:
            # Until the client is authenticated, the only legal command
            # is 'auth'.
            if cmd == 'auth':
                self.cmd_auth(**args)
            else:
                util.log("%s: Unauthenticated %r "
                          "attempted to send command %r %r"
                          % (__name__, self, cmd, args))
                return
        else:
            # Client is authenticated, so we'll dispatch
            meth = 'cmd_'+cmd
            if not hasattr(self, meth):
                util.log("%s: %r sent unknown command %r %r"
                          % (__name__, self, cmd, args))
                return
            meth = getattr(self, meth)
            meth(**args)


    def handle_read (self):
        data = self.recv(80)
        if data == "":
            # asyncore signals a closed connection by signalling a read
            # and returning an empty string.  This therefore means
            # the client has gone away, and can be deleted.
            util.log("%s: %r closed connection"
                      % (__name__, self) )
            self.network_server.delete_client(self)
            return

        # Add available data to the command line being built up.
        self._line += data
        
        # If the end of a line is reached, perform the command.
        while self._line:
            index = self._line.find('\n')
            if index == -1:
                break
            
            cmd = self._line[:index]
            self._line = self._line[index+1:]
            self.handle_command(cmd)

        # Guard against insanely long commands; when the line grows to
        # be more than 4K, throw it away and log the problem.
        if len(self._line) > 4096:
            msg = ("%s: %r sent overly long line\n"
                   "       first 25 bytes = %r" % (__name__, self,
                                                   self._line[:25]) )
            util.log(msg)
            self._line = ""

        
    def handle_write (self):
        if self.out_buffer:
            sent = self.socket.send(self.out_buffer)
            self.out_buffer = self.out_buffer[sent:]


    def handle_close (self):
        self.network_server.delete_client(self)


    def send_message (self, message):
        """send_message(message:strip)
        Send a message to the client.
        """
        message = message.strip() + '\n'
        self.send(message)
        config = get_configuration()
        if config.get_debug():
            util.log('Sending message %r to client %r' % (message,self))

    def parse_rfc822_info(self, header):
        file = cStringIO.StringIO( header )
        hdr = rfc822.Message( file )

        # Copy all the header lines as attributes
        # XXX should constrain to a list of legal header names!
        for k in hdr.keys():
            # Header names are translated into attributes by
            # lowercasing them and replacing '-' with '_'.
            # 
            attr = k.lower()
            attr = attr.replace('-', '_')

            setattr(self, attr, hdr[k])

        # XXX temporary hack to get name right
        self.user_id = self.name ; del self.name
        
        # Some attributes need special processing.
        
        # The privileges header is a comma-delimited list of strings,
        # so it needs to be broken up..
        self.privileges = self.privileges.split(',')

        # The archive_images header is "true" or "false"
        self.archive_images = (self.archive_images == 'true')


    def delete (self):
        """delete()
        Remove this client from its network server.
        """
        self.network_server.delete_client(self)
        
    #
    # Command handlers
    #

    def cmd_auth (self, value):
        # Check the validity of the authentication token
        try:
            infostring = token.verify_token(value)
        except ValueError:
            # Signature failed to verify
            # XXX what should we do here?
            util.log("%s: Auth. token from client %r "
                      "failed to verify" % (__name__, self))
            return
            
        self.parse_rfc822_info( infostring )
        self.authenticated = 1
        self.network_server.introduce_client(self)

        # If this is the first user to connect after a period
        # of inactivity, turn up the lights.
        # XXX implement this!
#        if len(self.clients) == 0:
#            self.manager.set(light = self.saved_light_intensity)

#        self.send_message('userlistadd '+ user.id,
#                          self.clients )

        util.log("%s: %r authenticated" % (__name__, self))


    def cmd_cede (self):
        # If someone else is in control, just return
        if (self.network_server.controller is not self):
            return

        self.network_server.clear_controller()


    def cmd_chatmsg (self, msg):
        # Secret hook to allow me to crash the server at will, in order to
        # test whether exception mailing works.
        if self.user_id == 'akuchlin' and msg == 'crash':
            1/0                         # Oops!  ZeroDivisionError!
        
        line = protocols.build_message_line('chatmsg',
                                           id=self.user_id, msg=msg)

        self.network_server.send_all(line)

    def cmd_frames (self, num_frames):
        val = int(num_frames)
        # Clip legal values to between 0 and 30 frames/second.
        if val < 0:
            val = 0
        elif val > 30:
            val = 30
        self.frames_per_second = val
                
    def cmd_image (self, x, y):
        msg = protocols.build_message_line('size', x=x, y=y)
        util.send_message('camera', msg)
        util.send_message('camera', 'snapshot')

        
    def cmd_measure (self, x1, y1, x2, y2):
        msg = protocols.build_message_line('measure',
                                           x1=x1, y1=y1,
                                           x2=x2, y2=y2)
        msg = "measure x1=%i y1=%i x2=%i y2=%i" % (x1, y1, x2, y2)
        self.network_server.send_all(msg)

    def cmd_move (self, x=None, y=None, z=None,
                  magnification=None, mode=None,
                  light=None, aperture=None):
        # If someone else is in control, just return
        if (self.network_server.controller is not self):
            return

        action = 0                      # Note whether anything was changed
        args = {}
        if x is not None: args['x'] = x
        if y is not None: args['y'] = y
        if z is not None: args['z'] = z
        if len(args):
            msg = protocols.build_message_line('move', **args)
            util.send_message('microscope', msg)
            action = 1
            
        if magnification is not None:
            msg = protocols.build_message_line('magnification',
                                               value=magnification)
            util.send_message('microscope', msg)
            action = 1
            
        if mode is not None:
            msg = protocols.build_message_line('mode',
                                               value=mode)
            util.send_message('microscope', msg)
            action = 1

        if aperture is not None:
            msg = protocols.build_message_line('aperture',
                                               value=aperture)
            util.send_message('microscope', msg)
            action = 1

        if light is not None:
            msg = protocols.build_message_line('light',
                                               value=light)
            util.send_message('microscope', msg)
            action = 1

        if not action:
            # If nothing was actually changed, force a microscope status
            # update and image update.
            util.send_message('microscope', 'move')
            util.send_message('camera', 'snapshot')


    def cmd_autofocus (self):
        msg = protocols.build_message_line('autofocus', value='on')
        util.send_message('microscope', msg)

        # Force a status update and snapshot.
        util.send_message('microscope', 'move')
        util.send_message('camera', 'snapshot')
            
        
    def cmd_quit (self):
        # Just delete this client from the server
        self.close()
        self.network_server.delete_client(self)


    def cmd_rectangle (self, x1, y1, x2, y2):
        msg = "rectangle x1=%i y1=%i x2=%i y2=%i" % (x1, y1, x2, y2)
        self.network_server.send_all(msg)
        
        
    def cmd_take (self):
        # If someone else is in control, just return
        if (self.network_server.controller is not None):
            return

        # Put this client in control
        self.network_server.set_controller(self)


    def cmd_image_socket (self, port):
        # Find the corresponding connection
        remote_host, remote_port = self.socket.getpeername()
        self.image_sock = self.network_server.find_connection(remote_host,
                                                              port)

    def cmd_email (self, address, comments=""):
        global email_address, email_comments
        email_address = address
        email_comments = comments
        util.send_message('camera', "hires")
        
    def cmd_feedback (self, comments=""):
	msg = """Subject: Microscope feedback (from user %s)
	
%s""" % (self.user_id, comments)
        send_mail('microscope-feedback@mems-exchange.org', msg)
    
        
class NetworkServer(ErrorLogging, asyncore.dispatcher):
    "Class containing the listening socket."
    
    def __init__ (self, image_network_server):
        asyncore.dispatcher.__init__(self)
        self.image_network_server = image_network_server
        self.config = get_configuration()
        self.clients = []

        # None, or the Client object that's currently in control
        # of the microscope.
        self.controller = None

        # Create codec for converting images to a series of packets
        self.codec = codecs.NullSerializer() 
        
        # Open the listener socket
        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
        self.set_reuse_addr()
        self.bind( ('', self.config.server.port) )
        self.set_reuse_addr()
        self.listen(5)

    def introduce_client (self, client):
        self.send_all('userlistadd id="%s"' % client.user_id)
        
        # Send the newly connected client some startup data: the list of
        # connected users, and who's in control (if anyone).
        for c in self.clients:
            if c is client: continue
            client.send_message('userlistadd id="%s"' % c.user_id)
        if self.controller is None:
            client.send_message('cede')
        else:
            client.send_message('taken id="%s"' % self.controller.user_id)

        # Force a microscope status update
        # XXX There's a possible irritation (or DoS?) attack here;
        # if someone connects and reconnects a lot, all the scope status
        # updates may be annoying.  Not sure this is worth fixing, though.
        util.send_message('microscope', 'move')


    def find_connection (self, host, port):
        return self.image_network_server.find_connection(host, port)

    
    def delete_client (self, client):
        client.close()
        if client in self.clients:
            self.clients.remove(client)
        if self.controller is client:
            self.clear_controller()
        if client.user_id is not None:
            self.send_all('userlistdelete id="%s"' % client.user_id)


    def clear_controller (self):
        self.controller = None
        self.send_all('cede')
        

    def set_controller (self, client):
        assert self.controller is None
        self.controller = client
        client.send_message('take')
        for c in self.clients:
            if not c.authenticated:
                continue
            if c is client:
                continue
            c.send_message('taken id=%s' % client.user_id)

        
    def handle_accept (self):
        conn, addr = self.accept()

        # XXX Restore this code for setting the send and receive queue lengths
        #conn.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, max_send_queue)
        #conn.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, max_recv_queue)

        util.log('network_server: accepted connection on fd %s from %r' %
                  (conn.fileno(), addr))
        self.clients.append(Client(self, conn))


    def handle_read (self):
        pass


    def handle_write (self):
        pass


    def send_all (self, message):
        """send_all(message:string)

        Send a message to all connected, authenticated clients.
        """
        for client in self.clients:
            if not client.authenticated:
                continue
            client.send_message(message)
    
    def send_image_packet (self):
        """send_image_packet(packet:string)

        Send a packet of image data to all connected, authenticated
        clients that have an image connection.
        """
        packet = self.codec.next()
        if packet is None:
            return
        
        msg = "Sending image packet to "
        count = 0
        for client in self.clients:
            if not client.authenticated or client.image_sock is None:
                continue

            # Apply check to reduce the number of images sent to a client:
            # if an image was sent too recently, just skip this client.
            now = time.time()
            if (client.frames_per_second > 0 and
                (client.last_sent is not None and
                 (now - client.last_sent) < (1.0/client.frames_per_second))):
                continue
                
            count += 1
            msg += repr(client) + ":"

            host, dummy = client.addr
            try:
                client.image_sock.send(packet)
            except socket.error:
                # error with the socket, so remove this client
                client.delete()
            client.last_sent = time.time()
                 
        if count == 0:
            msg += "no recipients"
	    return 
            
        if get_configuration().get_debug():
            util.log(msg)

            
    def load_image (self, path, hires):
        image = Image.open(path)
        self.codec.set_image(image)


    def handle_hires_image (self, path):
        global email_address, email_comments
        if email_address is not None:
            # Generate a MIME message
            msg = MIMEBase('multipart', 'mixed')
            msg['Subject'] = 'Subject: Microscope image\n'
            msg['From'] = ('From: %s server '
                           '<microscope-feedback@mems-exchange.org>\n'
                           % socket.getfqdn() )
            msg['To'] = email_address
            msg.preamble = 'This is a MIME-encoded message'
            msg.epilogue = ''

            # Attach a text message
            # XXX should probably word-wrap the comments here
            text = MIMEText(email_comments)
            msg.attach(text)
            
            # Attach the image
            fp = open(path, 'rb')
            img = MIMEImage(fp.read())
            fp.close()
            msg.attach(img)
            img['Content-type'] = 'image/jpeg'

            # Send the message
            send_mail(email_address, msg.as_string(unixfrom=0) )

        
class ImageNetworkServer(ErrorLogging, asyncore.dispatcher):
    "Class containing the listening socket."
    def __init__ (self):
        asyncore.dispatcher.__init__(self)
        self.config = get_configuration()
        self.clients = []
        
        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
        self.set_reuse_addr()
        self.bind( ('', self.config.server.port+2) )
        self.set_reuse_addr()
        self.listen(5)

    def handle_accept (self):
        conn, addr = self.accept()
        self.clients.append( (conn,addr) )
        
    def find_connection (self, host, port):
        """find_connection(host:string, port:integer)
        Search for a connection to the corresponding host and port and
        return it, removing it from this object's list of connected clients.
        """
        for i in range(len(self.clients)):
            conn, (h, p) = self.clients[i]
            if (h,p) == (host,port):
                del self.clients[i]
                return conn

        # Not found, so we'll just return None.
        return None


class FIFOdispatcher(ErrorLogging, asyncore.file_dispatcher):
    def __init__(self, fd, network_server):
        asyncore.file_dispatcher.__init__(self, fd)
        self._line = ""
        self.network_server = network_server
        
    def writable (self):
        # The FIFO we open in this module is read-only, so this method
        # can always return false.  If it returns true, then the FIFO's
        # file descriptor winds up in the list of writable fd's passed
        # to select(), and select() always returns it as being
        # writable, so we'd end up consuming 100% of the CPU.
        return 0
    
    def handle_read(self):
        data = self.recv(80)
        self._line += data

        # If the end of a line is reached, perform the command. 
        index = self._line.find('\n')
        if index != -1:
            config = get_configuration()
            cmd, args = protocols.parse_message_line(self._line[:index])
            self._line = self._line[index+1:]

            if config.get_debug():
                print '%s: Received command %s %r' % (__name__, cmd, args)

            self.handle_command(cmd, args)

    def handle_command (self, cmd, args):
        if cmd == "image":
            path, hires = args['path'], args.get('hires', 'no')
            if hires == 'no':
                # Regular snapshot image
                self.network_server.load_image(path, hires)
            else:
                self.network_server.handle_hires_image(path)
            
        elif cmd in ['scope']:
            # Some commands are simply reflected out to all connected clients.
            msg = protocols.build_message_line(cmd, **args)
            self.network_server.send_all(msg)
            
        elif cmd == "send_image":
            self.network_server.send_image_packet()
            
        elif cmd == "quit":
            util.log('%s: Received quit command; exiting' % __name__)
            os._exit(0)
            
        else:
            raise RuntimeError, ("%s: Unknown FIFO command %r args=%r"
                                 % (__name__, cmd, args))

    
        
def main():
    # Create server for image data socket
    image_network_server = ImageNetworkServer()
    
    # Create network server
    network_server = NetworkServer(image_network_server)

    # Register the FIFO with asyncore's main loop
    fifo_dispatcher = FIFOdispatcher(sys.stdin.fileno(),
                                     network_server)

    
    # Enter the polling loop
    asyncore.loop()
    
