Skip to main content

Build an Arduino Vital Signs Monitor: ECG, PLETH, and Temperature

·3741 words·18 mins
Emiliano Fernández Cervantes
Author
Emiliano Fernández Cervantes
I build things where hardware meets software: Verilog architectures, biomedical instrumentation, and a home lab that keeps growing.

Have you ever wondered what actually happens inside the monitor hanging above a hospital bed, the one drawing a green heartbeat line while a number blinks in the corner?

Behind that screen there is a chain of sensors, amplifiers, filters, and deliberate design decisions. You can build a simplified version of that chain yourself, and by the end of it you will understand bedside monitors in a way no datasheet or lecture can teach you.

That is what this guide is for. You will acquire three physiological signals at the same time (electrocardiogram, photoplethysmography, and temperature), condition each one in the analog domain, and draw all three live on a single TFT-LCD, the way a real monitor does. Many Arduino health projects lean on ready-made sensor modules that hide the analog work. This build does not: the front end is made of discrete parts, so every gain, every cutoff, and every offset is a decision you get to make and defend.

I built this version for BI3014.1, the Biomedical Technologies Lab at Tecnológico de Monterrey, Campus Ciudad de México. Everything below is the design that ended up working, including the obstacles that shaped it.

One clarification matters more than any circuit in this post: this is a didactic prototype built for a university lab, not a medical device. It was never intended, tested, or certified for patient diagnosis or monitoring, and it should not be used for that purpose. What it is genuinely good for is understanding, hands-on, how a vital signs monitor is put together.

Finished TFT-LCD display showing 25.8 degrees Celsius in yellow, a cyan PLETH waveform in the upper half, and a green ECG trace with clear QRS complexes in the lower half
The three signals running simultaneously on the TFT-LCD: temperature in yellow, photoplethysmography (PLETH) in cyan, and the electrocardiogram (ECG) in green.

What a vital signs monitor actually does
#

Before designing anything, it helps to know what you are imitating.

CENETEC (Mexico’s National Center for Health Technology Excellence) defines a vital signs monitor as a device that allows a patient’s physiological parameters to be continuously detected, processed, and displayed. In practice, a monitor acquires a signal from the body, amplifies it, conditions it, and renders it on a screen.

Most clinical monitors are modular, so a physician can choose which variables to track: ECG, respiratory rate, non-invasive or invasive blood pressure, temperature, oxygen saturation (SpO2), venous oxygen saturation, cardiac output, carbon dioxide, intracranial pressure, or airway gas pressure during anesthesia. By mobility they split into fixed units (anesthesia, adult, neonatal) and transport units for moving patients within a hospital, between hospitals, or inside an ambulance.

Structurally, though, every one of them comes down to four pieces: a power supply, an information-processing unit, a display, and one acquisition module per parameter. That structure is the blueprint you are about to scale down to something an Arduino can handle.


The architecture: five blocks, not one tangled circuit
#

Organize the project as five blocks: three signal-acquisition blocks (ECG, PLETH, and temperature), one integration block built around an Arduino MEGA 2560, and one visualization block, a TFT-LCD screen.

The ECG and PLETH blocks each go through three stages: signal acquisition, analog filtering and conditioning, and analog-to-digital conversion. Temperature skips the middle stage entirely and goes straight from the sensor to the ADC, because the LM35 already outputs a clean, linear signal that needs no conditioning.

Thinking in blocks rather than in one tangled schematic was the single most useful engineering decision I made, and it is the one I would recommend most strongly. It lets you design, build, and debug each acquisition chain on its own before worrying about how they will share one Arduino and one screen.

The blocks did not go straight to the breadboard either. I simulated the circuits in Proteus and Tinkercad first, and once they were built I verified them on the bench with an oscilloscope and a multimeter. Simulating before soldering and measuring after assembling is a habit worth keeping in any analog build: the simulation is cheap to iterate on, and the bench instruments are what tell you what your circuit is actually doing rather than what you assumed it would do.

Block diagram of the five-block architecture, showing the TFT-LCD and the three sensor inputs, PLETH, temperature, and ECG, all wired into the Arduino MEGA 2560
The five-block architecture: three acquisition chains feeding into the Arduino MEGA 2560, which drives the TFT-LCD.

