suitcase scripting examples

The following samples can be browsed at suitcase_script/

suitcase_midi_demo.py

The following script opens both an Arduino serial port connection and a MIDI input port, then translates drum pad ‘note on’ events into motion commands. This is intended to be run from the command line and needs to configured for the specific ports on your machine using command line arguments. To see the current options:

python3 suitcase_midi_demo.py --help

A typical invocation under macOS with all options specified might look like this:

python3 suitcase_midi_demo.py --port /dev/cu.usbmodem1444301 --midi "MPD218 Port A" --verbose --debug

A typical invocation under Window with all options specified might look like this:

python.exe -u suitcase_midi_demo.py --port "COM5"  --midi "MPD218 0" --verbose --debug

The exact serial name for –port and MIDI device name for –midi depend on your system. One way to identify the Arduino is to launch the Arduino IDE. The MIDI devices can be enumerated from Python using the list_MIDI_ports.py utility.

The full code follows, but may also be downloaded from suitcase_midi_demo.py.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
#!/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/tty.usbmodem14101',
                        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()
    

    

suitcase_motion_demo.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#!/usr/bin/env python3

"""\
suitcase_motion_demo.py : sample code in Python to communicate with an Arduino running StepperWinch

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

"""

#================================================================
import argparse
import time

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

#================================================================
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 sconsole
    :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
        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 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

#================================================================
cmd_desc =  "Simple test client to send data to 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( '--extra', action='store_true', help='Unused.')
    parser.add_argument( '-p', '--port', default='/dev/tty.usbmodem14101',
                         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 and initialize the
    # client object.  Note that it will ignore extraneous values, e.g., from
    # additional command argument inputs unneeded by this object.
    client = StepperWinchClient(**vars(args))

    # Begin the motion sequence.  This may be safely interrupted by the user pressing Control-C.
    try:
        print("Waiting for wakeup.")
        client.wait_for_wakeup()
        client.set_freq_damping(1.0, 0.5)
        
        while True:
            print("Beginning movement sequence.")
            client.motor_enable()
            client.move_to([100, 200, 300, 400])
            client.move_to([0, 0, 0, 0])
            client.move_to([4000, 1000, 2000, 3000])
            client.move_to([0, 0, 0, 0])
            client.move_to([400, 300, 200, 100])
            client.move_to([0, 0, 0, 0])
            client.move_to([3000, 4000, 1000, 2000])
            client.move_to([0, 0, 0, 0])

    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()