Project Overview & Target Board

Measuring a human pulse with a microcontroller relies on photoplethysmography (PPG). By shining an infrared (IR) LED into the skin and measuring the reflected light with a photodiode, we can detect the microscopic volume changes in blood capillaries with each heartbeat. While older analog sensors exist, the digital MAX30102 is the modern standard for hobbyist and medical-grade prototyping due to its onboard ambient light cancellation and high-resolution ADC.

Target Board: This guide and code specifically target the Arduino Nano v3 (ATmega328P). The Nano is chosen over the Uno R3 for this build because its compact footprint makes it practical for wearable or standalone kiosk mounts, while retaining the exact same I2C and power architecture as the Uno. If you are using an ESP32, note that you will need to change the I2C pin definitions in the code, as the ESP32 uses GPIO 21 (SDA) and GPIO 22 (SCL).

Sensor Showdown: MAX30102 vs. Analog Pulse Sensor

Before we wire anything up, it is critical to understand why we are using the MAX30102 instead of the classic analog 'Pulse Sensor Amped'. The analog sensor is essentially just an LED and a raw phototransistor. It is highly susceptible to 50/60Hz mains hum and ambient sunlight. The MAX30102 solves this by integrating the LED driver, photodiode, and a 18-bit ADC with ambient light rejection into a single I2C package.

Table 1: Heartbeat Sensor Module Comparison (2026 Market Data)
Specification MAX30102 (Digital I2C) Pulse Sensor Amped (Analog) MAX30105 (Digital I2C)
Interface I2C (Address 0x57) Analog Voltage (0-5V) I2C (Address 0x57)
ADC Resolution 18-bit (up to 32kHz) Depends on MCU (10-bit on Nano) 18-bit (up to 32kHz)
Ambient Light Rejection Yes (Hardware + DSP) No (Highly susceptible) Yes (Hardware + DSP)
SpO2 Capable? Yes (Red + IR LEDs) No (Single Green/Red LED) Yes (Red + IR + Green LEDs)
Typical Clone Cost $3.50 - $6.00 USD $8.00 - $12.00 USD $4.00 - $7.00 USD
Wiring Complexity Moderate (I2C + Pull-ups) Simple (VCC, GND, Signal) Moderate (I2C + Pull-ups)

Note: The MAX30105 is largely software-compatible with the MAX30102 but adds a green LED for better surface-level pulse detection. The code provided below works for both.

Hardware BOM & Pin Mapping

The clone market for MAX30102 breakouts is flooded with boards that have slight voltage regulator variations. When buying, look for a board that explicitly includes an onboard 3.3V LDO (Low Dropout Regulator) and level-shifting resistors. If your board is raw (no LDO), you must power it strictly from the Nano's 3.3V pin.

Parts List

  • Microcontroller: Arduino Nano v3 (ATmega328P, 16MHz, 5V logic)
  • Sensor: MAX30102 Breakout Board (GY-MAX30102 or SparkFun SEN-14786)
  • Wiring: 4x Female-to-Male jumper wires (keep under 6 inches / 15cm for I2C stability)
  • Resistors (Conditional): 2x 4.7kΩ pull-up resistors (only if your clone board lacks them)

Pin Mapping Table

MAX30102 Pin Arduino Nano v3 Pin Notes & Warnings
VCC / VIN 5V Use 5V ONLY if the breakout has an onboard LDO. Otherwise use 3.3V.
GND GND Ensure a solid common ground; loose grounds cause I2C bus crashes.
SDA A4 I2C Data. Do not use PWM on this pin while reading the sensor.
SCL A5 I2C Clock. Keep wire length under 15cm to prevent capacitance issues.
INT D2 Interrupt pin (Optional). Used for waking MCU, tied to hardware interrupt 0.

Step-by-Step Wiring Procedure

