A photoplethysmography sensor (PPG) measures blood volume changes in the microvascular bed of tissue using light. For embedded makers, the output is strictly divided into two camps: digital I2C sensors (like the MAX30102 or MAX30105) which output raw 18-bit ADC counts via a FIFO buffer, and analous sensors (like the PulseSensor Amped) which output a 0-3.3V or 0-5V DC-biased AC voltage waveform. You cannot treat them interchangeably; digital sensors require I2C register configuration and math-heavy firmware, while analog sensors require hardware op-amp filtering and microcontroller ADC sampling.

The Sensing Principle: Optical Absorption and the AC/DC Split

At the bench, a PPG sensor works by firing specific wavelengths of light (typically 660nm Red and 880nm Infrared) into the skin and measuring the reflected or transmitted photons with a photodetector. According to the modified Beer-Lambert Law, different biological tissues absorb light at different rates. Oxygenated hemoglobin (HbO2) absorbs more IR light and lets more Red light pass through, while deoxygenated hemoglobin (Hb) does the exact opposite. By comparing the ratio of Red to IR absorption, the sensor calculates blood oxygen saturation (SpO2).

The raw optical signal contains two distinct components: a massive DC component (caused by static tissue, bone, venous blood, and non-pulsatile arterial blood) and a tiny AC component (caused by the pulsatile expansion and contraction of arterial blood during each heartbeat). The AC component is typically only 1% to 5% of the total signal amplitude. Extracting that tiny AC ripple from the massive DC baseline without introducing phase distortion is the primary challenge of PPG firmware design.

Hardware Specs, Supply Ranges, and I2C Wiring

Before wiring anything, you must identify whether you are using a bare IC or a breakout board. The bare MAX30102 IC operates strictly at 1.8V logic and requires a 1.8V supply for its internal analog front-end. However, 99% of maker breakouts (from SparkFun, Adafruit, or generic marketplaces) include an onboard 1.8V LDO and I2C level-shifters, allowing you to safely power them from 3.3V or 5V. Always check your specific board's schematic.

Table 1: Common Maker PPG Sensor Comparison
Sensor ModuleInterface / OutputWavelengthsPrimary Use Case
MAX30102I2C (18-bit Digital ADC)Red (660nm) + IR (880nm)Heart Rate + SpO2
MAX30105I2C (18-bit Digital ADC)Red + IR + Green (530nm)Particle/Smoke + HR
PulseSensor AmpedAnalog (0-5V Biased)Green (525nm) onlyBasic Heart Rate (BPM)
AFE4490 (TI)SPI (22-bit Digital ADC)Red + IRClinical-grade Wearables

Below is the standard wiring matrix for the ubiquitous MAX30102 breakout board. Note the supply range constraints.

Table 2: MAX30102 Breakout Wiring (ESP32 and Arduino Uno)
Breakout PinESP32 PinArduino Uno PinFunction & Notes
VIN / VCC3.3V5VSupply Range: 3.3V to 5.5V (Breakout only)
GNDGNDGNDCommon ground reference
SDAGPIO 21A4I2C Data (Requires 4.7kΩ pull-up if missing on board)
SCLGPIO 22A5I2C Clock (Default address: 0x57)
INTGPIO 15D2Active-low interrupt for FIFO almost-full flag
Callout Tip: I2C Bus Capacitance
The MAX30102 I2C bus is highly sensitive to parasitic capacitance. If you are using long jumper wires (>15cm) or sharing the bus with multiple sensors, the I2C edges will degrade, causing the sensor to drop into an unresponsive state. Keep I2C traces short, and if you experience phantom reads, drop your I2C clock speed from 400kHz down to 100kHz in your microcontroller's wire library initialization.

Output Signal Math: From Raw ADC to Physical Units

The most common mistake makers make with a photoplethysmography sensor is assuming the raw numbers map directly to physical units like volts or percentage. They do not. The MAX30102 outputs raw 18-bit ADC counts ranging from 0 to 262,143. These counts represent the magnitude of current generated by the internal photodiode, which correlates to reflected light intensity.

Extracting Heart Rate (BPM)

Heart rate is derived purely from the time domain of the AC signal. You do not need absolute physical units. 1. Apply a DC removal filter to the raw 18-bit stream to isolate the AC component. 2. Detect the systolic peaks (local maxima) using a threshold or zero-crossing algorithm. 3. Calculate the time delta ($\Delta t$) in seconds between consecutive peaks. 4. Apply the formula: BPM = 60 / \Delta t.

Calculating SpO2 (The Ratio of Ratios)

SpO2 calculation requires both the Red and IR channels. Because tissue thickness and skin pigmentation vary wildly between users, we cannot use absolute light intensity. Instead, we normalize the AC component against the DC component for each wavelength, creating a ratio ($R$). According to Analog Devices' MAX30102 design guides, the math flows as follows:

  1. Find DC: Calculate the moving average (or local minima baseline) of the raw signal for both Red and IR.
  2. Find AC: Calculate the peak-to-trough amplitude of the pulsatile signal for both Red and IR.
  3. Calculate R: $$R = \frac{(AC_{red} / DC_{red})}{(AC_{ir} / DC_{ir})}$$
  4. Apply Empirical Calibration: $$SpO2\% = A - (B \times R)$$

