Project presentation: Real-time ECG data acquisition and visualization on Arduino UNO Q

Hi everyone,
I’d like to share a project I recently published on the Arduino Project Hub and use it as a starting point for a broader discussion about real-time data acquisition and visualization on the Arduino Uno Q platform:
DIY ECG on Arduino Uno Q

Although the concrete example is an ECG system, the project is intentionally designed as a generic architectural concept applicable to other real-time sensor applications as well.

  • The STM32 MCU performs real-time ADC sampling (here: 200 Hz) and stores the data in a ring buffer.
  • The MPU (QRB2210) reads the buffer block-wise via the RPC bridge using a Python script running inside the standard Docker container.
  • The Python layer processes the data (in this example: digital filtering and R-peak detection).
  • Processed data are forwarded to WebUI, providing real-time visualization via HTML, JavaScript, and WebGL.
  • Data transfer between MCU → Python and Python → Browser uses compact binary formats to minimize bandwidth.
  • The WebUI can run in a browser on an external computer or directly on the Uno Q MPU itself. Currently this uses Chromium on the xfce desktop (Xorg); experiments with Chromium on Wayland/Weston are ongoing and will be reported later.

The Project Hub page focuses on the ECG use case, but the goal is to demonstrate a reusable design pattern for mixed MCU/MPU systems with web-based visualization.
I’d be happy to hear your thoughts, ideas or own experiences for similar applications on the Uno Q.
Best regards,
Jens

Thanks for the hard work in putting everything together and making the system work.

The project would benefit university students if it were presented in a tutorial format in the forum -- step by step, showing "do this and get this result.”

As a follow-up, here is a graphical overview of the system architecture used in the project presented above.
It illustrates the data flow and interactions between the different software layers and hardware components.

Definitely a bit more complex than the “classic” Arduino Uno setup. :wink:

@jens-bongartz
Really amazing diagram you ave posted in #3!

A project of this nature requires a group of self-volunteered experts, as it is unlikely that a single person (apart from a few exceptional cases) possesses so many diverse skills to give the project a fully working shape.

I would be grateful if you could kindly provide the following information for onward presentation to my university students to spark their interest in this glorified DIY project.

1. Arduino codes to acquire ADC data and store them into the buffer. Have you used DMA mechanism?

2. Arduino codes and the Python script demonstrating the transfer of 256-byte data items from the MCU buffer to the MPU buffer.

3. Purpose of Timer module and the related Arduino codes.

Q1: The 14-bit ADC of UNO Q gives 200 uV/LSB, and you have still used pre-amplifier for thr ECG signal -- why?

Q2: Can your project be handled using App Lab and IDE 2.3.7 in PC hosted mode? Or it needs SBC configuration?

Thank you very much for your positive and encouraging feedback.
Indeed, this project was developed solely by myself.
It started during the COVID pandemic, when we were forced to move our lectures to an online format. The original motivation was to create a transparent ECG system that allows students to explore the complete signal chain – from analog front end to digital processing and visualization. I called this project DIY-ECG.
The first prototype was based on a commercial AD8232 ECG module and a classic Arduino Uno. Data acquisition and visualization on the host computer were implemented in GNU Octave, which was (and still is) used in my teaching.
The next step was a fully self-designed analog front end using a quad op-amp (MCP6004). This PCB has meanwhile evolved into an Arduino-shield form factor. The KiCad design files are available on GitHub:

One long-term goal of this project has always been to eliminate the need for an additional computer. With the new Arduino Uno Q, this finally became possible, and I started to transfer the project accordingly.
You can find all related source code of the Arduino Uno Q version on GitHub as well:

The README of the GitHub repository and the Arduino Project Hub description (see above) contain additional background information.
Regarding your detailed technical questions, I would prefer to address them one by one in separate follow-up posts. This allows me to give precise answers with code references and, at the same time, helps to keep the discussion structured and easier to follow. I will look into this as soon as possible.

It would be very much interesting to get codes from you and test them. Thank you once again.

