What You'll Build
A fingertip pulse monitor: you rest a finger on a tiny MAX30102 module and your ESP32 or Arduino Uno prints your heart rate in beats per minute and your blood oxygen saturation (SpO2) over the Serial Monitor. Along the way you will also stream the raw optical waveform to the Serial Plotter, so you can actually see your pulse.
You will learn:
- How a pulse oximeter turns red and infrared light into a heart rate and an oxygen percentage
- How to wire the MAX30102 to an ESP32 and to an Arduino Uno (and the voltage trap to avoid)
- How to confirm the sensor with an I2C scan, then read beats with
checkForBeat() - How to compute SpO2 with the Maxim algorithm, including the RAM trick that makes it fit on an Uno
- Why readings jump around and how to get steadier ones
| Difficulty | Beginner to Intermediate |
| Build time | About 30 minutes |
| Boards | ESP32 DevKit or Arduino Uno |
| Interface | I2C (address 0x57) |
Not a medical device. The MAX30102 is a hobby-grade sensor. Use this project to learn, never to make health decisions. See the accuracy section below.
How a Pulse Oximeter Works
The MAX30102 contains two LEDs and one photodiode, all facing the same direction. One LED is red (about 660 nm) and the other is infrared (about 880 nm). When your fingertip covers the module, light from the LEDs travels through the tissue and a fraction is reflected back to the photodiode. This technique is called photoplethysmography (PPG).
Two things make the measurement possible:
1. Your pulse changes how much light comes back. Each heartbeat pushes a wave of blood into the capillaries of your finger. More blood means more absorption and less reflected light, so the photodiode signal rises and falls in time with your heart. Count the peaks and you have your heart rate.
2. Oxygenated and deoxygenated blood absorb red and infrared differently. Oxygen-rich hemoglobin (HbO2) absorbs less red light and more infrared than oxygen-poor hemoglobin (Hb). By comparing the two wavelengths you can estimate what fraction of your hemoglobin is carrying oxygen.
The comparison is done with a ratio of ratios. For each wavelength, take the pulsing part of the signal (AC) divided by the steady part (DC), then divide red by infrared:
R = (AC_red / DC_red) / (AC_ir / DC_ir)
A popular rule of thumb maps R to saturation as roughly SpO2 ≈ 110 - 25 x R. Real devices, including the Maxim algorithm used below, use an empirical curve fitted against calibrated reference measurements, but the idea is identical: a lower R means more oxygen.
MAX30102 at a Glance
| Spec | Value |
|---|---|
| Chip | Analog Devices (Maxim) MAX30102 |
| Measures | Heart rate (pulse) and SpO2 |
| LEDs | Red 660 nm, infrared 880 nm |
| ADC resolution | Up to 18 bits |
| Sample rate | 50 to 3200 samples per second (selectable) |
| LED current | Up to about 50 mA (programmable) |
| Interface | I2C, address 0x57, up to 400 kHz |
| Supply (chip) | 1.8 V core, 3.3 V LED supply |
| Supply (module VIN) | Usually 3.3 V (check your board) |
| Extras | On-die temperature sensor, 32-sample FIFO, interrupt pin |
The older MAX30100 is a different chip with a different address and register set. This article is for the MAX30102 (and the near-identical MAX30105, which adds a green LED). The SparkFun library below supports all of these.
Components
5 itemsModule Pinout
Most modules break out seven pins. You only need four.
| Pin | Function | Connect to |
|---|---|---|
| VIN | Power input | 3.3 V |
| GND | Ground | GND |
| SCL | I2C clock | SCL pin of your board |
| SDA | I2C data | SDA pin of your board |
| INT | Interrupt (active low) | Not needed for this article |
| IRD | IR LED cathode | Leave unconnected |
| RD | Red LED cathode | Leave unconnected |
IRD and RD give access to the LED cathodes for custom drive circuits. Wiring them to anything will break the sensor's LED control, so leave them floating.
Wiring
ESP32 DevKit
| MAX30102 | ESP32 |
|---|---|
| VIN | 3V3 |
| GND | GND |
| SDA | GPIO 21 |
| SCL | GPIO 22 |
Arduino Uno
| MAX30102 | Uno |
|---|---|
| VIN | 3.3V |
| GND | GND |
| SDA | A4 |
| SCL | A5 |
On newer Uno R3 boards SDA and SCL are also broken out next to AREF. They are the same signals as A4 and A5.
⚠️ Warning
Power the module from **3.3 V**, not 5 V. Many breakouts tie the I2C pull-up resistors to the same rail as VIN, so powering from 5 V would push 5 V into the sensor chip's 3.3 V-tolerant pins. The Uno's I2C lines swing to 5 V, which the module's 3.3 V pull-ups do not prevent. It commonly works, but for a permanent build on an Uno, put a bidirectional I2C level shifter between the two. The ESP32 is natively 3.3 V and needs nothing extra.
Steps
- 1Wire the four connections from the tables above with the board unpowered.
- 2In the Arduino IDE open Library Manager and install **SparkFun MAX3010x Pulse and Proximity Sensor Library**.
- 3Select your board (ESP32 Dev Module or Arduino Uno) and the correct port.
- 4Upload the I2C scanner below and confirm the sensor appears at 0x57.
- 5Upload the heart rate sketch, place a fingertip on the sensor, and watch the Serial Monitor at 115200 baud.
Step 1: Confirm the Sensor with an I2C Scan
Before blaming code, make sure the board can see the sensor. This sketch tries every I2C address and prints the ones that answer. You should see exactly one device, at 0x57.
If you see "No I2C devices found", recheck SDA and SCL (they are easy to swap) and the 3.3 V and GND connections. If you see a different address, you probably have a different sensor on the bus. For more on how I2C addressing works, read I2C vs SPI: Differences and When to Use Each.
Step 2: Detect Heartbeats and Read BPM
This sketch uses the library's checkForBeat() helper, which watches the infrared signal for the characteristic rising-then-falling shape of a pulse. Each time it fires, we measure the time since the previous beat, convert that to BPM, and keep a rolling average of the last four beats so one noisy beat does not make the display jump.
A few details worth understanding:
- Finger detection: when no finger is present the IR reading is small. Above roughly 50,000 counts means skin is covering the sensor. The threshold is a starting point; a different module or LED current may need a different value.
- Sanity limits: the sketch ignores any beat outside 30 to 220 BPM, which filters out the glitch you get from a single missed or double-counted beat.
- Settings:
sensor.setup(0x1F, 4, 2, 400, 411, 4096)selects a low LED current, 4-sample averaging, red plus IR mode, 400 samples per second (so 100 per second after averaging), a 411 microsecond pulse width, and the 4096 nA ADC range.
The first one or two readings after you place your finger are usually off, because the algorithm needs a couple of beats to lock on. Give it five to ten seconds.
Step 3: See Your Pulse in the Serial Plotter
Numbers are useful, but nothing builds intuition like watching the waveform. This tiny sketch streams the raw IR and red readings. Open Tools > Serial Plotter at 115200 baud and put a finger on the sensor.
What you should see is a large, slowly varying baseline (the DC part) with a small ripple riding on it (the AC part, your pulse). The IR line is typically larger than the red line. If the waveform is flat, the finger is not covering the sensor well. If it is jagged and chaotic, the finger is moving or pressing too hard.
This is also the best debugging tool you have. When the BPM output looks wrong, look at the waveform first: a clean periodic signal gives good readings and a messy one never will.
Step 4: Add SpO2
For oxygen saturation we use the Maxim reference algorithm shipped with the SparkFun library (spo2_algorithm.h). Unlike the beat detector it does not work sample by sample. It analyses a window of 100 samples (4 seconds at 25 samples per second), finds the pulse peaks, computes the red/IR ratio R, and maps it to a percentage. It also returns its own heart rate.
The sketch uses a sliding window: after the first 4 seconds it drops the oldest 25 samples, collects 25 fresh ones (about 1 second), and recalculates. That gives you an updated result every second with a 4-second memory.
The Uno RAM trick. The reference code stores each sample in a 32-bit integer. Two buffers of 100 samples is 800 bytes, which is 40 percent of the Uno's 2 KB of RAM. The sketch below detects the AVR chip at compile time and keeps 16-bit samples instead, shifting each 18-bit reading right by two bits. The ratio of ratios barely changes, because both channels are scaled equally, so SpO2 is unaffected while memory use is halved. On the ESP32 the full 32-bit values are kept.
A -- means the algorithm did not find a trustworthy result in that window, which is normal while you settle your finger. Only trust values after they have been stable for several consecutive seconds.
Understanding the setup() Parameters
sensor.setup(ledBrightness, sampleAverage, ledMode, sampleRate, pulseWidth, adcRange) is where most tuning happens.
| Parameter | Options | What it does |
|---|---|---|
| ledBrightness | 0 to 255 (about 0 to 50 mA) | LED drive current. Higher means a stronger signal but more heat and power. |
| sampleAverage | 1, 2, 4, 8, 16, 32 | Hardware averaging. More means less noise and a lower effective rate. |
| ledMode | 1 (red), 2 (red + IR), 3 (red + IR + green*) | SpO2 needs mode 2. *Green is MAX30105 only. |
| sampleRate | 50, 100, 200, 400, 800, 1000, 1600, 3200 | Raw samples per second before averaging. |
| pulseWidth | 69, 118, 215, 411 | LED pulse in microseconds. Longer means higher resolution (15 to 18 bits). |
| adcRange | 2048, 4096, 8192, 16384 | Full-scale current. Larger avoids saturation under bright light or strong LEDs. |
The two sketches use different settings on purpose. The beat detector wants a high sample rate for a smooth waveform. The SpO2 algorithm was designed around 25 samples per second, so it uses 100 samples per second with 4-sample averaging.
Getting Steady Readings
Optical sensors are extremely sensitive to how they are used. Most "my sensor is wrong" problems are technique, not hardware.
- Light pressure. Rest the finger on the glass. Pressing hard squeezes blood out of the capillaries and flattens the pulse signal.
- Stay still. Movement is the number one cause of garbage readings. Rest your hand on a table.
- Cover the sensor completely. Ambient light leaking in adds noise.
- Warm hands. Cold fingers have poor blood flow and a weak signal.
- Right LED current. If the IR value is very low (a few thousand), increase
ledBrightness. If it sits at the maximum of 262143 for 18-bit mode, the sensor is saturated: reduce the LED current or enlarge the ADC range. - Wait for lock. Give the algorithm 5 to 10 seconds before trusting a number.
- Skin tone, nail polish and tattoos all affect optical absorption. Dark pigment and some nail colors reduce the signal and can bias SpO2.
🚨 Danger
**This is not a medical device.** The MAX30102 module and the Maxim algorithm are not clinically validated, and results can be off by several percent or several beats per minute. Hobby readings must never be used to diagnose, monitor or treat any condition. If you are worried about your health, use a certified device and speak to a medical professional.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| "MAX30102 not found" | Wiring, power, or wrong address | Run the I2C scanner; check SDA/SCL and 3.3 V; try another jumper. |
| Scanner shows no devices | SDA/SCL swapped, no pull-ups, no power | Swap SDA and SCL; confirm the module LED is lit. |
| Module LED does not light up | No power | Check VIN and GND; use a multimeter on VIN. |
| IR value is always tiny | Finger absent or LED current too low | Cover the sensor; raise ledBrightness. |
| IR value stuck at maximum | Saturation | Lower LED current or increase adcRange. |
| BPM jumps around | Movement or pressure | Hold still; lighter touch; wait for averaging. |
SpO2 always -- | Algorithm cannot lock | Keep finger steady for 5 to 10 s; check the raw waveform. |
| SpO2 reads 100 % or in the 80s constantly | Poor signal or wrong settings | Use ledMode 2, the SpO2 settings above, and a steady finger. |
| Uno resets or prints garbage | RAM exhaustion | Use the Uno-aware sketch in this article; avoid extra large buffers or strings. |
| Works on ESP32, flaky on Uno | 5 V I2C levels on a 3.3 V module | Add an I2C level shifter and power the module from 3.3 V. |
Ideas to Extend
- OLED display. Show BPM and SpO2 on a small SSD1306 screen over the same I2C bus, so the project works without a computer.
- Bluetooth heart rate service. Advertise the BPM through the standard BLE Heart Rate profile and read it on your phone. Start with the ESP32 BLE Scanner guide to learn how BLE advertising works.
- MQTT logging. Publish readings and chart them over time using the ESP32 MQTT tutorial.
- Battery power. Put the ESP32 to sleep between spot checks, as described in the ESP32 deep sleep guide.
- Temperature. The MAX30102 has an on-die temperature sensor that you can read with
sensor.readTemperature(). It measures the chip, not your body, but is handy for sanity checks.
FAQ
Can I use the MAX30100 library for the MAX30102? No. They are different chips with different register maps. Use the SparkFun MAX3010x library for the MAX30102.
Does this work on 5 V Arduino boards like the Uno? Yes, with the module powered from 3.3 V. Add a level shifter for a permanent build.
Why is my SpO2 always 99 or 100 percent? A healthy person at rest normally reads 95 to 100 percent, so a high value is often plausible. If it never changes regardless of finger or conditions, check the raw waveform for a saturated or flat signal.
Can it measure from the wrist? Not reliably with this module. Fingertips and earlobes have far stronger pulsatile signals than a wrist, and wrist readings need careful optical design and motion compensation.
What is the difference between the two sketches' heart rates? The beat detector reacts to every pulse and averages four. The Maxim algorithm computes the rate across a 4-second window, so it is smoother but slower to respond.
Related on SolderHub
- I2C vs SPI: Differences and When to Use Each
- ESP32 Pinout: The Complete Guide
- Arduino Uno Pinout: The Complete Guide
- ESP32 BLE Scanner Tutorial
Published on SolderHub