Skip to content
NIHFAB edited this page Aug 13, 2020 · 20 revisions

Step-by-Step Tutorial

Table of Contents

Overview

Before using the code in this repository on a project, it is helpful to understand how information flows in the code. First, let’s look at how data might travel from the graphical user interface (GUI) to an embedded microcontroller (i.e. a Teensy or Arduino). Then we'll see how data travels in the opposite direction, from the microcontroller back to the GUI.

Data Transfer from GUI to Microcontroller

Say the user wants to send a single numerical value to a Teensy microcontroller over Bluetooth. Let’s see how the software sends the proportional gain (labeled ‘P Gain’) for a PID controller to the microcontroller.

1. User Text Entry

Suppose the user needs a text entry widget to enter a value, and additional text identifying what the entry widget is for. Here’s an example of what that looks like in the GUI:

Image of PGain Button + Text

In Python, a library called Tkinter is used to produce widgets like this for a GUI. For more information on the Tkinter library, see the Software Installation section for a link to further documentation. Here’s what the text entry widget looks like in Python code:

self.PGAIN = tk.Entry(self.uniconframe, width=8)
self.PGAIN.grid(row=0, column=1)
self.PGAIN.insert(END, "425")

The first line instantiates an ‘Entry’ class from the Tkinter library as ‘PGAIN.’ (Technically, PGAIN is a child class of the class TestingPage until TestingPage is instantiated, hence ‘self.PGAIN.’) The grid() command tells PGAIN where to appear on the page, and the insert() command inserts starting text in the box, in this case “425.”

Additionally, here’s what the text label that says ‘P Gain:’ looks like in code:

pLabel = tk.Label(self.uniconframe, text="P Gain: ")
pLabel.grid(row=0, column=0)

The ‘Label’ class is instantiated as “pLabel” and is told where to appear by grid().

2. User Control to Send Text

Next, the user needs a way to tell the software to send the data (a P Gain of 425) to the microcontroller. This is accomplished with a button, in this case, the ‘Set Gains’ button:

Image of Set Gains Button

Here’s what that looks like in code, again using the Tkinter library:

self.SETGAINS = Button(self.uniconframe, relief="groove", overrelief="raised")
self.SETGAINS["text"] = "Set Gains"
self.SETGAINS["fg"] = "blue"
self.SETGAINS["command"] = self.gains
self.SETGAINS.grid(row=0, column=2, sticky=W, padx=10)

Similar to before, the ‘Button’ class is instantiated as ‘SETGAINS’ as a child object of TestingPage(). The text in the button is set to “Set Gains,” the color is set to blue, and the button commands the function ‘gains.’

3. Function to Pull Text from Entry Widget and Send Text to Microcontroller

A nifty feature of the Tkinter library is that it can link buttons in the user interface to functions in the code. The “Set Gains” button in this example commands the gains() function. When the button is pressed by the user, the gains() function is called, executing some task. In this case, the gains() function (1) changes important states that execute universal control over the code, (2) sends a ‘g’ to tell the microcontroller to expect to receive values containing P, I, and D gains, (3) calls the function construct_gains_string(), and (4) calls send_data() again to actually send the P, I, and D gains.

def gains(self):
    # Function makes you mad swole. Run with care
    # To set gains when running controller tests.
    global buttons_state
    buttons_state = "off"
    data = "g"
    send_data(data)  # puts teensy into mode to set gains

    gains_data = construct_gains_string()  # send string with gains data
    send_data(gains_data)

    print(data)
    print("Gains data: " + gains_data)
    receive_data()

3a. Function to Encode Communication String

To really understand what the gains() function does, though, you need to understand the construct_gains_string() function it calls! In essence, it goes and look in the text entry widgets PGAIN, IGAIN, and DGAIN, uses .get() to acquire the values in those text entry widgets, and then squishes all of those values into a single string for transport. That string is called “settsStrG”, as seen in the return statement.

def construct_gains_string():
    """This function constructs the settings string 'settsStrG', which contains controller gains. For most controllers,
    these are PID gains. For adaptive control, these are weight, angle thresholds and desired assistance"""
    global settsStrG
    global test_button

    gains_menu_opt = main.p2.CONGAINSOPT.get()
    if gains_menu_opt == "Torque":
        gains_controller_opt = 6
    elif gains_menu_opt == "Impedance":
        gains_controller_opt = 7
    elif gains_menu_opt == "Adaptive":
        gains_controller_opt = 8
    elif gains_menu_opt == "Speed":
        gains_controller_opt = 9

    if gains_controller_opt == 6:  # torque controller
        controller_type = "g/6"
        kp_torq_s = str(main.p2.PGAIN.get())
        ip_torq_s = str(main.p2.IGAIN.get())
        dp_torq_s = str(main.p2.DGAIN.get())

        settsStrG = controller_type + "/" + kp_torq_s + "/" + ip_torq_s + "/" + dp_torq_s