What you need
#

  • Arduino MEGA 2560, the integration block that reads every channel and drives the display.
  • A TFT-LCD screen with an HX8357 controller, for the visualization block.
  • Ag/AgCl electrodes and lead wires, for the ECG.
  • An AD620 instrumentation amplifier, for the ECG front end.
  • An IR333C infrared LED and a PT333-3B phototransistor, for the PLETH front end.
  • A TL081 operational amplifier, for the PLETH conditioning stage.
  • An LM35 temperature sensor.
  • Two 9 V batteries, for the bipolar supply the instrumentation stage needs.
  • A second Arduino UNO, used purely as a 5 V supply (more on why below).

The three signal chains at a glance
#

SignalTransducerAmplificationFilteringArduino pin
ECGAg/AgCl electrodes, Einthoven’s lead IIAD620, gain 1000 V/V, 2.5 V offsetBandpass, 1.2 to 150 HzA12
PLETHIR333C LED and PT333-3B phototransistorTL081 stage, gain 180 (set experimentally)Bandpass, 1.2 to 150 HzA8
TemperatureLM35None, the sensor output is already usableNoneA10

The ECG chain: turning millivolts into something an ADC can see
#

Electrocardiography measures the electrical activity of the heart, and it starts with the transducer: a pair of Ag/AgCl (silver / silver-chloride) electrodes that convert the physiological signal into an electrical one. I used Einthoven’s lead II configuration for the acquisition.

Here is the problem you have to solve. A typical adult ECG has an amplitude of only 0.5 to 4 mV, over a frequency range of 0.05 to 150 Hz. Feed that straight into an Arduino and you will see nothing but noise, because the signal is thousands of times smaller than the ADC’s step size.

The amplification comes from an AD620 instrumentation amplifier, which reads the signal differentially straight from the electrodes. Reading differentially matters: it is what rejects the interference that both electrodes pick up in common, which in a room full of mains wiring is most of what they pick up. I set the gain to 1000 V/V with a 2.5 V offset, so the amplified signal sits comfortably between 0 and 5 V, centered inside the Arduino’s input range instead of clipping against ground.

After amplification, the signal passes through a bandpass filter from 1.2 to 150 Hz to remove baseline drift and high-frequency noise, and the conditioned signal is read on analog pin A12.

ECG acquisition circuit built on a breadboard, with three Ag/AgCl electrodes and their lead wires connected
The ECG acquisition circuit: AD620 instrumentation amplifier, electrodes, and lead wires on the breadboard.

The PLETH chain: reading a pulse with light
#

Photoplethysmography measures pulsatile changes in blood volume, which lets you recover heart rhythm optically instead of electrically. The transducer is an IR333C infrared LED shining through a capillary bed (a fingertip, in this case), paired with a PT333-3B phototransistor on the other side that detects the transmitted light and turns it into an electrical signal. With every heartbeat, arterial pulsations change how much light is absorbed, and that variation is the signal you are after.

I used the phototransistor in a common-emitter configuration, feeding a conditioning stage built with a TL081 op-amp. The gain, 180, was determined experimentally, tuned for a clear trace on the display rather than derived analytically.

Two honest trade-offs are worth naming here, because they are the kind of decision that separates a working prototype from a wrong one:

PLETH is qualitative here, by choice. So many external factors affect light absorption that it is a poor tool for measuring absolute blood volume. I treated it strictly as a qualitative indicator of blood flow, which is exactly enough to visualize a pulse waveform and no more than the setup can honestly support.

The filter band is shared, not optimal. PLETH is normally filtered between 0.5 and 5 Hz. For simplicity I filtered it in the same 1.2 to 150 Hz band used for the ECG, reusing one filter design across both acquisition chains. That costs some noise rejection and buys a much simpler build, which was the right call for a lab prototype. The signal is read on analog pin A8.

Photoplethysmography circuit on a breadboard, with a white finger clip holding the infrared LED and phototransistor in place
The PLETH circuit: an IR333C LED and PT333-3B phototransistor mounted in a finger clip, wired to the TL081 conditioning stage.

The temperature chain: the one that stays simple
#

Temperature is the simplest of the three chains by design, and that is a feature. I used an LM35 sensor, chosen for its precision, its working range, and its linear response, which removes the need for any calibration curve. Its accuracy is roughly 0.25 °C, which is actually finer than what the Arduino resolves: with the default 5 V reference, one step of the 10-bit ADC is about 4.88 mV, and at the LM35’s 10 mV/°C that step works out to roughly 0.49 °C. In other words, the conversion sets the resolution of the measurement, not the sensor, and you can read that resolution straight out of the code: the 500.0 / 1023 constant in the sketch below is the step size itself, 0.489 °C per count. That is the intended outcome rather than a compromise, since half a degree per step is more than enough for human body temperature, the one quantity this monitor exists to display, and a reading shown with a single decimal over the clinically interesting range asks for nothing finer. More ADC bits would only have resolved digits that the sensor’s own accuracy does not back up, which is false precision: numbers that look more certain than the measurement truly is.

