Skip to main content

Robust Arduino ADC reader for Wolfram Language

⏱️ 4 min read
Kirill Vasin

An onboard ADC (analog-to-digital converter) is one of the basic features of Arduino-compatible boards. With just a few lines of code, the can turn voltages into numerical values. When streamed over a serial interface, these values let the Arduino act as a live external sensor—capturing data from photodiodes, microphones, or other circuits.

In this example, we build a robust workflow for acquiring ADC samples via UART/USB and processing them in the Wolfram Language. Instead of relying on naïve streaming (which is prone to corruption), we design a lightweight framing protocol that improves integrity of data packets. Once in Wolfram Language, the values can be visualized, filtered, or transformed in real time—turning an entry-level Arduino into a toy-like oscilloscope or spectrum analyzer.

Download original notebook

Notes on Serial Connection

ADCArduinoWLJSNotebookPCUARTover USB

It is important to note that the default UART interface used on Arduino boards ==does not guarantee the delivery of data== and has a certain error rate. This means that if you send structured data, it might easily be corrupted when you continuously stream it.

Just relying on

uint16_t v = analogRead(A0);
Serial.write(v & 0xFF);
Serial.write((v >> 8) & 0xFF);

is a huge mistake ⚠️



Basic Frame Protocol

Here we draft a very simple protocol, which uses control bits to mark the start/end of a message and a payload:

MarkerDescription
11000000Start marker
0100XXXXPayload (4-bit)
........
00000000End marker

Another consideration is to avoid endless streaming at all. The reading procedure from the host can be:

  • Request 64/128/... values or ADC
  • Wait for the reply frame and check for errors

Set up the board

Here we use an Arduino Uno/Duemilanove/Nano-compatible board.

  • Install the Arduino IDE.
  • Load the following sketch.
adc.ino

int requestedSamples = -1;

void setup() {
  Serial.begin(115200);
  while (!Serial) {}
}

void loop() {
  if (requestedSamples == 0) {
     requestedSamples = -1;
     Serial.write((uint8_t)(0x0)); // Start
     return;
  }
  
  if (requestedSamples == -1 && Serial.available() >= 2) {
    uint8_t c0 = Serial.read(), c1 = Serial.read();
    requestedSamples = (int)c0 + (int)(c1 << 8);
    while (Serial.available() > 0) (void)Serial.read(); // clean up garbage
    Serial.write((uint8_t)(0xC0)); // End
  }
  
  if (requestedSamples == -1) return;

  uint16_t v = analogRead(A0);
  
  Serial.write((uint8_t)(((v >> 0) & 0xF)  | 0x40));
  Serial.write((uint8_t)(((v >> 4) & 0xF)  | 0x40));
  Serial.write((uint8_t)(((v >> 8) & 0xF)  | 0x40));
  Serial.write((uint8_t)(((v >> 12) & 0xF) | 0x40));
  
  requestedSamples--;
}

First contact

  1. Connect the board
  2. Find a path via Arduino IDE or device inspector, it is usually something like
    • COM X Windows
    • /dev/cu.usbserial... Unix
  3. Evaluate the cell below
path = "/dev/cu.usbserial-140";

