Jump to content

Contributions:SerialWidgetADC

From BCI2000 Wiki

Synopsis

An ADC that allows signals to be acquired from programmable serial-port devices such as the Arduino, Teensy, Pico, etc.


Location

http://www.bci2000.org/svn/trunk/src/contrib/SignalSource/SerialWidget


Versioning

Authors

Jeremy Hill (hill@neurotechcenter.org)

Version History

  • 2023-09-22: Initial public release

Source Code Revisions

  • Initial development: r7613
  • Known to compile under: r8888
  • Broken since: --

Functional Description

The SerialWidgetADC filter is the key component of the SerialWidget source module, and can acquire signals from a serial-port-equipped embedded system such as an Arduino, Teensy or Pico microcontroller (hereafter referred to as a "widget").

This ADC includes all the functionality of the SerialInterface Extension (which allows widgets to be used alongside other source acquisition hardware, providing auxiliary input and output). The difference is that the ADC allows the primary signal to be acquired from the widget as well. This provides cheap platform possibilities for realtime testing during development of BCI2000 setups (as an alternative to the SignalGenerator, SoundcardSource or FilePlayback modules) and also allows BCI2000 to be used as a development tool in microcontroller programming (when you need higher data rates, better timing stability or greater flexibility than the Arduino IDE's Serial Plotter can provide).

Developer note: the SerialInterface Extension and the SerialWidgetADC are aware of each other, and the Extension will automatically disable itself in the presence of the ADC, so there is no conflict if the Extension is turned on in CMake during the build.

Parameters

Parameters for handling widgets are described under Contributions:SerialInterface.

As in the SerialInterface Extension, the --SerialPort parameter must be supplied on the command-line and cannot be changed without relaunching BCI2000. For example, your BCI2000 script might contain the line:

 start executable SerialWidget --local --SerialPort=COM4:baud=9600,dtr=on

The same is true of the --PublishCommand parameter, if used.

The one departure from SerialInterface behavior is the handling of custom StartCommand and StopCommand messages. ADCs expect the signal to be delivered between runs as well as during runs. Therefore, if a StartCommand message is specified, the ADC sends it to the widget as soon as the user presses Set Config (whereas the SerialInterface Extension would wait until the user presses Start or Resume). Similarly, if a StopCommand message is specified, the ADC only sends it immediately prior to disconnecting and reconnecting on subsequent Set Config operations, and/or when BCI2000 quits (whereas the SerialInterface Extension would send it when the user presses Suspend).

Additional Parameters may be defined by the widget itself, if you specify a --PublishCommand on the command-line and the widget is programmed to support the specified command, as described in the SerialInterface documentation. As also described there, the values of widget-defined parameters may optionally be updated by the widget when a run ends, if you specify a ParameterUpdateCommand that the widget supports.


Parameters common to all source modules (SamplingRate, SampleBlockSize, SourceCh, etc.) are described under User Reference:DataIOFilter. SerialWidgetADC adds only one parameter of its own:

SourceChPins

This is a list of integer values, one per channel. The widget interprets these numbers when deciding how to acquire signals. The interpretation depends entirely on the way the widget is programmed: the simplest way would be to interpret the numbers as indices of the pins from which signals should be read (for example, as arguments to analogRead() or digitalRead() in the Arduino language). The parameter takes its name from this idea. However, a more-sophisticated widget might be programmed to do its own digital signal processing, and/or to generate artificial signals in response to external sensor inputs, and the relationship between these signals and the SourceChPins values may be arbitrary, as long as the number of channels matches the number of SourceChPins values.

States

The SerialWidgetADC does not define any State Variables or Events of its own, but can create Events as directed by the widget, as described in the SerialInterface documentation.


Widget Programming

The requirements for widget programming are somewhat more stringent than they were for compatibility with the SerialInterface Extension. An example SerialWidgetADC-compatible sketch is provided in the ExampleSourceSketch subdirectory (programs in the Arduino IDE are called "sketches" for some reason). ExampleSourceSketch.ino makes use of the SignalAcquisition and Keyhole libraries, which can be installed via the IDE's library manager and which makes it easy for sketches to respond to serial-port commands and to allow their variables to be read and written. This sketch is an expanded version of the TTLExampleSketch.ino provided with the SerialInterface Extension, and it handles TTL input and output in the same way; however, courtesy of the SignalAcquisition library, the sketch also fulfills the following additional requirements for SerialWidgetADC compatibility:

  • To enable signal acquisition, the sketch must support the following commands, sent over the serial port (the specific numbers are just examples, but the variable names and syntax must be supported as shown):
  samplesPerSecond=1000\n
  samplesPerBlock=40\n
  sourcePins="26 27"\n
  
  // NB: in the SignalAcquisition library, the .exposeVariables() method ensures
  //     that your sketch supports these, if called inside a Keyhole::begin() block
  • The widget is responsible for sample timing. When the widget has acquired a complete sample-block (the specified number of signal samples from all the specified pins) it must send a sample-block header, which is either \x01\x00 or \x00\x01 followed by a line-ending (\n or \r\n). Disregarding the line-ending, the header is actually the 16-bit binary representation of the number 1 (the former version is little-endian, the latter big-endian). Based on the header, the ADC will automatically determine whether the subsequent sample data need to be endian-swapped. The header can be sent easily using the following Arduino code:
  const uint16_t endianMarker = 1;
  Serial.write( (uint8_t*)&endianMarker, sizeof(endianMarker) );
  Serial.println(); // don't forget the line-ending
  
  // NB: in the SignalAcquisition library, the .acquire() method does this for you
 
  • Immediately after the sample-block header, the widget must send the raw sample data as packed 32-bit floats. The size of this payload is exactly (number of pins)*samplesPerBlock*sizeof(float) bytes. All the different channels' values for the first sample should be sent in the order specified by sourcePins, then all the channels' values for the second sample, and so on.
  Serial.write( (uint8_t *)data, numberOfBytes );
  Serial.println(); // a line-ending at this point is optional, but makes debugging easier in the Arduino IDE's Serial Monitor
  Serial.flush();   // important for good timing
  
  // NB: in the SignalAcquisition library, the .acquire() method does this for you
  • Between sample-blocks, the widget may send other messages provided they end with a line-ending (\n or \r\n) and do not begin with \x01\x00 or \x00\x01. This allows it to send the same message types that the SerialInterface Extension allows, which are:
    • BCI2000 Event descriptor lines, to mark the timing of arbitrary events: BCI2000 will attempt to interpret any line that does not begin with { as an Event descriptor;
    • error messages: BCI2000 will issue a fatal error containing the text of any line it receives from the widget if that line begins with { and contains the substring _ERROR_ (which is true of any error message sent by the Keyhole library);
    • any other JSON output: BCI2000 will simply ignore any other line beginning with the { character.
    • (You can also use the SerialInputs parameter to recognize strings that do not necessarily have to end with \r\n or \n, as long as you do not include entries beginning with \x01\x00 or \x00\x01 in the table. However, there's no real advantage to this; since you have control over the widget's firmware, it is better to program it to send event descriptors as text lines.)

Widget Timing

The particular implementation in the SignalAcquisition library contains features that will help you verify and debug your widget's timing.

  1. . In the Serial Monitor of the Arduino IDE, first send the commands to set the acquisition parameters the way you want them. (NB: where you see \n above, do not literally type a backslash followed by an n: the \n stands for the newline character, which the Serial Monitor will append for you when you press return.)
  2. . Then, additionally send debugCommOverhead=1 to enable measurement of the serial-port overhead.
  3. . Then send mute=0 to begin sending sample-blocks.
  4. . Send mute=1;? to stop the flow and examine variables.
  5. . Repeatedly alternate mute=0 with mute=1;? to take multiple readings.

Verify the following:

  1. . The idleLoops variable should be reliably greater than 0 when queried with mute=1;? . If it is 0, this indicates that your sketch may be unable to keep up with the desired sample rate. You need to reduce samplesPerSecond.
  2. . The debugCommOverhead variable will now contain a timing measurement indicating the number of microseconds it took to send data over the serial port for the last sample-block. This will be roughly proportional to the number of channels and the number of samples per block. It should not exceed the sample period in microseconds (1e6/samplesPerSecond): if it does, your data will contain readings that were unevenly sampled in time. In that case, you should reduce samplesPerSecond, reduce samplesPerBlock, or reduce the number of channels in sourcePins.

You can also probe the capabilities of your system by setting debugSampleInterval=1, then setting samplesPerSecond too high and repeatedly querying the debugSampleInterval variable. This gives you an empirical measurement of the interval between the sample before last and the last one in microseconds. (With debugSampleInterval set to non-zero you can also use BCI2000 to do something similar: set the SamplingRate parameter too high, and use the timing window to look at the difference between nominal and measured sample-block durations.) Remember to set debugSampleInterval=0 when you have finished debugging - this makes the system more responsive to, and allows more robust recovery from, transient timing hiccups (whereas prolonged tolerance of overly-long sample intervals will lead to data loss).

NB: the use of debugCommOverhead and debugSampleInterval assume you are working with version 1.4.0 or later of the SignalAcquisition Arduino-IDE library. In earlier versions, similar functionality was available under the names commOverheadMicros and measuredMicrosPerSample but we strongly recommend that you update your library to the latest version.

See also

User Reference:DataIOFilter, Programming Reference:GenericADC Class, Contributions:SerialInterface