Because the LM35 already outputs a clean analog voltage proportional to temperature, it skips filtering and conditioning entirely and is read directly on analog pin A10. The conversion from raw ADC counts to degrees Celsius is the single line you will find in the sketch below:

T = LM35 * (500.0 / 1023)

where LM35 holds the number of steps measured by the 10-bit ADC. The 500 is not a magic number either: at the 5 V analog reference, full scale corresponds to 5000 mV, and since the LM35 outputs 10 mV/°C, that full scale spans 500 °C. Dividing those 500 °C by the 1023 counts of the ADC is what turns raw counts into degrees Celsius, which is the same arithmetic as the step size above seen from the other direction: the reference voltage and the sensor’s output scale folded together into a single figure.

One small refinement makes the reading pleasant to look at. Raw sample-to-sample variation makes the number on screen jitter constantly, so the displayed value is the average of the last five measured temperatures, printed with one decimal place. That average is refreshed five times per program cycle: in the sketch, the temperature branch runs whenever the column counter x1 is a multiple of 63, which lands at 0, 63, 126, 189 and 252 as the trace sweeps across the screen. Those are two different fives, worth keeping apart: one is how many samples go into the average, the other is how often the result is redrawn.

LM35 temperature sensor wired directly to the Arduino MEGA
The LM35, wired directly into the Arduino MEGA with no intermediate conditioning stage.

Power: the problem I did not see coming
#

Powering three very different acquisition chains from one board turned out to be trickier than any of the filter designs, and it is where most of my lab time went.

The AD620 instrumentation stage needed a bipolar supply, so I powered it from two 9 V batteries wired as a -9 V to +9 V split rail. The LM35, the infrared LED, the phototransistor, and the ECG circuit’s 2.5 V summing stage all needed a stable 5 V, but a single Arduino could not source enough current for all of them at once. The fix was to add a second Arduino UNO dedicated purely to supplying that 5 V rail, which solved the problem cleanly if not elegantly. The TFT-LCD shield, finally, runs off the 3.3 V rail of the Arduino MEGA itself.

Early on I also tried powering the whole setup from a single bench supply with multiple voltage rails. It did not work. Mains noise from the wall supply bled into the signals in a way the batteries never did, most likely because of the notch filters: they were part of the design from the beginning, but they were never correctly calibrated, since I did not have the equipment on hand to characterize them. Going back to battery power for the sensitive analog stages fixed it immediately.

If you are building your own front end, take that as a shortcut rather than a warning. There is a reason so much biomedical instrumentation still leans on isolated, battery-backed supplies for the input stage, and you will feel that reason the first time you watch 60 Hz ride on top of a QRS complex.


Rendering three signals at once on a TFT-LCD
#

The visualization block is a TFT-LCD built around an HX8357 controller, driven with the Adafruit_GFX and Adafruit_TFTLCD libraries. Getting there was its own project.

There is no single well-documented library that works across every variant of this screen, and the controller most often referenced online is the ILI9341, not the HX8357 I actually had. Identifying which controller was on my specific board, before anything would draw correctly, was one of the harder debugging sessions of the whole build. If your screen shows nothing but white, suspect the controller before you suspect your wiring.

Once the display is talking to the Arduino, the rendering logic is refreshingly simple. PLETH and ECG are drawn as live scrolling graphs: for each new sample, the program draws a straight line from the previous sample’s position to the current one. When the trace reaches the right edge of the screen, both graphs are erased and drawing restarts from the left edge, which marks the end of one program cycle. Temperature, being a single number rather than a waveform, is printed as text.

On screen, temperature appears in yellow in the top-right corner, PLETH in cyan in the upper half, and ECG in green in the lower half. That color scheme is not decoration: it is what lets the layout be read at a glance, the same way a real monitor is designed to be read.

Here is the complete Arduino sketch, TFTLCDemi.ino. The in-code comments are in Spanish, since that is how I originally wrote it, and I left the working code untouched rather than tidying it after the fact:

#include <Adafruit_GFX.h>    // Core graphics library
#include <Adafruit_TFTLCD.h> // Hardware-specific library