Safety & Hardware Warning: Never connect the SDA/SCL lines of a 5V Arduino directly to a raw 1.8V/3.3V MAX30102 sensor without a level shifter or pull-ups to 3.3V. The Nano outputs 5V on I2C, which can fry the sensor's internal logic if the breakout board lacks protection diodes.
  1. Verify the Breakout Voltage: Flip your MAX30102 board over. If you see a small 3-pin SMD component (the LDO) and 4.7kΩ resistors near the I2C pins, wire VCC to the Nano's 5V pin. If the board is completely bare, wire VCC to the Nano's 3.3V pin.
  2. Connect I2C Lines: Wire SDA to A4 and SCL to A5. Keep these wires short and routed away from any AC mains transformers or high-current DC motor lines to prevent induced noise.
  3. Establish Ground: Connect the sensor GND to the Nano GND. If you are powering the Nano via USB, ensure the USB port can supply at least 500mA, as the IR LED can draw peak currents during initialization.
  4. Add Pull-ups (If Needed): If you are using a cheap clone board and your I2C scanner fails to find the sensor, solder a 4.7kΩ resistor between SDA and 3.3V, and another 4.7kΩ between SCL and 3.3V. The Arduino Wire library relies on these pull-ups to pull the bus high.

Complete Compilable Arduino Code

This code uses the industry-standard SparkFun MAX3010x library. Install it via the Arduino IDE Library Manager by searching for SparkFun MAX3010x. The code includes explicit pin definitions, I2C initialization error handling, and a moving average filter to stabilize the BPM output.

#include <Wire.h>
#include "MAX30105.h"
#include "heartRate.h"

// --- PIN DEFINITIONS ---
// The MAX3010x uses hardware I2C. On Nano v3, SDA is A4, SCL is A5.
// INT pin is mapped to D2 (Hardware Interrupt 0) if using wake-on-pulse.
#define SENSOR_INT_PIN 2

MAX30105 particleSensor;

const byte RATE_SIZE = 4; // Increase this for more averaging
byte rates[RATE_SIZE];    // Array of heart rates
byte rateSpot = 0;
long lastBeat = 0;        // Time at which the last beat occurred
float beatsPerMinute;
int beatAvg;

void setup() {
  Serial.begin(115200);
  
  // Initialize I2C bus
  Wire.begin();
  Wire.setClock(400000); // Set I2C clock to 400kHz for faster data throughput

  Serial.println("Initializing MAX30102 Heartbeat Sensor...");

  // Initialize sensor with error handling
  // 0x57 is the default I2C address for MAX3010x family
  if (!particleSensor.begin(Wire, I2C_SPEED_FAST)) {
    Serial.println("ERROR: MAX30105 was not found. Please check wiring/power.");
    Serial.println("Halt: Check I2C pull-ups and VCC voltage.");
    while (1); // Halt execution to prevent bus flooding
  }
  
  Serial.println("Place your index finger on the sensor with steady pressure.");

  // Sensor configuration optimized for heart rate
  particleSensor.setup(); // Configure sensor with default settings
  particleSensor.setPulseAmplitudeRed(0x0A); // Lower red LED power for better signal
  particleSensor.setPulseAmplitudeIR(0x1F);  // IR LED power
  particleSensor.setPulseAmplitudeGreen(0);  // Turn off green LED (not used for HR)
  
  particleSensor.setSampleRate(50);    // 50 samples per second
  particleSensor.setLedMode(2);        // Use only Red and IR
  particleSensor.setAdcRange(4096);    // 18-bit ADC range
}

void loop() {
  long irValue = particleSensor.getIR();

  // Check if finger is present (IR value threshold)
  if (checkForBeat(irValue) == true) {
    // We sensed a beat!
    long delta = millis() - lastBeat;
    lastBeat = millis();

    beatsPerMinute = 60 / (delta / 1000.0);

    // Sanity check for human heart rates (30 to 220 BPM)
    if (beatsPerMinute > 30 && beatsPerMinute < 220) {
      rateSpot++;
      rateSpot = rateSpot % RATE_SIZE; // Wrap around buffer
      rates[rateSpot] = (byte)beatsPerMinute; // Store current BPM

      // Calculate running average
      beatAvg = 0;
      for (byte x = 0; x < RATE_SIZE; x++) {
        beatAvg += rates[x];
      }
      beatAvg /= RATE_SIZE;
    }
  }

  // Serial output formatting
  Serial.print("IR=");
  Serial.print(irValue);
  Serial.print(", BPM=");
  Serial.print(beatsPerMinute, 1);
  Serial.print(", Avg BPM=");
  Serial.print(beatAvg);
  
  if (irValue < 50000) {
    Serial.print(" | No finger?");
  }
  Serial.println();
  
  delay(20); // Small delay to prevent serial buffer overflow
}