Calibration Reality Check: The constants $A$ and $B$ cannot be derived from first principles. They are empirically derived via clinical hypoxia testing on human subjects. For basic maker projects, a linear approximation of A = 110 and B = 25 is widely used in open-source libraries (like SparkFun's), but this will fail at the extremes (below 85% SpO2). True medical devices use complex, multi-point lookup tables stored in flash memory, calibrated against a blood gas analyzer.

Common Interference Sources and Signal Conditioning

If your raw PPG signal looks like a chaotic mess rather than a clean sine-like wave, you are fighting one of three interference sources. Understanding these is critical before you write a single line of filtering code.

  • Motion Artifacts (The Primary Killer): When the sensor moves relative to the skin, the optical path length changes, and venous blood shifts. This creates massive low-frequency spikes (0.1Hz to 1.0Hz) that completely swallow the 1Hz to 2Hz heartbeat AC signal. Fix: Physical stabilization (a tight silicone finger boot or elastic strap) is mandatory. Firmware-side adaptive filtering (like LMS algorithms) is required for wrist-worn implementations, but finger-clip setups usually survive with a simple bandpass filter.
  • Ambient Light Flicker: Room lighting (especially CFLs and cheap LEDs) flickers at 100Hz or 120Hz (double the 50/60Hz mains frequency). While the MAX30102 has an internal Ambient Light Cancellation (ALC) register that samples the photodiode with the LEDs off and subtracts it, extreme ambient light will saturate the 18-bit ADC. Fix: Shield the sensor from direct room light using an opaque enclosure.
  • Poor Perfusion and Skin Tone: Cold fingers cause vasoconstriction, dropping the AC signal amplitude below the noise floor of the ADC. Darker skin pigmentation increases melanin absorption, reducing the overall DC return signal. Fix: Increase the LED pulse amplitude (current) and pulse width in the sensor's configuration registers to maximize the dynamic range of the ADC without clipping.

Step-by-Step Firmware Implementation (ESP32 / Arduino)

Below is the exact sequence to initialize the MAX30102, configure the FIFO, and read the raw 18-bit data using the industry-standard SparkFun library. This assumes you are using an ESP32 or Arduino Uno with the SparkFun MAX3010x library installed.

Step 1: Hardware Setup & Level Shifting
Wire the sensor per Table 2. Ensure your I2C pull-ups are active. If using an ESP32, the internal pull-ups are often too weak (~40kΩ); add external 4.7kΩ resistors to 3.3V on SDA and SCL.
Step 2: Initialize I2C and Sensor Registers
You must explicitly set the LED current and sample rate. Default settings often clip on lighter skin or fail on darker skin.
#include <Wire.h>
#include "MAX30105.h" // SparkFun library handles both 30102 and 30105

MAX30105 particleSensor;

void setup() {
  Serial.begin(115200);
  Wire.begin();
  Wire.setClock(400000); // 400kHz I2C

  // Initialize sensor with default parameters
  if (!particleSensor.begin(Wire, I2C_SPEED_FAST)) {
    Serial.println("MAX30102 not found. Check wiring and I2C pull-ups.");
    while (1);
  }

  // CRITICAL: Configure for SpO2 mode (Red + IR)
  byte ledMode = 2; // 2 = Red + IR, 3 = Red + IR + Green
  int sampleRate = 400; // 400 SPS (Samples Per Second)
  int pulseWidth = 411; // 411us pulse width (gives 18-bit ADC resolution)
  int ledCurrent = 6; // 6.4mA (Start low, increase if signal is weak)
  
  particleSensor.setup(0x7F, 4, 4, ledMode, sampleRate, pulseWidth, ledCurrent);
  
  // Enable FIFO rollover to prevent buffer locking
  particleSensor.enableFIFORollover();
}
Step 3: Read FIFO and Apply DC Removal
Never read the sensor in a blocking delay() loop. Poll the FIFO buffer and apply a simple IIR (Infinite Impulse Response) high-pass filter to strip the DC baseline in real-time.
float dcRed = 0;
float dcIR = 0;
const float alpha = 0.95; // IIR filter coefficient for DC removal

void loop() {
  // Check if FIFO has data
  while (particleSensor.available()) {
    uint32_t rawRed = particleSensor.getFIFORed();
    uint32_t rawIR = particleSensor.getFIFOIR();
    
    // Update DC baseline (moving average via IIR)
    dcRed = (alpha * dcRed) + ((1.0 - alpha) * rawRed);
    dcIR = (alpha * dcIR) + ((1.0 - alpha) * rawIR);
    
    // Extract AC component (Raw - DC)
    float acRed = rawRed - dcRed;
    float acIR = rawIR - dcIR;
    
    // Output to Serial Plotter for visual debugging
    Serial.print(acRed);
    Serial.print(",");
    Serial.println(acIR);
    
    particleSensor.nextSample();
  }
}

By feeding the acRed and acIR variables into a peak-detection algorithm (available in the SparkFun MAX3010x Hookup Guide), you can reliably extract BPM. Remember that deriving clinical-grade SpO2 requires mapping the $R$ ratio against a calibrated lookup table, but for relative trend monitoring and heart-rate tracking, this raw-to-AC math pipeline provides a robust, noise-resistant foundation for any embedded project.