Panel[{
  Refresh[If[DeviceOpenQ[dev]//TrueQ, Green, Red], 1],
  Button["Connect", Print["Connecting..."]; dev = DeviceOpen["Serial", {path, "BaudRate"->115200}]],
  Button["Close", dev = DeviceClose[dev]]
} // Row, Style["Control Center", 11, FontFamily->"system-ui"]]
(*GB[*){{(*BB[*)("Control Center")(*,*)(*"1:eJxTTMoPSmNiYGAo5gcSAUX5ZZkpqSn+BSWZ+XnFEAk+IBFcUpmT6pKanF+UWJJflMkNFIJIsgCJoNKc1GIuIMMtP6/ELTE3M6cymBMkWVlckpqrW5qJqjSYDWxgUWZeOlgspKg0FQAG8yHA"*)(*]BB*)(*VB[*)(**)(*,*)(*"1:eJxTTMoPSmNmYGAo5gUSYZmp5S6pyflFiSX5RcEsQBHPktTcNCaQPIgXVJqTWswJZDjmZKbn5abmlSDkfDKLSyCM1LQSsCLPvIzUosyS1BQ0AwSADJfUtMTSnBKnxOLU4JLKnNRgLqBgQGJeao5PYlJqDgCpyyXx"*)(*]VB*)}(*||*),(*||*){(*BB[*)((*BB[*)(Panel[(*GB[*){{(*VB[*)(Null)(*,*)(*"1:eJxdj82KwkAQhKMu+PMWgtdAEmMSryuKwi5CFM8ZnWocGGZ0flDf2Mdw1mVX8FJ8XVTT1cO9rqkVRZH9CLLUklPnZxoEqUEG9vipry9vzoXTZidw+d3qBlkTSc24LQPPNBHwJc4e3jTzq4OyQivbfDMlTl4yh2alHIxisrHOjNK8KrKE2n8Vai+x6T2vM75W8vZ0t8bjLdMPsPBS1rBw/6HNKMAB05Kx5BAXhCrOs4THVYE0no4rjklappTtxT389AADB0KT"*)(*]VB*)(*|*),(*|*)(*VB[*)(EventObject[<|"Id" -> "1b42e5b6-48df-42a7-bf95-2a32008ee6c0", "Initial" -> False, "View" -> "b52c32a2-5c58-41ef-ae9f-bf672b56071e"|>])(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKJ5kaJRsbJRrpmiabWuiaGKam6SamWqbpJqWZmRslmZoZmBumAgCH/BXS"*)(*]VB*)(*|*),(*|*)(*VB[*)(EventObject[<|"Id" -> "4d7b4062-d521-4dbb-a356-a56487d61780", "Initial" -> False, "View" -> "3bd8129f-60f2-4c1d-9ac3-6b3130f1ea9e"|>])(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKGyelWBgaWabpmhmkGemaJBum6FomJhvrmiUZGxobpBmmJlqmAgCD6xXK"*)(*]VB*)}}(*]GB*)])(*,*)(*"1:eJxTTMoPSmNiYGAo5gcSAUX5ZZkpqSn+BSWZ+XnFaYwgCV4gEZaZWu6SmpxflFiSXxTMClKamJeaA9HJAiSCSnNSg0EMj9TEFIQCAH5qF00="*)(*]BB*))(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KWnMIB4vkAjLTC13SU3OL0osyS8KBskHJOalpjHBVAeV5qQWcwIZjjmZ6Xm5qXklCDmfzOKSYjYgwxkonFpUzAFkOiUWp+ZkYpgggCQVkF+cWZKZn4eiHgAIiyhB"*)(*]BB*)(*VB[*)(**)(*,*)(*"1:eJxTTMoPSmNiYGAo5gUSYZmp5S6pyflFiSX5RcEsQBHPktRciDyIF1Sak1osAGS4pKYlluaUOCUWpwaXVOakBrMDBX0Sk1JzUlMAU+0Vnw=="*)(*]VB*)}}(*]GB*)

Now we need a little helper function to request and decode the frames of data:

readDevice[dev_, packetSize_:64] := Module[{},
  If[!(DeviceOpenQ[dev]//TrueQ), Return[$Failed, Module]];
  
  DeviceReadBuffer[dev];
  DeviceWrite[dev, ExportByteArray[packetSize, "UnsignedInteger16"]//Normal];
  With[{result = TimeConstrained[DeviceReadBuffer[dev, packetSize 4 + 2], 0.5, $Failed]},
  
    If[FailureQ[result],   Return[$Failed, Module]];
    If[result[[1]] != 192, Return[$Failed, Module]];
    If[result[[-1]] != 0,  Return[$Failed, Module]];

    With[{payload = result[[2;;-2]]},
      If[Sum[BitGet[b, 6], {b, payload}] != packetSize 4, Return[$Failed, Module]];
    
      Map[Function[p,
        BitClear[p[[1]], 6] + BitShiftLeft[BitClear[p[[2]], 6], 4] + BitShiftLeft[BitClear[p[[3]],6], 8] + BitShiftLeft[BitClear[p[[4]],6], 12]
      ], Partition[payload, 4]]
    ]
  ]
]

Let's read our first frame of data:

warning

Do not use large frame buffers. For my setup anything bigger than 512 casues an overflow and complete data loss.

readDevice[dev, 128] // ListLinePlot
(*VB[*)(FrontEndRef["6377d64f-10b9-4bb8-8f96-fe24a9672540"])(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKmxmbm6eYmaTpGhokWeqaJCVZ6FqkWZrppqUamSRampkbmZoYAAB90BUq"*)(*]VB*)

What you see is my screen's refresh rate overlayed by a background electrical noise captured by a photodiode transimpedance amplifier built on LM358P.

note

The sampling rate may vary and for buffer sizes 512 is ~2800 samples/sec

Here is a dynamic version:

Refresh[ListLinePlot[With[{d = readDevice[dev, 64]}, If[!FailureQ[d],
  d, Table[0, {64}]
]], PlotRange->{{1,64}, {0,1025}}], 0.1]

We defined plot range and return zeros in a case of a failure on purpose to benifit JIT features used in Refresh expression. This keeps the input data consistens and allows to update the graph smoothly

ArduinoScope

A toy-like oscilloscope

Nothing can stops us from performing a live Fourier transformation and accumulate longer times 🙂

We start from an automatic accumulating function, which runs in the background:

buffer = Table[0, {2048}];
accumulate[dev_] := With[{data = readDevice[dev, 128]},
  If[!FailureQ[data],
    buffer = RotateLeft[buffer, 128];
    buffer[[2048 - 127 ;;]] = data;
  ];
];

TaskRemove[task] // Quiet;
task = SetInterval[
  accumulate[dev];
  EventFire["scopeUpdate", buffer];
, Quantity[100, "Milliseconds"]];

To stop the task:

TaskRemove[task] // Quiet;

This procedure also fires an event object scopeUpdate to which we can subscribe

Graph widget

Let's design our first widget, that takes care of visualizing the data.

With[{
  event = EventClone["scopeUpdate"],
  data = Unique[],
  avg = Unique[]
},
  data = Table[{i,0}, {i,2048}];
  avg = Table[{i,0}, {i,2048}];
  
  EventHandler[ResultCell[], {"Destroy" -> Function[Null,
    EventRemove[event];
  ]}];

  EventHandler[event, Function[d,
    data = MapIndexed[Function[{val, i}, {i[[1]], val}], d];
    avg = MapIndexed[Function[{val, i}, {i[[1]], val}], MovingAverage[d, 16]];
  ]];

  Legended[Graphics[{
    (*VB[*)(RGBColor[0.368417, 0.506779, 0.709798])(*,*)(*"1:eJxTTMoPSmNiYGAo5gUSYZmp5S6pyflFiSX5RcEsQBHn4PCQNGaQPAeQCHJ3cs7PyS8qKpg26anKlOv2RYbTXk7vMH9gX3S8ZYb3qm3P7AF5kRs6"*)(*]VB*), Line[data // Offload], 
    (*VB[*)(RGBColor[0.880722, 0.611041, 0.142051])(*,*)(*"1:eJxTTMoPSmNiYGAo5gUSYZmp5S6pyflFiSX5RcEsQBHn4PCQNGaQPAeQCHJ3cs7PyS8q8jkS/fy+3hv7on/VH24t7X1sX7R51jr1XXqH7AGSkRxD"*)(*]VB*), Line[avg // Offload]
  }, 
    Frame->True, Axes->True,
    PlotRange->{{1,2048}, {0,1024}}, AspectRatio->0.5
  ], SwatchLegend[{(*VB[*)(RGBColor[0.368417, 0.506779, 0.709798])(*,*)(*"1:eJxTTMoPSmNiYGAo5gUSYZmp5S6pyflFiSX5RcEsQBHn4PCQNGaQPAeQCHJ3cs7PyS8qKpg26anKlOv2RYbTXk7vMH9gX3S8ZYb3qm3P7AF5kRs6"*)(*]VB*), (*VB[*)(RGBColor[0.880722, 0.611041, 0.142051])(*,*)(*"1:eJxTTMoPSmNiYGAo5gUSYZmp5S6pyflFiSX5RcEsQBHn4PCQNGaQPAeQCHJ3cs7PyS8q8jkS/fy+3hv7on/VH24t7X1sX7R51jr1XXqH7AGSkRxD"*)(*]VB*)}, {"Actual", "Moving Avg. (16)"}]]
]

We did not use Module here since WL`s automatic garbage collector can mistakenly purge the symbols.

Fourier Transform

Here is another example with a live FFT window

With[{
  event = EventClone["scopeUpdate"],
  data = Unique[]
},
  data = Table[{i,0}, {i,127}];
  
  EventHandler[ResultCell[], {"Destroy" -> Function[Null,
    EventRemove[event];
  ]}];

  EventHandler[event, Function[d,
    data = MapIndexed[Function[{val, i}, {i[[1]], val}], 
      Take[Drop[Fourier[Take[d, -256]]//Abs,1], 127]
      (* drop DC and the conjugated half *)
    ];
  ]];

  Graphics[{
    ColorData[97][3], Line[data // Offload]
  }, 
    Frame->True, Axes->True,
    PlotRange->{{1,127}, {0,1024}}, AspectRatio->0.5
  ]
]

One can project the output cells using the right-side cell group menu to separate windows for the convenience.