-
Notifications
You must be signed in to change notification settings - Fork 0
Home
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.
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.
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:
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().
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:
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.’
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()
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
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.
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.
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.
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");
}
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 = ""
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,:);
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.