Here is a brief explanation of how to get the code running:

  1. Go to the GitHub repository of diy-ecg-uno-Q (see link above), click on the green <>Code Button and select Download as zip

  2. Start Arduino App Lab:
    Go to My Apps page, click Create new app and select Import app.
    Drag the downloaded ZIP-File diy-ecg-uno-Q-main.zip onto the import area.

  3. Select the imported DIY-ECG App.
    You have now all necessary source code files available:

  • In the sketch-folder you will find sketch.ino for the STM32 microcontroller.
  • In the python-folder you will find main.py, arduino_q_bridge_rpc.py and data_stream.py running on the MPU and Debian Linux.
  • Inside this folder, the WebUI directory contains app.js, index.html and styles.css for the webserver and browser.
  1. Hit the Run button.
    The first start will take a while, since all files have to be transferred to the Arduino Uno Q. You can monitor this process on the Console >> Launch-App page. This canl take several minutes.
    Once finished you will see the following message on the Console >> Python page.
======== App is starting ============================
2026-02-04 15:25:19.746 INFO - [WebUI.execute] WebUI:  The application interface is available here:
- Local URL:   http://localhost:7000
- Network URL: http://192.168.1.37:7000
2026-02-04 15:25:19.747 INFO - [MainThread] App:  App started
  1. Open a browser on your host computer and navigate to the displayed URL (you will see another URL). The following UI should appear:

The graph shows the signal at A0. Since no ECG amplifier or electrodes are connected, you will see a random signal. You may also connect any other signal source to A0 in the range between 0 and 3.3 volts.

In this form, the project essentially acts as a 200 Hz oscilloscope for A0. Filters (high-pass, notch, low-pass, adaptive mean) can be enabled or disabled in the upper-left corner of the UI.

When the Arduino is running in standalone SBC mode, simply use the browser on the local xfce desktop.

As promised, more detailed information about the individual source code files will follow soon.

Thank you.

I will certainly play with your work and try to learn as much as I can to the best of my ability. As my learning progresses, I will come up with questions from time to time.

I have followed your steps and successfully obtained the DIY-ECG Live Viewer running in my computer browser (Fig-1). At present, I have a 1.65 V DC signal applied to the A0 pin.

My next step will be to collect a few ECG electrodes and acquire real signals from the human body. It would be very helpful if you could share the typical signal levels produced by your ECG probes. This information will allow me to decide whether pre-amplifiers can be avoided, especially considering the 14-bit resolution of the UNO Q’s ADC.


Figure-1:

I would like to extend to you my sincere gratitude for providing such a useful and fully working DIY project. I am confident that it will be extensively studied by the students of my university who are enrolled in the course Biomedical Engineering.

What I especially appreciate about your project is that it aligns perfectly with my own learning philosophy: I enjoy working with gadgets that actually function, and then extracting new knowledge from their behavior -- often discovering things I did not know before.