#define LCD_CS A3 // Chip Select goes to Analog 3
#define LCD_CD A2 // Command/Data goes to Analog 2
#define LCD_WR A1 // LCD Write goes to Analog 1
#define LCD_RD A0 // LCD Read goes to Analog 0
#define LCD_RESET A4 // Can alternately just connect to Arduino's reset pin

// For the Arduino Mega, use digital pins 22 through 29
// (on the 2-row header at the end of the board).
//   D0 connects to digital pin 22
//   D1 connects to digital pin 23
//   D2 connects to digital pin 24
//   D3 connects to digital pin 25
//   D4 connects to digital pin 26
//   D5 connects to digital pin 27
//   D6 connects to digital pin 28
//   D7 connects to digital pin 29

//Variables
int Ox[316];
int ECG[316];
int x1 = 0; //Contador

//Temperatura
int LM35;
float TEMPERATURA;
float T1 = 0;
float T2 = 0;
float T3 = 0;
float T4 = 0;

#define BLACK   0x0000
#define BLUE    0x001F
#define RED     0xF800
#define GREEN   0x07E0
#define CYAN    0x07FF
#define MAGENTA 0xF81F
#define YELLOW  0xFFE0
#define WHITE   0xFFFF

Adafruit_TFTLCD tft(LCD_CS, LCD_CD, LCD_WR, LCD_RD, LCD_RESET);

void setup() {
  // put your setup code here, to run once:
  Serial.begin(9600);

  tft.reset();
  tft.begin(0x8357);
  tft.setRotation(1);        // establece posicion vertical
  tft.fillScreen(BLACK);    // fondo de pantalla de color negro

  tft.setTextColor(YELLOW, BLACK);  // texto en color amarillo
  tft.setTextSize(3);   // escala de texto en 3
  tft.setCursor(270, 30);  // ubica cursor
  tft.print((char)247);
  tft.setTextSize(4);   // escala de texto en 4
  tft.setCursor(290, 30);  // ubica cursor
  tft.print('C');

  //  tft.fillRect(0, 0, tft.width(), 20, CYAN);  // rectangulo azul naval a modo de fondo de titulo
  //  tft.setTextColor(WHITE);    // color de texto en blanco
  //  tft.setTextSize(2);       // escala de texto en 2
  //  tft.setCursor(25, 6);       // ubica cursor
  //  tft.print("Panel de control");    // imprime texto
  //  tft.setCursor(0, 35);       // ubica cursor
  //  tft.print("Zona: 1");       // imprime texto
  //  tft.setCursor(0, 55);       // ubica cursor
  //  tft.print("Temperatura Humedad");   // imprime texto
  //  tft.drawLine(0, 170, 240, 170, RED);  // linea horizontal de color rojo
  //  tft.setCursor(0, 185);      // ubica cursor
  //  tft.print("Zona: 2");       // imprime texto
  //  tft.setCursor(0, 205);      // ubica cursor
  //  tft.print("Temperatura Humedad");   // imprime texto

}

void loop() {
  tft.fillRect(0, 70, tft.width(), tft.height() - 60, BLACK); // Se borra el display
  tft.setTextColor(CYAN, BLACK);  // texto en color amarillo
  tft.setTextSize(1);   // escala de texto en 3
  tft.setCursor(10, 60);  // ubica cursor
  tft.print("PLETH");

  tft.setTextColor(GREEN, BLACK);  // texto en color amarillo
  tft.setTextSize(1);   // escala de texto en 3
  tft.setCursor(10, 170);  // ubica cursor
  tft.print("ECG");

  while ( x1 < 315 ) {

    // Leer temperatura
    if (x1 % 63 != 0) {
      LM35 = analogRead(10);
      TEMPERATURA = (LM35 * 500.0) / 1023; //Fórmula para calcular la temperatura
      //      SUMA = TEMPERATURA + SUMA;
      T4 = T3; T3 = T2; T2 = T1; T1 = TEMPERATURA;
      delay(1);
    }
    else {
      LM35 = analogRead(10);
      TEMPERATURA = (LM35 * 500.0) / 1023; //Fórmula para calcular la temperatura
      //      SUMA = TEMPERATURA + SUMA;
      TEMPERATURA = (TEMPERATURA + T1 + T2 + T3 + T4) / 5;

      //Escribir en TFTLCD
      tft.setTextColor(YELLOW, BLACK);  // texto en color amarillo
      tft.setTextSize(4);   // escala de texto en 4
      tft.setCursor(170, 30);  // ubica cursor
      tft.print(TEMPERATURA, 1); //Temperatura con 1 decimal
      delay(2);

      //      SUMA = 0; //Se borra la suma de las temperaturas
    }

    //Leer ECG
    ECG[x1 + 1] = analogRead(12);
    ECG[x1 + 1] = map(ECG[x1 + 1], 0, 1023, 0, 50);
    tft.drawLine( x1 + 4, 230 - ECG[x1], x1 + 5, 230 - ECG[x1 + 1], GREEN);
    delay(10);

    //Leer Pulsímetro
        Ox[x1 + 1] = analogRead(8);
        Ox[x1 + 1] = Ox[x1 + 1] * 6;
        if (Ox[x1 + 1] > 1023) {
          Ox[x1 + 1] = Ox[x1];
        }
        else {
          Ox[x1 + 1] = map(Ox[x1 + 1], 0, 1023, 0, 50);
          tft.drawLine( x1 + 4, 120 - Ox[x1], x1 + 5, 120 - Ox[x1 + 1], CYAN);
      }

//    Ox[x1 + 1] = analogRead(8);
//    Ox[x1 + 1] = Ox[x1 + 1] * 6;
//    Ox[x1 + 1] = map(Ox[x1 + 1], 0, 1023, 0, 50);
//    tft.drawLine( x1 + 4, 120 - Ox[x1], x1 + 5, 120 - Ox[x1 + 1], CYAN);

    x1++;
    delay(10); // demora de 10 mseg.
  }
  ECG[0] = ECG[x1]; //Se guarda el último valor medido
  Ox[0] = Ox[x1];
  x1 = 0;
}