Debugging: Fixing "Not Found" Errors & Noisy Data

When working with I2C sensors on the bench, things rarely work perfectly on the first upload. If your serial monitor outputs the exact error string: "ERROR: MAX30105 was not found. Please check wiring/power.", do not assume the sensor is dead. Follow this ranked diagnostic path.

The First Three Things to Check

  1. Run an I2C Scanner Sketch: Upload the standard Arduino 'I2C Scanner' example. The MAX30102 should respond at address 0x57. If it shows up at 0x5E or not at all, you have a wiring or pull-up resistor issue.
  2. Verify I2C Pull-Up Resistors: This is the #1 cause of failure on clone boards. The Arduino Nano's internal pull-ups are too weak (approx. 20kΩ-50kΩ) for reliable 400kHz I2C communication. If your breakout lacks the 4.7kΩ SMD resistors, the SDA/SCL lines will float, causing the Wire.begin() handshake to fail.
  3. Check VCC vs. VIN Logic: If you wired 5V to a raw sensor board (one without an LDO), you may have browned out the internal 1.8V logic core. Touch the sensor chip; if it is burning hot, disconnect immediately. You must use 3.3V for raw boards.

Fixing Noisy or Erratic BPM Readings

If the sensor initializes but the BPM jumps wildly (e.g., 40 BPM to 180 BPM in seconds), you are experiencing motion artifacts or ambient light saturation.

  • Finger Pressure: Pressing too hard restricts capillary blood flow, flattening the PPG waveform. Pressing too light allows ambient room light (especially 50/60Hz fluorescent flicker) to hit the photodiode. Use a Velcro strap or a 3D-printed clip to apply consistent, medium pressure.
  • Lower the LED Current: In the code, change particleSensor.setPulseAmplitudeIR(0x1F); to 0x0F. If the IR LED is too bright, the photodiode saturates, clipping the top of the waveform and confusing the peak-detection algorithm.

How to Extend or Simplify the Build

Depending on your end goal, you may want to scale this project up into a standalone kiosk or strip it down for a basic trigger mechanism.

Simplifying the Build

If you do not need exact BPM calculations or SpO2 data, and only want to trigger an LED or a relay every time a heart beats (e.g., for a Halloween prop or an art installation), switch to the Analog Pulse Sensor Amped. You can read the analog pin, apply a basic software threshold, and trigger an output. You sacrifice clinical accuracy and noise rejection, but you eliminate the I2C bus entirely, reducing wiring to three pins and code to about 15 lines.

Extending the Build

To make this a standalone diagnostic tool, you need to remove the dependency on the Serial Monitor.

  • Add a Display: Wire a 0.96-inch SSD1306 I2C OLED display to the same I2C bus (SDA/SCL). The SSD1306 uses address 0x3C, which will not conflict with the MAX30102 (0x57). Use the Adafruit_SSD1306 library to render the BPM in large, readable text.
  • Add Wireless Telemetry: Swap the Arduino Nano for an ESP32 DevKit v1. The ESP32 has native BLE (Bluetooth Low Energy). You can use the standard BLE Heart Rate Service profile to transmit the BPM directly to a smartphone app or a smartwatch, turning your breadboard build into a functional chest-strap alternative.
  • Implement FIR Filtering: For advanced signal processing, port the code to a Teensy 4.1 and implement a Finite Impulse Response (FIR) bandpass filter (0.5Hz to 3.5Hz) to completely eliminate respiratory artifacts and high-frequency muscle noise from the PPG signal.