I hope I will be able to establish a clear one-to-one correspondence between the information presented in the block diagram (#3) and the codes running in the App Lab.

Q1: In practical ECG machine, there are about a dozen of electrodes attached with human body. In the project of this thread, there is only one electrode feeding signal at A0-pin. In #1, there are at least three electrodes are seen to have been attaced with the human body. Is my observation correct?

Q2: I have fixed 1.65V DC connected at A0-pin and the oscillogram is still chnaging. Is it ok?

It is good to see that everything is working properly. You are a very helpful and responsive tester.

Deselect all filters in the upper left corner. Then you should see a stable signal from your 1.65 volts source.

My amplifier has three electrodes. Two electrodes are attached to the left (LE) and right (RE) wrist to acquire Einthoven’s Lead I . The third electrode is used as a driven reference electrode for active common-mode feedback (REF).

The schematic is deliberately simple, yet it offers valuable insights for students.

If you are interested I can provide you an amplifier pcb. Just write me an e-mail to info@diy-ecg.org for further exchange.

A:

Thank you for the good words.

B:

I have deselected all the four filters. Now, the signal is more-or-less stable with some noise spikes.

C:

1. How much is the Battery supply?
2. What is the bias supply for the op-amp?
3. Where does the 3rd elctrode (REF) fit with the body?

D:
1. The process begins at the MCU side with the acquisition of a uni-polar signal from the A0 pin. Am I correct? Practically, electrode signals are bi-polar which you are making unipolar by adding offset. Do you retrieve the original bi-polar signal before processing begins?

2. To help us study your sketch code systematically, could you please serialize the stages of signal processing that occur before the data is sent to the MPU through
Bridge.provide("ecg_get_frame", ecg_get_frame);
in response to
Bridge.call("ecg_get_frame")?

3. There are three Python scripts on the MPU side. I would be grateful if you could clarify the order of execution—that is, which x.py is executed first, followed by which script, and so on.

4. I (and my students as well) am not a regular Python programmer; however, I am familiar with C++ and Arduino programming. We have already started a session to read and understand your Python code using Python documentation and AI tools. Needless to say, we appreciate your advanced coding style in both the Arduino sketch and the Python scripts.

First, a brief description of the schematic:

The circuit is connected to the Arduino as follows: Bat+ is supplied by the Arduino’s 3.3 V output, Bat- is connected to the Arduino ground, and the amplified ECG signal is fed to A0.
The signals from the right and left electrodes (RE / LE) are AC-coupled via the high-pass filters C1/R_EL1 and C2/R_EL2. These filters suppress DC offsets caused by electrode half-cell potentials and slow baseline drift while maintaining a very high input impedance.

The operational amplifiers U1C, U1D, and U1A form a classic three-op-amp instrumentation amplifier. U1C and U1D act as non-inverting input amplifiers with high input impedance, while U1A performs the differential amplification. The overall gain of this stage is approximately 210, mainly defined by RA1 in combination with R_RE1and R_LE1.

A virtual ground at 1.65 V is generated by the voltage divider R_BA1/R_BA2 and buffered by U1B. This virtual reference shifts the ECG signal into the input range of the Arduino’s ADC.
The common-mode voltage of the electrode signals is extracted using the resistor network R_CM1/R_CM2. This common-mode component is inverted and fed back (also by U1B) to the subject through a 1 MΩ resistor, implementing a driven-right-leg (DRL) circuit. Although traditionally connected to the right leg, the feedback electrode is placed on the right forearm here for practical reasons, which is electrically equivalent.
This feedback loop significantly reduces common-mode interference (especially mains hum) and improves the signal-to-noise ratio of the ECG measurement.

This are all comonents:


A short three-electrode cable is connected via the red 3.5 mm TRRS jack. I am using adhesive Ag/AgCl electrodes.

Here a photo of a fully assembled kit

I can provide one if you want.

I see that you are good as well in teaching electronics. Thank you.

You are most welcome!

Okay, let’s start now with a description of the code. I will begin with the MCU code sketch.ino and its connection to the Python code on the Linux side.

The following graphic shows the overall architecture.

General idea
The main goal of the sketch is to sample the ADC0 input every 5 ms (sampling frequency 200 Hz) and store the samples in a ring buffer (RingBuf[]). The ring buffer has 200 entries, so it can store up to 200 samples, which corresponds to about 1 second of signal data.

Sampling and timing
To generate the 5 ms sampling interval, a Zephyr OS k_timer is used, called SampleTimer. Every 5 ms, the timer callback onSampleTimer() is executed and increments the variable timer_ticks.

The actual ADC read is not done inside the timer callback. Instead, timer_ticks is checked in the Arduino loop() function:

  • If timer_ticks is zero, the loop sleeps for 500 µs.
  • If timer_ticks is greater than zero (normally one), the function readADC() is called once for each pending tick.

The function readADC() reads ADC0 (analogRead(A0)) and stores the sampled value together with a timestamp (in milliseconds) in the next position of the ring buffer. When the end of the ring buffer is reached, it wraps around to the beginning. This results in a continuously running sampling loop (shown in the lower part of the orange MCU box in the diagram).

Ring buffer and RPC access
The content of RingBuf[] can be accessed via the MsgPack RPC function ecg_get_frame(). This function can be called from the Linux side (MPU) via the RPC bridge using:

Bridge.call("ecg_get_frame")

The function remembers the last read position in the ring buffer and returns only the new samples that have not been sent yet.

On the Python side, Bridge.call() is typically executed every 40 ms. At a sampling rate of 200 Hz, this usually results in about 8 samples per frame. The polling interval can be changed if needed.

Data frame format
ecg_get_frame() builds a compact binary data frame with the following structure:

Number of samples (uint8 count)
Timestamp of the first sample (uint32 t0_ms)
For each sample:
ADC value (uint16)
Time delta to the previous sample (uint8 dt_ms)
CRC16 checksum at the end

Using delta timestamps (dt_ms) significantly reduces the frame size. This is important because the RPC bridge is implemented over a serial connection running only at 115.200 baud.

Python side
On the Linux side, Bridge.call("ecg_get_frame") is executed inside the script arduino_q_bridge_rpc.py, wrapped by the function request_frame(). This function is called periodically from main.py.
The script also contains a parse_frame() function that unpacks and validates the received data frame. More details about the Python side will follow in a later post.

In short:
The MCU samples ADC0 every 5 ms using a Zephyr timer.
Samples are stored continuously in a ring buffer.
The Python side requests new samples on demand via RPC.
Only unsent samples are transmitted, packed efficiently into a small binary frame.

I hope this makes the functionality clear.

1.

Typo error:
==> 115,200

2.

The default 10-bit resoluton of ADC; the upper 6-bit are always 0s. Is it correct?

3. Our students are from Electrical Engineering discipline and are not very efficient in advanced level of C++ Programming and data structure.

So, I need to study your code, simplify it, and then present it to the students. I would appreciate your kind generisity in addressing all kinds of questions we may have.

4. If there would be more teachers in this forum playing with UNO Q, it would have been fantastic to hear their constructive criticsms on your project.

Okay, I will continue with the code explanation of my project. This post focuses on the Python part. The following graphic shows the usual architectural overview of this part.

The Python layer acts as the central glue between the microcontroller-based ECG acquisition and the browser-based visualization.
At the lowest level, arduino_q_bridge_rpc.py covers the transport layer to the microcontroller. It uses the Arduino Bridge RPC mechanism to call the MCU function ecg_get_frame. The returned data frame is parsed, CRC-checked, and converted into two Python lists: raw ECG samples and their corresponding timestamps.
The data- and signal-processing lives in data_stream.py. This module implements a circular ring buffer and some different filter. Incoming samples are processed sample-by-sample through a cascade of IIR filters (notch, low-pass, and high-pass), followed by an adaptive mean filter. R-peak detection is integrated directly into this adaptive stage, including polarity detection, a dynamic threshold based on the last two seconds of signal history, and an inhibit phase to avoid double detections. When a new beat is detected, BPM is calculated immediately from the RR interval and made available to the application layer.
The application entry point is main.py, which orchestrates the entire system. It defines also the ECGWebServer class of the WebUI-brick. A dedicated polling thread periodically requests new ECG frames via the RPC bridge, feeds them into the DataStream, and extracts incremental updates. Instead of sending full signal windows continuously, only small delta payloads (new samples plus timing information) are pushed to the browser. A complete signal window is transmitted only once when a client connects. This design keeps latency low and bandwidth usage low. In addition to the signal data, the server also sends metadata such as connection status, active filters, sampling rate, BPM, and detected polarity. All data is transmitted to the browser as structured JSON text-messages over Socket.IO.

Thank you for the clear graphical and textual explanations in #18.

@jens-bongartz
1. At 40 ms after startup, the MPU requests data from the MCU. At that point, will the MCU send 8 samples (48 bytes) to the MPU, calculated as
(2 bytes for value + 4 bytes for t_ms) × 8 = 48 bytes. Is this understanding correct?

2. For some reason, the MPU requests data at 150 ms after startup. By then, there are 30 samples (180 bytes) in the ring buffer. In response, will the MCU send all 180 bytes of data to the MPU?

3. Suppose, no data send request arrives from the MPU to the MCU for 1030 ms after start up. In the meantime, the ring buffer head wraps around and reaches position 6. What will happen to the remaining 194 unsent samples? Will they be over-written by new samples?

4.

Why not for 5000 us (5 ms) after which the timer_ticks will assume 1 for sure?

5.
Would greatly appreciate if you describe in plain text the purposes of the following codes which belong to readADC() function?

 // If the ring would overwrite unsent data, drop the oldest (move last_sent forward)
  if (next == last_sent) 
  {
    uint16_t new_last_sent = last_sent + 1;
    if (new_last_sent == RING_SIZE) 
      new_last_sent = 0;
    last_sent = new_last_sent;
    overflowed = true;
  }

I have tried to infer the answers to my above queries from your sketch, but that is a bit difficult due to individual programming style and efficiency.