One timing detail that will save you hours
#

The Arduino’s ADC has a conversion time of 13 clock cycles, and each analogRead() call needs that time to settle before you switch to the next channel. Skip that wait, or read channels faster than the ADC can multiplex between them, and you get incorrect readings or crosstalk between channels, where one signal visibly bleeds into another. That is why the delay() calls are sprinkled through the acquisition loop above, and it is the first thing to check if your ECG trace starts pulsing in time with your PLETH.


What you end up with
#

The payoff is a screen that behaves like the thing you set out to imitate: three physiological variables, updating live, readable at a glance.

In my build, all three signals appeared simultaneously on the TFT-LCD, which was the entire point. Room-temperature readings from the LM35 hovered between 23.9 °C and 25.8 °C, matching what I measured independently through the serial monitor. The ECG trace showed clean, recognizable QRS complexes, and the PLETH trace showed clear pulsatile waveforms in sync with every heartbeat.

Complete assembled monitor showing two breadboards, both Arduinos, the two 9 V batteries, the electrodes, the finger clip, and the mounted TFT-LCD
The complete build: two Arduinos, two breadboards, both battery rails, and the TFT-LCD, all working together to show three signals at once.

Beyond the screen, you walk away with something more portable than the prototype: the ability to take a signal that exists in the physical world and carry it end to end into a display, making a defensible decision at every stage.


Lessons worth carrying to your next project
#

Integration is the hard part, not any single circuit. With five interdependent blocks, a failure anywhere breaks the whole system, so every connection has to be made deliberately and verified before you move on. Debugging a finished system that has never worked once is far harder than validating each block as you add it.

Datasheets beat forum posts when the hardware is ambiguous. Identifying an undocumented display controller, planning a power budget across multiple rails and two boards, and adapting signals for a peripheral the Arduino was never designed to drive natively: none of that came from a single tutorial. It came from reading closely and working through each obstacle as it appeared, which is honestly most of what engineering is.

Choosing the harder display was the right trade-off. A simple numeric display would have worked sooner. The TFT-LCD took far longer to get running, but it let me show two full waveforms in different colors alongside a numeric readout, so the result actually resembles a vital signs monitor instead of a row of numbers. When a project’s goal is to teach you what a real system feels like, pick the component that gets you closest to the real system.

Every setback here, the noisy bench supply, the silent screen, the Arduino that could not source enough current, ended up teaching more than the parts that worked on the first try.


Where to take it next
#

  • A properly calibrated notch filter, characterized with the right test equipment, so a bench supply becomes viable and the analog front end no longer depends on batteries.
  • SpO2 estimation, extending the PLETH chain with a second-wavelength LED to move from a qualitative pulse trace toward an actual oxygen saturation measurement.
  • Data logging, streaming the acquired signals over serial to a computer for storage and offline analysis on top of the real-time display.
  • A single shared 5 V rail designed with enough current headroom from the start, removing the need for a second Arduino used only as a power source.

If you are working through a biomedical instrumentation project of your own, I would love to hear what you built and what tripped you up along the way.