#!/usr/bin/env python3

"""\
suitcase_midi_demo.py : sample code in Python to translate MIDI events into
motion commands for an Arduino running StepperWinch

No copyright, 2021, Garth Zeglin.  This file is explicitly placed in the public domain.

"""

#================================================================

# Standard Python modules.
import argparse
import time
import platform

# This requires a pySerial installation.
#  Package details: https://pypi.python.org/pypi/pyserial,
#  Documentation: http://pythonhosted.org/pyserial/
import serial

# For documentation on python-rtmidi: https://pypi.org/project/python-rtmidi/
import rtmidi

#================================================================
class StepperWinchClient(object):
    """Class to manage a connection to a serial-connected Arduino running the
    StepperWinch script.
    
    :param port: the name of the serial port device
    :param verbose: flag to increase console output
    :param debug: flag to print raw inputs on console
    :param kwargs: collect any unused keyword arguments

    """
    
    def __init__(self, port=None, verbose=False, debug=False, **kwargs ):
        # initialize the client state
        self.arduino_time = 0
        self.position = [0, 0, 0]
        self.target   = [0, 0, 0]
        self.verbose = verbose
        self.debug   = debug
        self.awake   = False

        # open the serial port, which should also reset the Arduino
        if self.verbose:
            print(f"Opening Arduino on {port}")
            
        self.port = serial.Serial( port, 115200, timeout=5 )
       
        if self.verbose:
            print("Opened serial port named", self.port.name)
            print("Sleeping briefly while Arduino boots...")

        # wait briefly for the Arduino to finish booting
        time.sleep(2)   # units are seconds

        # throw away any extraneous input
        self.port.flushInput()
        
        return
    
    def close(self):
        """Shut down the serial connection to the Arduino, after which this object may
        no longer be used."""
        self.port.close()
        self.port = None
        return
    
    def _wait_for_input(self):
        line = self.port.readline().rstrip()
        
        if line:
            elements = line.split()
            if self.debug:
                print("Received: '%s'" % line)

            if elements[0] == b'txyza':
                self.arduino_time = int(elements[1])
                self.position = [int(s) for s in elements[2:]]

            elif elements[0] == b'awake':
                self.awake = True
                
            elif elements[0] == b'dbg':
                print("Received debugging message:", line)
                
            else:
                print("Unknown status message: ", line)
            
        return

    def _send_command(self, string):
        if self.verbose:
            print("Sending: ", string)
        self.port.write(string.encode() + b'\n')
        return

    def motor_enable( self, value=True):
        """Issue a command to enable or disable the stepper motor drivers."""
        
        self._send_command( "enable 1" if value is True else "enable 0" )
        return

    def set_freq_damping(self, freq, damping):
        """Issue a command to set the second-order model gains."""
        self._send_command("g xyza %f %f" % (freq, damping))
        return
            
    def wait_for_wakeup(self):
        """Issue a status query and wait until an 'awake' status has been received."""
        while self.awake is False:
            self._send_command("ping")
            self._wait_for_input()

    def relative_move(self, flags, positions):
        """Issue a non-blocking relative move command for a subset of channels defined
        by a set of flag characters."""
        
        values = " ".join(["%d" % (i) for i in positions])
        self._send_command("d %s %s" % (flags, values))

    def move_to(self, position):
        """Issue a command to move to a [x, y, z, a] absolute position (specified in
        microsteps) and wait until completion.

        :param position: a list or tuple with at least four elements

        """
        
        self._send_command("a xyza %d %d %d %d" % tuple(position))
        self.target = position

        # wait for all reported positions to be close to the request
        moving = True
        while moving:
            self._wait_for_input()
            if self.verbose:
                print ("Position:", self.position)
            moving = any([abs(pos - target) > 5 for pos, target in zip(self.position, self.target)])

        return

################################################################
class MidiDriver(object):
    """Class to manage a MIDI input connection and translate events to motion commands."""
    
    def __init__(self, client, args):

        # save the client handle
        self.client = client

        if args.verbose:
            print(f"Opening MIDI input to look for {args.midi}.")
        
        # Initialize the MIDI input system and read the currently available ports.
        self.midi_in = rtmidi.MidiIn()
        
        for idx, name in enumerate(self.midi_in.get_ports()):
            if args.midi in name:
                print("Found preferred MIDI input device %d: %s" % (idx, name))
                self.midi_in.open_port(idx)
                self.midi_in.set_callback(self.midi_received)
                break
            else:
                print("Ignoring unselected MIDI device: ", name)

        if not self.midi_in.is_port_open():
            if platform.system() == 'Windows':
                print("Virtual MIDI inputs are not currently supported on Windows, see python-rtmidi documentation.")
            else:
                print("Creating virtual MIDI input.")
                self.midi_in.open_virtual_port(args.midi)

    def midi_received(self, data, unused):
        msg, delta_time = data
        if len(msg) > 2:
            if msg[0] == 153: # note on, channel 9
                key = (msg[1] - 36) % 16
                row = key // 4
                col = key % 4
                velocity = msg[2]
                print("Pad (%d, %d): %d" % (row, col, velocity))
                delta = velocity if row > 1 else -velocity
                if row == 0 or row == 3:
                    delta = delta * 2
                flag = "xyza"[col]

                # send a single-channel relative move command
                self.client.relative_move(flag, [delta])

#================================================================
cmd_desc = "Translate MIDI events to motion commands for the StepperWinch firmware on an Arduino."

# The following section is run when this is loaded as a script.
if __name__ == "__main__":

    # Initialize the command parser.
    parser = argparse.ArgumentParser(description = cmd_desc)
    parser.add_argument('-v', '--verbose', action='store_true', help='Enable more detailed output.' )
    parser.add_argument('--debug', action='store_true', help='Enable debugging output.' )
    parser.add_argument('--midi', type=str, default = "MPD218 Port A",
                        help = "Keyword identifying the MIDI input device (default: %(default)s).")
    
    parser.add_argument('-p', '--port', default='/dev/cu.usbmodem14301',
                        help='Specify the name of the Arduino serial port device (default is %(default)s).')

    # Parse the command line, returning a Namespace.
    args = parser.parse_args()

    # Convert the Namespace to a set of keyword arguments.
    kwargs = vars(args)

    # Connect to the Arduino. Note that it will ignore extraneous values, e.g.,
    # from additional command argument inputs unneeded by this object.
    client = StepperWinchClient(**kwargs)

    # Connect to the MIDI port.
    driver = MidiDriver(client, args)
    
    # Enter the event loop.  MIDI events will arrive at the callback and trigger motion commands.
    try:
        print("Waiting for wakeup.")
        client.wait_for_wakeup()
        client.set_freq_damping(1.0, 0.5)

        while True:
            client._wait_for_input()

    except KeyboardInterrupt:
        print("User interrupted motion.")

    # Issue a command to turn off the drivers, then shut down the connection.
    client.motor_enable(False)
    client.close()
    

    

