To build a functional pulse rate monitor with Arduino, you need an Arduino Uno R3, a MAX30102 optical sensor breakout, and a 0.96-inch I2C OLED display. The MAX30102 uses photoplethysmography (PPG)—shining an infrared LED into the skin and measuring the light reflected back to detect micro-changes in blood volume. With the correct hardware variant and I2C configuration, this setup yields a stable beats-per-minute (BPM) reading within 4 to 6 seconds of finger placement.
This guide covers the exact hardware variants to avoid common 1.8V logic failures, provides a complete pin mapping, and includes a fully compilable Arduino sketch with a custom peak-detection algorithm and I2C error handling.
Hardware Selection & Sensor Comparison
The most common failure point in DIY optical heart rate projects is buying the wrong sensor breakout. The raw Maxim Integrated (now Analog Devices) MAX30102 chip operates at 1.8V logic and power. If you wire a standard 5V Arduino Uno directly to a bare chip, you will instantly fry the I2C transceiver. You must use a breakout board that includes a 3.3V Low Dropout Regulator (LDO) and logic-level shifters (like BSS138 MOSFETs).
Here is how the MAX30102 compares to other common optical sensors on the maker market as of 2026:
| Sensor Module | Interface & Voltage | I2C Address | Typical Cost (2026) | SpO2 Capability | Max Sample Rate |
|---|---|---|---|---|---|
| MAX30102 (with LDO/Shifters) | I2C / 3.3V-5V tolerant | 0x57 | $6 - $9 | Yes (Red + IR) | 3200 SPS |
| MAX30100 (Legacy) | I2C / 3.3V-5V tolerant | 0xAE | $4 - $6 | Yes (Red + IR) | 100 SPS |
| PulseSensor Amped | Analog / 3.3V-5V | N/A (Analog) | $24 - $28 | No (Green LED only) | ~500 Hz (ADC limited) |
| MAX30105 (Particle Sensor) | I2C / 3.3V-5V tolerant | 0x57 | $14 - $18 | No (Red + IR + Green) | 3200 SPS |
Exact Parts List
- Microcontroller: Arduino Uno R3 (Rev3, ATmega328P DIP package). Note: The code targets the standard 5V/16MHz Uno architecture.
- Sensor: MAX30102 Breakout Board. Look for the GY-MAX30102 variant or the SparkFun MAX30105 (software-compatible). Ensure the board has a visible 6-pin voltage regulator and MOSFETs near the I2C pins.
- Display: 0.96" SSD1306 OLED (I2C interface, 128x64 resolution, 4-pin header).
- Wiring: 22 AWG solid-core jumper wires, half-size breadboard.
Pin Mapping & Wiring Procedure
Both the MAX30102 and the SSD1306 OLED communicate over the I2C bus. The Arduino Uno R3 has dedicated hardware I2C pins. Because both devices share the same SDA and SCL lines, we must ensure their I2C addresses do not conflict (the MAX30102 defaults to 0x57 and the OLED to 0x3C, so they coexist perfectly).
| Arduino Uno R3 Pin | MAX30102 Breakout Pin | SSD1306 OLED Pin | Function / Notes |
|---|---|---|---|
| 5V | VIN (or VCC) | VCC | Power (Breakout LDO steps this down to 3.3V/1.8V internally) |
| GND | GND | GND | Common Ground (Critical for I2C reference) |
| A4 (SDA) | SDA | SDA | I2C Data Line |
| A5 (SCL) | SCL | SCL | I2C Clock Line |
| D2 (INT) | INT | - | Interrupt (Optional, left unconnected in this basic build) |
Complete Arduino IDE Code with Error Handling
This sketch is written for the Arduino Uno R3 (ATmega328P). It uses the SparkFun MAX3010x library to handle the sensor initialization and the Adafruit SSD1306 library for the display. Instead of relying on heavy external heart-rate algorithm libraries that often cause memory overflows on the Uno's 2KB SRAM, this code implements a lightweight, self-contained moving-average peak detector.
Required Libraries (Install via Arduino Library Manager): SparkFun MAX3010x, Adafruit SSD1306, Adafruit GFX Library.
#include <Wire.h>
#include <MAX30105.h> // SparkFun library supports both 30102 and 30105
#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
MAX30105 particleSensor;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// --- Algorithm Variables ---
long lastBeat = 0;
float bpm = 0.0;
int irThreshold = 50000; // Adjust based on ambient light and finger pressure
bool beatDetected = false;
void setup() {
Serial.begin(115200);
// Initialize OLED with error handling
if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
Serial.println(F("SSD1306 allocation failed. Check I2C wiring."));
for(;;); // Halt execution
}
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0,0);
display.print("Initializing Sensor...");
display.display();
// Initialize MAX30102 with error handling
if (!particleSensor.begin(Wire, I2C_SPEED_FAST)) {
Serial.println("MAX3010x was not found. Please check wiring/power.");
display.clearDisplay();
display.setCursor(0,0);
display.print("ERROR: Sensor\nNot Found!");
display.display();
while(1); // Halt
}
// Configure sensor for heart rate (Optimized for 50Hz sample rate)
byte ledMode = 2; // 2 = Red + IR (needed for SpO2, but we use IR for HR)
int sampleRate = 50; // 50 samples per second
int pulseWidth = 411; // 411us
int sampleAverage = 4; // Average 4 samples to reduce noise
int ledBrightness = 0x1F; // 0x1F = ~6.4mA (Safe for continuous skin contact)
particleSensor.setup(ledBrightness, sampleAverage, ledMode, sampleRate, pulseWidth);
display.clearDisplay();
display.setCursor(0,0);
display.print("Place finger\non sensor...");
display.display();
}
void loop() {
long irValue = particleSensor.getIR();
// Check if finger is present (IR value above noise floor)
if (irValue < 5000) {
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.print("No finger detected");
display.display();
bpm = 0;
lastBeat = 0;
return;
}
// Simple Peak Detection Algorithm
// If the IR value crosses the threshold and we haven't detected a beat recently
if (irValue > irThreshold && !beatDetected) {
beatDetected = true;
long currentTime = millis();
if (lastBeat != 0) {
long timeDelta = currentTime - lastBeat;
// Calculate BPM: 60,000 ms in a minute
float currentBpm = 60000.0 / timeDelta;
// Filter out physiological impossibilities (Human resting HR is 40-200)
if (currentBpm > 40 && currentBpm < 200) {
// Exponential moving average for smoothing
bpm = (bpm * 0.7) + (currentBpm * 0.3);
}
}
lastBeat = currentTime;
}
// Reset beat detection when signal drops below threshold
if (irValue < (irThreshold * 0.8)) {
beatDetected = false;
}
// Update Display
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.print("Heart Rate (BPM):");
display.setTextSize(3);
display.setCursor(0, 20);
if (bpm > 0) {
display.print(bpm, 0); // Print as integer
} else {
display.print("---");
}
display.setTextSize(1);
display.setCursor(0, 52);
display.print("IR Raw: ");
display.print(irValue);
display.display();
// Small delay to match ~50Hz sample rate processing
delay(10);
}
Debugging: First Three Things to Check When It Fails
Optical PPG sensors are notoriously finicky on the workbench. If your monitor is failing, follow this ranked decision tree before rewriting code.
1. Serial Monitor Prints: "MAX3010x was not found"
The Cause: I2C Address NACK or a fried logic level. If you bought a cheap clone breakout without the BSS138 level shifters and wired it directly to the Uno's 5V I2C lines, you likely burned out the sensor's internal I2C transceiver. The official Analog Devices MAX30102 datasheet specifies an absolute maximum I2C voltage of 3.6V.
The Fix: Run an I2C scanner sketch. If the sensor doesn't show up at 0x57, the chip is dead. Replace it with a verified breakout that includes logic-level shifting, or use a dedicated bidirectional logic level converter between the Uno and a raw 1.8V sensor.
2. Serial Monitor Prints: "Wire.endTransmission() returned 2"
The Cause: I2C NACK on address due to missing pull-up resistors or excessive bus capacitance. The ATmega328P's internal pull-ups are ~30kΩ, which is too weak for the 400kHz Fast I2C mode used in the setup() function.
The Fix: Verify your breakout board has physical 4.7kΩ surface-mount resistors near the SDA/SCL pins. If you are using long jumper wires (over 20cm), the parasitic capacitance will round off the I2C clock edges. Switch to 100kHz Standard mode by changing I2C_SPEED_FAST to I2C_SPEED_STANDARD in the particleSensor.begin() call.
3. BPM Spikes to 200+ or Drops to 0 Erratically
The Cause: Ambient light flooding the photodiode, or finger pressure occluding the capillaries. The MAX30102 is highly sensitive to 50Hz/60Hz mains lighting flicker and direct sunlight. Furthermore, pressing your finger too hard against the glass squeezes the blood out of the capillary bed, flattening the PPG waveform.
The Fix: Build a simple shroud (a piece of black electrical tape or a 3D-printed cap) to block ambient light from the sides of the sensor. Rest your finger lightly on the glass—just enough to make contact. If the raw IR value printed on the OLED maxes out at 131071 (the 17-bit ADC ceiling), lower the ledBrightness variable in the code from 0x1F to 0x10.
Extending and Simplifying the Build
Depending on your end goal, you can strip this project down for rapid debugging or scale it up for wearable integration.
Simplify: Ditch the OLED for Serial Plotter
If you are just bench-testing the sensor or tuning the peak-detection threshold, remove the SSD1306 OLED entirely. Delete the Adafruit library calls and add Serial.println(irValue); inside the loop. Open the Arduino IDE and navigate to Tools > Serial Plotter (set baud to 115200). You will see the raw PPG waveform rendered in real-time. This is the fastest way to visually verify that your finger placement is generating clean systolic peaks before you bother wiring a display.
Extend: Add BLE with an ESP32
The Arduino Uno R3 lacks native wireless connectivity. If you want to stream BPM data to a smartphone app or a smartwatch, swap the Uno for an ESP32-WROOM-32 DevKit v1. The ESP32 operates natively at 3.3V, meaning you can wire the MAX30102 directly without logic level shifters. Use the NimBLE library to create a Bluetooth Low Energy (BLE) Heart Rate Service (UUID 0x180D). This allows your DIY monitor to pair natively with standard fitness apps on iOS and Android without writing custom socket code.






