To build a reliable optical Arduino heart rate monitor, you need a photoplethysmography (PPG) sensor, a microcontroller to process the signal, and a display. The most robust setup for hobbyists and bench prototyping uses the MAX30102 pulse oximeter and heart-rate sensor paired with an Arduino Nano V3.0 (ATmega328P) and a 128x64 I2C OLED. Unlike older analog pulse sensors that rely on basic ambient light blocking, the MAX30102 uses integrated red and IR LEDs with a high-resolution ADC to measure blood volume changes directly, yielding clinical-grade resting heart rate data when properly filtered.

This guide provides the exact parts list, I2C pin mapping, complete compilable C++ code with error handling, and a debugging framework for the most common sensor initialization failures.

Project Spec Sheet & Parts List

The MAX30102 is strictly a 3.3V device. Feeding 5V into its I2C data lines will permanently destroy the internal logic. When buying breakout boards, you must select one with built-in logic level shifters and voltage regulation, or use a dedicated 3.3V microcontroller. The parts below assume a standard 5V Arduino Nano workflow.

Component Exact Variant / Model Est. Price (2026) Critical Notes
Microcontroller Arduino Nano V3.0 (ATmega328P) $4.00 - $22.00 Use the classic bootloader variant. Ensure it has the CH340 or FT232RL USB chip.
PPG Sensor MAX30102 Breakout (with level shifters) $8.00 - $15.00 Must include 4.7kΩ I2C pull-up resistors and a 3.3V LDO. Avoid bare generic boards without level shifting.
Display SSD1306 128x64 I2C OLED (0.96") $4.00 - $7.00 4-pin I2C version (VCC, GND, SCL, SDA). Address is typically 0x3C.
Wiring 28 AWG Silicone Jumper Wires $5.00 Silicone insulation prevents melting if soldering near the board.

Pin Mapping & Wiring Steps

Both the MAX30102 and the SSD1306 OLED communicate over the I2C bus. The Arduino Nano V3.0 shares its I2C lines on analog pins A4 (SDA) and A5 (SCL). Because the Nano is a 5V board, the MAX30102 breakout's level shifters will safely step the 5V I2C signals down to 3.3V.

Component Pin Arduino Nano Pin Function
MAX30102 VIN 5V Power input (feeds onboard 3.3V LDO)
MAX30102 GND GND Common ground
MAX30102 SDA A4 I2C Data (Level shifted)
MAX30102 SCL A5 I2C Clock (Level shifted)
MAX30102 INT D2 Interrupt (Optional for this code, but wired for future use)
OLED VCC 5V (or 3.3V) Power (Check OLED silkscreen for voltage tolerance)
OLED GND GND Common ground
OLED SDA A4 I2C Data (Shared bus)
OLED SCL A5 I2C Clock (Shared bus)
Callout Tip: I2C Bus Capacitance
When running two I2C devices on the same bus, keep your wire lengths under 12 inches (30 cm). Longer wires increase bus capacitance, which can pull the I2C rise times out of spec and cause the OLED to flicker or the sensor to drop packets. If you need longer runs, add 2.2kΩ external pull-up resistors to the SDA and SCL lines.

Complete Compilable Code

This firmware targets the Arduino Nano V3.0 (ATmega328P). It uses the SparkFun MAX3010x library (which fully supports the MAX30102) and the Adafruit SSD1306 library. The code includes a moving average filter to smooth the BPM calculation and explicit error handling if the sensor fails to initialize.

Required Libraries (Install via Arduino Library Manager):

  • SparkFun MAX3010x Pulse and Proximity Sensor Library
  • Adafruit SSD1306
  • Adafruit GFX Library
#include <Wire.h>
#include <MAX30105.h>
#include <heartRate.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// --- Pin & Display Definitions ---
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C

// --- Object Instantiation ---
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
MAX30105 particleSensor;

// --- Heart Rate Variables ---
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 of the last detected beat
float beatsPerMinute;
int beatAvg;

void setup() {
  Serial.begin(115200);
  
  // Initialize OLED
  if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 allocation failed"));
    for(;;); // Halt execution
  }
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.println("Initializing...");
  display.display();

  // Initialize MAX30102 Sensor
  if (!particleSensor.begin(Wire, I2C_SPEED_FAST)) {
    Serial.println("MAX3010x was not found. Please check wiring/power.");
    display.clearDisplay();
    display.setCursor(0, 0);
    display.println("Sensor Error!");
    display.println("Check I2C wiring");
    display.display();
    while(1); // Halt execution
  }

  // Configure Sensor Parameters
  byte ledMode = 2;           // 2 = Red + IR (Heart Rate), 3 = SpO2
  int sampleRate = 400;       // Samples per second
  int pulseWidth = 411;       // ADC resolution
  int sampleAvg = 4;          // Averaging
  int ledBrightness = 0x1F;   // ~6.4mA (adjust 0-255 for finger sensitivity)
  
  particleSensor.setup(ledBrightness, sampleAvg, ledMode, sampleRate, pulseWidth);
  
  display.clearDisplay();
  display.display();
}

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

  if (irValue > 50000) { // Finger detected threshold
    if (checkForBeat(irValue) == true) {
      long delta = millis() - lastBeat;
      lastBeat = millis();

      beatsPerMinute = 60 / (delta / 1000.0);

      if (beatsPerMinute > 40 && beatsPerMinute < 200) {
        rates[rateSpot++] = (byte)beatsPerMinute;
        rateSpot %= RATE_SIZE;

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

    // Update OLED Display
    display.clearDisplay();
    display.setTextSize(1);
    display.setCursor(0, 0);
    display.println("Heart Rate Monitor");
    
    display.setTextSize(2);
    display.setCursor(0, 20);
    display.print("BPM: ");
    display.println(beatAvg);
    
    display.setTextSize(1);
    display.setCursor(0, 50);
    display.print("Raw IR: ");
    display.println(irValue);
    
    display.display();
    Serial.print("BPM="); Serial.print(beatAvg); Serial.print(" IR="); Serial.println(irValue);

  } else { // Finger removed
    display.clearDisplay();
    display.setTextSize(1);
    display.setCursor(0, 25);
    display.println("Place finger on sensor");
    display.display();
    
    // Reset averages to prevent stale data
    for (byte x = 0 ; x < RATE_SIZE ; x++) rates[x] = 0;
    beatAvg = 0;
  }
}

Debugging: "MAX3010x was not found" Error

If your serial monitor outputs the exact string "MAX3010x was not found. Please check wiring/power." and the code halts, the Arduino Nano cannot communicate with the sensor over I2C. This is the most common failure mode in optical PPG builds.

The First Three Things to Check:

  1. Verify I2C Addresses with a Scanner: Upload the standard Arduino I2C_Scanner example sketch. The MAX30102 should appear at address 0x57. If it shows up as 0x56 or not at all, you have a hardware or addressing issue.
  2. Check for 5V Logic Damage: If you wired a bare MAX30102 module (without level shifters) directly to the Nano's 5V A4/A5 pins, the sensor's internal I2C transceiver is likely fried. The MAX30102 absolute maximum rating for I/O pins is 3.6V. You must replace the sensor and use a level-shifting breakout.
  3. Inspect Solder Joints on the Breakout: Many cheap generic MAX30102 boards ship with cold solder joints on the header pins. Reflow the 6-pin header with a soldering iron set to 350°C (660°F) using flux-core 60/40 or SAC305 solder.
Ranked Causes for I2C Failure:
1. Missing I2C pull-up resistors on the breakout board (requires adding external 4.7kΩ resistors to 3.3V).
2. I2C bus lockup caused by a reset while the sensor was mid-transmission (fix by power-cycling the entire breadboard).
3. SDA/SCL wires swapped (A4 is SDA, A5 is SCL on the Nano).

Extending and Simplifying the Build

Depending on your end goal, you may want to strip this project down to its bare essentials or scale it up into a wearable IoT device.

How to Simplify (Bench Testing Mode)

If you only want to view the raw PPG waveform to tune the LED brightness or test a custom digital bandpass filter, remove the OLED display entirely. Delete the Adafruit library includes and display calls. Open the Arduino IDE's Serial Plotter (Tools > Serial Plotter) and ensure your baud rate is set to 115200. Print only the irValue to the serial port. This reduces loop execution time and gives you a clean visual of the systolic and diastolic peaks.

How to Extend (BLE Wearable Mode)

To make this a wireless wearable, swap the Arduino Nano for an ESP32-WROOM-32 DevKit V1. The ESP32 operates natively at 3.3V, eliminating the need for I2C level shifters. You can use the BLEDevice library to broadcast the beatAvg variable over a custom GATT service to a smartphone app or an MQTT broker for remote patient monitoring dashboards. Note that the ESP32's default I2C pins are GPIO 21 (SDA) and GPIO 22 (SCL), so update your pin definitions accordingly.

Frequently Asked Questions

How accurate is an Arduino heart rate monitor compared to medical ECG devices?

An Arduino heart rate monitor using the MAX30102 measures pulse rate via optical PPG, not electrical activity like an ECG. At rest, a properly calibrated MAX30102 is typically accurate within ±2 to ±3 BPM of a clinical ECG. However, PPG accuracy degrades significantly during high-motion activities due to motion artifacts disrupting the optical signal. According to research published in the National Institutes of Health (NIH) regarding wearable PPG sensors, optical heart rate monitors perform best under controlled, low-motion conditions and struggle with darker skin tones or heavy perfusion deficits unless LED current is dynamically adjusted.

Why does my Arduino heart rate monitor drop readings when I move my finger?

This is caused by motion artifacts. The MAX30102 calculates heart rate by measuring the AC component (pulsatile blood flow) superimposed on the DC component (tissue, venous blood, and bone). When you move your finger, the mechanical shifting of the tissue creates massive low-frequency noise that swamps the tiny AC pulse signal. To fix this, ensure your finger is resting flat on a hard surface, apply consistent but light pressure (pressing too hard restricts capillary blood flow and flattens the pulse wave), and increase the sampleAvg parameter in the code from 4 to 8 to enable the sensor's internal hardware averaging.

Can I power this Arduino heart rate monitor with a lithium battery for wearable use?

Yes, but you must manage the voltage regulation carefully. A single-cell LiPo battery (3.7V nominal, 4.2V fully charged) can power the MAX30102 directly via its 3.3V pin (bypassing the LDO), but it cannot power a standard 5V Arduino Nano without a boost converter. For battery operation, use a 3.3V microcontroller like the Arduino Nano 33 IoT or an ESP32, and wire the LiPo through a dedicated TP4056 charge controller and a low-dropout (LDO) regulator to ensure the voltage never exceeds the sensor's 3.6V absolute maximum limit. Never wire a lithium cell directly to a microcontroller without a protection circuit module (PCM) to prevent over-discharge and thermal runaway.