1
+ from pylsl import StreamInfo , StreamOutlet # For LSL (Lab Streaming Layer) to stream data
2
+ import argparse # For command-line argument parsing
3
+ import serial # For serial communication with Arduino
4
+ import time # For time-related functions
5
+ import csv # For handling CSV file operations
6
+ from datetime import datetime # For getting current timestamps
7
+ import serial .tools .list_ports # To list available serial ports
8
+ import numpy as np # For handling numeric arrays
9
+ import sys
10
+ import signal
11
+
12
+ # Initialize global variables for tracking and processing data
13
+ total_packet_count = 0 # Total packets received in the last second
14
+ cumulative_packet_count = 0 # Total packets received in the last 10 minutes
15
+ start_time = None # Track the start time for packet counting
16
+ last_ten_minute_time = None # Track the last 10-minute interval
17
+ previous_sample_number = None # Store the previous sample number for detecting missing samples
18
+ missing_samples = 0 # Count of missing samples due to packet loss
19
+ buffer = bytearray () # Buffer for storing incoming raw data from Arduino
20
+ samples_per_second = 0 # Number of samples received per second
21
+ retry_limit = 4
22
+
23
+ # Initialize gloabal variables for Arduino Board
24
+ board = "" # Variable for Connected Arduino Board
25
+ supported_boards = {"UNO-R3" :250 , "UNO-R4" :500 , "UNO-CLONE" : 250 ,"RPI-PICO-RP2040" :500 , "GIGA-R1" :500 } # Boards : Sampling rate
26
+
27
+ # Initialize gloabal variables for Incoming Data
28
+ SYNC_BYTE1 = 0xc7 # First byte of sync marker
29
+ SYNC_BYTE2 = 0x7c # Second byte of sync marker
30
+ HEADER_LENGTH = 4 #Length of the Packet Header
31
+
32
+ ## Initialize gloabal variables for Output
33
+ lsl_outlet = None # Placeholder for LSL stream outlet
34
+ verbose = False # Flag for verbose output mode
35
+ csv_filename = None # Store CSV filename
36
+ csv_file = None
37
+ ser = None
38
+ packet_length = 0
39
+ num_channels = 0
40
+
41
+ def decode_byte (data ):
42
+ # Extract the channel count (high nibble)
43
+ num_channels = ((data >> 4 ) & 0x0F ) + 1
44
+
45
+ # Extract the ADC resolution (low nibble)
46
+ adc_resolution = (data & 0x0F ) + 10
47
+
48
+ return num_channels , adc_resolution
49
+
50
+ def send_command (ser , command ):
51
+ ser .flushInput () # Clear the input buffer
52
+ ser .flushOutput () # Clear the output buffer
53
+ ser .write (f"{ command } \n " .encode ()) # Send command
54
+ time .sleep (0.1 ) # Wait briefly to ensure Arduino processes the command
55
+ response = ser .readline ().decode ('utf-8' , errors = 'ignore' ).strip () # Read response
56
+ return response
57
+
58
+ def connect_hardware (port , baudrate , timeout = 1 ):
59
+ global board , sampling_rate , num_channels , packet_length , buffer , data , mid_value
60
+ try :
61
+ ser = serial .Serial (port , baudrate = baudrate , timeout = timeout ) # Open serial port
62
+ print (f"Attempting to connect to hardware on { port } at { baudrate } baud." )
63
+
64
+ # Check board type
65
+ response = None
66
+ retry_counter = 0
67
+ while retry_counter < retry_limit :
68
+ ser .write (b'WHORU\n ' ) # Send command to identify the board
69
+ response = ser .readline ().strip ().decode () # Read response
70
+ if response in supported_boards :
71
+ board = response
72
+ print (f"Board detected: { board } " )
73
+ sampling_rate = supported_boards [board ]
74
+ break
75
+ retry_counter += 1
76
+
77
+ if response not in supported_boards :
78
+ print ("Error: Unable to detect a supported board!" )
79
+ ser .close ()
80
+ return None
81
+
82
+ # Initialize variables
83
+ buffer = bytearray ()
84
+ sync_detected = False
85
+ send_command (ser , 'START' ) # Start streaming
86
+
87
+ while not sync_detected :
88
+ raw_data = ser .read (ser .in_waiting or 1 )
89
+ if not raw_data :
90
+ send_command (ser , 'START' ) # Resend start command if no data received
91
+ continue
92
+
93
+ buffer .extend (raw_data ) # Add raw data to the buffer
94
+
95
+ # Find synchronization bytes
96
+ sync_index = buffer .find (bytes ([SYNC_BYTE1 , SYNC_BYTE2 ]))
97
+ if sync_index != - 1 and len (buffer ) >= sync_index + HEADER_LENGTH :
98
+ header = buffer [sync_index :sync_index + HEADER_LENGTH ]
99
+ if header [0 ] == SYNC_BYTE1 and header [1 ] == SYNC_BYTE2 :
100
+ sync_detected = True
101
+
102
+ # Decode header information
103
+ num_channels , adc_resolution = decode_byte (header [3 ])
104
+ print (f"Number of channels: { num_channels } " )
105
+ packet_length = (2 * num_channels ) + HEADER_LENGTH
106
+ print (f"Packet length: { packet_length } " )
107
+
108
+ adc_max_value = (2 ** adc_resolution ) - 1
109
+ mid_value = adc_max_value // 2
110
+
111
+ # Remove processed data from the buffer
112
+ buffer = buffer [sync_index + HEADER_LENGTH :]
113
+ print ("Synchronization successful. Starting data processing." )
114
+ return ser
115
+
116
+ print ("Error: Failed to synchronize with the board." )
117
+ ser .close ()
118
+ return None
119
+
120
+ except (OSError , serial .SerialException ) as e :
121
+ print (f"Error: { e } " )
122
+ return None
123
+
124
+ # Function to automatically detect the Arduino's serial port
125
+ def detect_hardware (baudrate , timeout = 1 ):
126
+ ports = serial .tools .list_ports .comports () # List available serial ports
127
+ ser = None
128
+ for port in ports : # Iterate through each port
129
+ ser = connect_hardware (port .device , baudrate )
130
+ if ser is not None :
131
+ return ser
132
+ print ("Unable to detect hardware!" ) # Notify if no Arduino is found
133
+ return None # Return None if not found
134
+
135
+ def read_arduino_data (ser , csv_writer = None , inverted = False ):
136
+ global total_packet_count , cumulative_packet_count , previous_sample_number , missing_samples , buffer , data , num_channels , packet_length
137
+
138
+ raw_data = ser .read (ser .in_waiting or 1 )
139
+ if raw_data == b'' :
140
+ send_command (ser , 'START' )
141
+ buffer .extend (raw_data )
142
+
143
+ while len (buffer ) >= packet_length : # Continue processing if the buffer has at least one full packet
144
+ sync_index = buffer .find (bytes ([SYNC_BYTE1 , SYNC_BYTE2 ]))
145
+ packet = buffer [sync_index :sync_index + packet_length ] # Extract the packet
146
+ data = np .zeros ((num_channels , 2000 ))
147
+ if len (packet ) == packet_length and packet [0 ] == SYNC_BYTE1 and packet [1 ] == SYNC_BYTE2 :
148
+ if start_time is None :
149
+ start_timer () # Start timers for logging
150
+
151
+ # Extract and process packet data
152
+ counter = packet [2 ]
153
+ if previous_sample_number is not None and counter != (previous_sample_number + 1 ) % 256 :
154
+ missing_samples += (counter - previous_sample_number - 1 ) % 256 # Calculate missing samples
155
+ if verbose :
156
+ print (f"Error: Expected counter { previous_sample_number + 1 } but received { counter } . Missing samples: { missing_samples } " )
157
+
158
+ previous_sample_number = counter
159
+ total_packet_count += 1
160
+ cumulative_packet_count += 1
161
+
162
+ # Extract channel datae
163
+ channel_data = []
164
+ for channel in range (num_channels ):
165
+ high_byte = packet [HEADER_LENGTH + 2 * channel ]
166
+ low_byte = packet [HEADER_LENGTH + 2 * channel + 1 ]
167
+ value = (high_byte << 8 ) | low_byte
168
+ if inverted : # Apply inversion if the flag is set
169
+ if value > mid_value :
170
+ inverted_data = (mid_value ) - abs (mid_value - value )
171
+ elif value < mid_value :
172
+ inverted_data = (mid_value ) + abs (mid_value - value )
173
+ else :
174
+ inverted_data = value
175
+ channel_data .append (float (inverted_data )) # Append the inverted value
176
+ else :
177
+ channel_data .append (float (value )) # Convert to float and add to channel data
178
+
179
+ if csv_writer :
180
+ csv_writer .writerow ([counter ] + channel_data )
181
+ if lsl_outlet :
182
+ lsl_outlet .push_sample (channel_data )
183
+
184
+ data = np .roll (data , - 1 , axis = 1 )
185
+ data [:, - 1 ] = channel_data
186
+
187
+ del buffer [: packet_length ]
188
+ else :
189
+ del buffer [:sync_index + 1 ]
190
+
191
+ # Function to start timers for logging data
192
+ def start_timer ():
193
+ global start_time , last_ten_minute_time , total_packet_count , cumulative_packet_count
194
+ current_time = time .time () # Get the current time
195
+ start_time = current_time # Set the start time for packet counting
196
+ last_ten_minute_time = current_time # Set the start time for 10-minute interval logging
197
+ total_packet_count = 0 # Initialize total packet count
198
+ cumulative_packet_count = 0 # Initialize cumulative packet count
199
+
200
+ # Function to log data every second
201
+ def log_one_second_data (verbose = False ):
202
+ global total_packet_count , samples_per_second
203
+ samples_per_second = total_packet_count # Update the samples per second
204
+ if verbose :
205
+ print (f"Data count for the last second: { total_packet_count } samples, Missing samples: { missing_samples } " ) # Print verbose output
206
+ total_packet_count = 0 # Reset total packet count for the next second
207
+
208
+ # Function to log data for 10-minute intervals
209
+ def log_ten_minute_data (verbose = False ):
210
+ global cumulative_packet_count , last_ten_minute_time
211
+ if verbose :
212
+ print (f"Total data count after 10 minutes: { cumulative_packet_count } " ) # Print cumulative data count
213
+ sampling_rate = cumulative_packet_count / (10 * 60 ) # Calculate sampling rate
214
+ print (f"Sampling rate: { sampling_rate :.2f} samples/second" ) # Print sampling rate
215
+ expected_sampling_rate = supported_boards [board ] # Expected sampling rate
216
+ drift = ((sampling_rate - expected_sampling_rate ) / expected_sampling_rate ) * 3600 # Calculate drift
217
+ print (f"Drift: { drift :.2f} seconds/hour" ) # Print drift
218
+ cumulative_packet_count = 0 # Reset cumulative packet count
219
+ last_ten_minute_time = time .time () # Update the last 10-minute interval start time
220
+
221
+ # Main function to parse command-line arguments and handle data acquisition
222
+ def parse_data (ser , lsl_flag = False , csv_flag = False , verbose = False , run_time = None , inverted = False ):
223
+ global total_packet_count , cumulative_packet_count , start_time , lsl_outlet , last_ten_minute_time , csv_filename , num_channels
224
+
225
+ csv_writer = None # Placeholder for CSV writer
226
+ csv_file = None
227
+
228
+ # Start LSL streaming if requested
229
+ if lsl_flag :
230
+ lsl_stream_info = StreamInfo ('BioAmpDataStream' , 'EXG' , num_channels , supported_boards [board ], 'float32' , 'UpsideDownLabs' ) # Define LSL stream info
231
+ lsl_outlet = StreamOutlet (lsl_stream_info ) # Create LSL outlet
232
+ print ("LSL stream started" ) # Notify user
233
+
234
+ if csv_flag :
235
+ csv_filename = f"chordspy-{ datetime .now ().strftime ('%Y%m%d-%H%M%S' )} .csv" # Create timestamped filename
236
+ print (f"CSV recording started. Data will be saved to { csv_filename } " ) # Notify user
237
+
238
+ try :
239
+ csv_file = open (csv_filename , mode = 'w' , newline = '' ) if csv_flag else None # Open CSV file if logging is
240
+ if csv_file :
241
+ csv_writer = csv .writer (csv_file ) # Create CSV writer
242
+ csv_writer .writerow ([f"Arduino Board: { board } " ])
243
+ csv_writer .writerow ([f"Sampling Rate (samples per second): { supported_boards [board ]} " ])
244
+ csv_writer .writerow ([]) # Blank row for separation
245
+ csv_writer .writerow (['Counter' , 'Channel1' , 'Channel2' , 'Channel3' , 'Channel4' , 'Channel5' , 'Channel6' ]) # Write header
246
+
247
+ while True :
248
+ read_arduino_data (ser , csv_writer , inverted = inverted ) # Read and process data from Arduino
249
+ if (start_time is not None ):
250
+ current_time = time .time () # Get the current time
251
+ elapsed_time = current_time - start_time # Time elapsed since the last second
252
+ elapsed_since_last_10_minutes = current_time - last_ten_minute_time # Time elapsed since the last 10-minute interval
253
+
254
+ if elapsed_time >= 1 :
255
+ log_one_second_data (verbose )
256
+ start_time = current_time
257
+
258
+ if elapsed_since_last_10_minutes >= 600 :
259
+ log_ten_minute_data (verbose )
260
+
261
+ if run_time and current_time >= end_time :
262
+ print ("Runtime Over, Sending STOP Command..." )
263
+ send_command (ser , 'STOP' )
264
+ break
265
+
266
+ except KeyboardInterrupt :
267
+ print ("Process interrupted by user" )
268
+
269
+ finally :
270
+ cleanup ()
271
+
272
+ print (f"Total missing samples: { missing_samples } " )
273
+ sys .exit (0 )
274
+
275
+ def cleanup ():
276
+ global ser , lsl_outlet , csv_file
277
+
278
+ # Close the serial connection first
279
+ try :
280
+ if ser is not None and ser .is_open :
281
+ send_command (ser , 'STOP' ) # Ensure the STOP command is sent
282
+ time .sleep (1 )
283
+ ser .reset_input_buffer () # Clear the input buffer
284
+ ser .reset_output_buffer () # Clear the output buffer
285
+ ser .close () # Close the serial port
286
+ print ("Serial connection closed." )
287
+ else :
288
+ print ("Serial connection is not open." )
289
+ except Exception as e :
290
+ print (f"Error while closing serial connection: { e } " )
291
+
292
+ # Close the LSL stream if it exists
293
+ try :
294
+ if lsl_outlet :
295
+ print ("Closing LSL Stream." )
296
+ lsl_outlet = None # Cleanup LSL outlet
297
+ except Exception as e :
298
+ print (f"Error while closing LSL stream: { e } " )
299
+
300
+ # Close the CSV file if it exists
301
+ try :
302
+ if csv_file :
303
+ csv_file .close () # Close the CSV file
304
+ print ("CSV recording saved." )
305
+ except Exception as e :
306
+ print (f"Error while closing CSV file: { e } " )
307
+
308
+ print ("Cleanup completed, exiting program." )
309
+ print (f"Total missing samples: { missing_samples } " )
310
+ sys .exit (0 )
311
+
312
+ def signal_handler (sig , frame ):
313
+ cleanup ()
314
+
315
+ # Main entry point of the script
316
+ def main ():
317
+ global verbose ,ser
318
+ parser = argparse .ArgumentParser (description = "Upside Down Labs - Chords-Python Tool" ,allow_abbrev = False ) # Create argument parser
319
+ parser .add_argument ('-p' , '--port' , type = str , help = "Specify the COM port" ) # Port argument
320
+ parser .add_argument ('-b' , '--baudrate' , type = int , default = 230400 , help = "Set baud rate for the serial communication" ) # Baud rate
321
+ parser .add_argument ('--csv' , action = 'store_true' , help = "Create and write to a CSV file" ) # CSV logging flag
322
+ parser .add_argument ('--lsl' , action = 'store_true' , help = "Start LSL stream" ) # LSL streaming flag
323
+ parser .add_argument ('-v' , '--verbose' , action = 'store_true' , help = "Enable verbose output with statistical data" ) # Verbose flag
324
+ parser .add_argument ('-t' , '--time' , type = int , help = "Run the program for a specified number of seconds and then exit" ) #set time
325
+ parser .add_argument ('--inverted' , action = 'store_true' , help = "Invert the signal before streaming LSL and logging" ) # Inverted flag
326
+
327
+ args = parser .parse_args () # Parse command-line arguments
328
+ verbose = args .verbose # Set verbose mode
329
+
330
+ # Register the signal handler to handle Ctrl+C
331
+ signal .signal (signal .SIGINT , signal_handler )
332
+
333
+ # Check if any logging or GUI options are selected, else show help
334
+ if not args .csv and not args .lsl :
335
+ parser .print_help () # Print help if no options are selected
336
+ return
337
+
338
+ if args .port :
339
+ print ("trying to connect to port:" , args .port )
340
+ ser = connect_hardware (port = args .port , baudrate = args .baudrate )
341
+ else :
342
+ ser = detect_hardware (baudrate = args .baudrate )
343
+
344
+ if ser is None :
345
+ print ("Arduino port not specified or detected. Exiting." ) # Notify if no port is available
346
+ return
347
+
348
+ # Start data acquisition
349
+ parse_data (ser , lsl_flag = args .lsl , csv_flag = args .csv , verbose = args .verbose , run_time = args .time , inverted = args .inverted )
350
+
351
+ # Run the main function if this script is executed
352
+ if __name__ == "__main__" :
353
+ main ()
0 commit comments