… (more nested if logic)

return settsStrG

3b. Function to Send Data over Bluetooth

So now the user has entered a value for PGAIN, pressed a button to calls the gains() function, which in turn calls construct_gains_string() and organizes the P, I, and D gains into a single string. Now all that’s left is actually sending that data over to the microcontroller. That’s what the function send_data() (called in by the gains() function) does. It simply takes that string, encodes it using a utf-8 format, and either (1) writes it serially to a USB port or (2) uses a client/socket relationship to send the string over Bluetooth.

def send_data(data, prefix='Y', parse='Y',
              leg='B'):  # no parse for immediate commands, like stop, walking, standby, etc.
    """Universal function to send data, either to Bluetooth or wire.
    'Parse' adds a prefix of data length to the communication."""
    global comType
    # leg denotes with leg to send to; L = left, R = right, B = both
    if comType == 'Ser':
        if parse == 'Y':  # send length of data before data, and parse with ~ and >
            data = str(len(data)) + '~' + data + '>'
        dataB = bytes(data, encoding='utf-8')  # converts strings to binary
        if leg == 'B':
            ser.write(dataB)  # left
            ser1.write(dataB)  # right
        elif leg == 'L':  # L and R used for calibrating potentiometers
            ser.write(dataB)
        elif leg == 'R':
            ser1.write(dataB)

    elif (comType == 'BLE'):  # and (prefix == 'Y'):
        if parse == 'Y':
            data = str(len(data)) + '~' + data + '>'
        # dataP = ">" + data  # prefixes data with '>', as Arduino expects
        if leg == 'B':
            client_socket.send(data)  # left
            client_socket1.send(data)  # right
        elif leg == 'L':  # L and R used for calibrating potentiometers
            client_socket.send(data)
        elif leg == 'R':
            client_socket1.send(data)

And from there, the data is sent via a Bluetooth dongle to a receiving bluetooth modem wired to the microcontroller!

While this all might seem rather complicated at first, once you understand these fundamentals, you’ll begin to recognize patterns that you can efficiently adapt for your own project.

4. Function to Receive Data from Bluetooth on the Microcontroller

Of course, for this data to be useful, the microcontroller needs an equally sophisticated way to breakdown the encoded information. Let’s look at one implementation of that in AVR-C (Arduino programming).

In the main while loop of your Arduino code, you might have a function like this that looks for data from an input pin during every loop:

void update_settings() { // this function continuously checks for serial input or bluetooth input
  if (Serial.available()) {
    settsLen = Serial.readStringUntil(midMarker); // Python sends # of characters first (gets an int)
    settsLenI = settsLen.toInt();
    Serial.print("\nSettings Length: " + String(settsLenI) + "\n");
    settsStrM = Serial.readStringUntil(endMarker); //
    Serial.print("Settings String: " + settsStrM + "\n\n");
    parseSetts(settsLenI, settsStrM);
  }
  else if (BLE.available()) {
    settsLen = BLE.readStringUntil(midMarker);
    settsLenI = settsLen.toInt();
    settsStrM = BLE.readStringUntil(endMarker);
    parseSetts(settsLenI, settsStrM);
  }
}

When data is received, parseSetts() is called.

5. Function to Decode Communication String and Assign Values

Lastly, you need a function to decode that “squished” string of P, I, and D gains. In our implementation, we convert the string a character array, then tokenize the string using strtok(), which essentially picks the string apart using some delimiter.

