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.
Notes on Serial Connection
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:
Marker | Description |
---|---|
11000000 | Start marker |
0100XXXX | Payload (4-bit) |
........ | |
00000000 | End 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
- Connect the board
- Find a path via Arduino IDE or device inspector, it is usually something like
- COM X Windows
- /dev/cu.usbserial... Unix
- 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:
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.
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.