void parseSetts(int len, String str) { // parses settings string.
  char strInChar[len]; // make character array of settings of proper length. len might not be necessary
  str.toCharArray(strInChar, len + 1);

... (Nested if logic to direct the values to the proper variables for assignment, depending on what kind of information was sent.)

      kp_torq_s = strtok(NULL, delim);
      update_double_b(kp_torq_s, kp_torq);
      allprint("P Gain: " + kp_torq_s + "\n");

And at long last, kp_torq_s, or the 'P Gain' is assigned on the microcontroller.

Data Transfer from Microcontroller to GUI

1. Write Data to Pin

In tandem with a Bluetooth modem, such as a BlueSMiRF, sending data over bluetooth from a micro-controller is relatively straightforward. In our implementation, we use a catch-all function to print to Bluetooth and serial ports at the same time:

void allprintln(String s) {
 Serial.println(s);
  BLE.print(s); BLE.print("\n");
}
void allprint(String s) {
 Serial.print(s);
  BLE.print(s); //BLE.print("\n");
}

Inside of a while loop, allprint() is called to send every piece of information, followed by a delimiter. For example:

while (entry != STOP) {
  allprint(a_variable); allprint("\t");
}

2. Receive and Parse Data (Python)

In the Python code, when the user presses the "Start Trial" button, the function receive_and_save_data() is called, which calls a function depending on if serial USB or Bluetooth communication is being used. In the case of Bluetooth data, receive_ble_data_and_send2LSL() is called, which contains a while loop that constantly looks for data:

while (L_state == 'rec' or R_state == 'rec') and buttons_state == 'on':
    # L & R states are changed by finding end communication characters; buttons_state is changed by 'stop' button
    main.update()

    # === receiving & decoding of data ====
    try:  # this statement should pass the .recv() call if .recv() receives 0 bytes.
        data_L = client_socket.recv(size)  # limits buffer size to 1 byte: controls data flow since whatever
        data_L = data_L.decode('utf-8')  # decodes characters according to utf-8
        received_data_L = received_data_L + data_L  # adds up characters until newline character is received

    except OSError as err:
        pass

    try:  # this statement should pass the .recv() call if it receives 0 bytes
        data_R = client_socket1.recv(size)
        data_R = data_R.decode('utf-8')
        received_data_R = received_data_R + data_R

    except OSError as err:
        pass

Later in the while loop, a piece of code checks to see if the communication is finished (noted by some 'end character'), after which the data is split using the delimiter.

    # === checks for end_string (end communication) and prints ====
    if end_string in received_data_L and L_state != 'fin':
        # send text to it's respective locations
        main.update()

        # splits lines into the 8 different data types being received
        data2SaveLL = received_data_L.split("\t")

        try:  # pushes samples to LSL
            data2SaveLL_Floats = [float(i) for i in data2SaveLL]  # converts a list of strings to floats
            outlet_LL.push_sample(data2SaveLL_Floats)
        except ValueError:
            trial_stop_L = True
            print("Ending trial...")

        if prompt_char in received_data_L and end_string in received_data_L:
            L_state = 'fin'

        received_data_L = ""

3. Save Data

In addition to parsing the data, the previous lines of code also save the data by pushing each piece to Lab Streaming Layer. Let's focus in on just a few lines of code:

# splits lines into the 8 different data types being received
data2SaveLL = received_data_L.split("\t")

try:  # pushes samples to LSL
    data2SaveLL_Floats = [float(i) for i in data2SaveLL]  # converts a list of strings to floats
    outlet_LL.push_sample(data2SaveLL_Floats)

These 4 lines do some really important work. First, .split() divides up the variable 'received_data_L' (the string of all data received thus far) into a list. Then the code using a for loop to convert each string in that list into a float. Lastly, the code pushes each float value into a stream of LSL, in this case, the outlet_LL stream. This stream will be shown as a variable in the LSL control panel. Check the checkbox next to this stream and select the directory and name the file properly where you'd like to save this data. Then click start button in the panel to start data saving. Click stop button in the panel to stop data saving.

Data will be saved in .xdf format and can be extracted using Matlab, here is an exemplary matlab script to extract the data using load_xdf function.

exo_trigger = load_xdf('trial1_condition1.xdf');

%left exoskeleton leg
exo_left_data = exo_trigger{1,1}.time_series;
exo_left_time = exo_trigger{1,1}.time_stamps(1,:) - exo_trigger{1,1}.time_stamps(1,1);
exo_left_trigger = exo_left_data(1,:);
exo_left_angle = exo_left_data(2,:);
exo_left_torq = exo_left_data(3,:);
exo_left_fsr = exo_left_data(4,:);
exo_left_fsm_state = exo_left_data(6,:);
exo_left_torq_set = exo_left_data(7,:);

%right exoskeleton leg
exo_right_data = exo_trigger{1,2}.time_series;
exo_right_time = exo_trigger{1,2}.time_stamps(1,:) - exo_trigger{1,2}.time_stamps(1,1);
exo_right_trigger = exo_right_data(1,:);
exo_right_angle = exo_right_data(2,:);
exo_right_torq = exo_right_data(3,:);
exo_right_fsr = exo_right_data(4,:);
exo_right_fsm_state = exo_right_data(6,:);
exo_right_torq_set = exo_right_data(7,:);

4. Display Data (Backend)

Lastly, data can be pulled from that LSL stream by a separate plotting application. Our implementation uses a C# interface designed in Unity, but any sort of plotting could be used. Visit the LSL page for more details.