PPG on Hardware
Heart rate extraction on ESP32-S3 with MAX30102
The PPG pipeline (DC removal, bandpass filtering, zero-crossing detection, Hampel outlier rejection, exponential smoothing) maps cleanly to a microcontroller. The entire chain runs at 100 Hz with negligible CPU load, leaving the ESP32-S3 free for wireless communication, display updates, and sensor management.
This page consolidates the embedded implementation. For the theory, signal decomposition, and Python prototypes, see the main PPG page.
Unlike the other embedded pages, this one is a single-platform case study: a complete build on the ESP32-S3 with the MAX30102, not a portability comparison (ADR-005 reserves the both-platforms rule for pages where cross-tier feasibility is the lesson). The DSP itself is portable: the biquad embedded page shows the same bandpass on the NUCLEO-F446RE with CMSIS-DSP (arm_biquad_cascade_df2T_f32), and the FIR DC-removal maps to arm_fir_f32. What is genuinely ESP32-specific here is the integration (I2C to the MAX30102, BLE, the OLED), not the filtering.
Hardware
| Component | Role | Interface | Approx. cost |
|---|---|---|---|
| ESP32-S3-DevKitC | Processing, WiFi/BLE | n/a | EUR 8 |
| MAX30102 breakout | PPG sensor (red + IR LEDs, photodetector, 18-bit ADC) | I2C | EUR 3 |
| LSM303C breakout | 3-axis accelerometer (motion detection, future adaptive filtering) | I2C | EUR 5 |
| PCM5102 breakout | Audio DAC for sonification / heartbeat feedback | I2S | EUR 3 |
| SSD1306 OLED (128x64) | Heart rate display | I2C | EUR 3 |
| Breadboard + wires | EUR 3 | ||
| Total | ~EUR 25 |
The MAX30102 samples at 100 Hz with 18-bit resolution. Both red and infrared channels are read; only the IR channel is used for heart rate. The red channel is reserved for future SpO2 estimation.
I2C bus setup
Both the MAX30102 and LSM303C share the same I2C bus. The ESP32-S3 has two I2C controllers; using one for sensors keeps the other available for the OLED display if needed.
#include "driver/i2c.h"
#define I2C_MASTER_NUM I2C_NUM_0
#define I2C_SDA_PIN GPIO_NUM_21
#define I2C_SCL_PIN GPIO_NUM_22
#define I2C_FREQ_HZ 400000
void i2c_init(void) {
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = I2C_SDA_PIN,
.scl_io_num = I2C_SCL_PIN,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master = { .clk_speed = I2C_FREQ_HZ },
};
i2c_param_config(I2C_MASTER_NUM, &conf);
i2c_driver_install(I2C_MASTER_NUM, conf.mode, 0, 0, 0);
}MAX30102 configuration
The MAX30102 is configured for PPG mode (red + IR), 100 Hz sample rate, 18-bit resolution, and moderate LED current:
#define MAX30102_ADDR 0x57
// Key registers
#define REG_MODE_CONFIG 0x09
#define REG_SPO2_CONFIG 0x0A
#define REG_LED1_PA 0x0C // Red LED current
#define REG_LED2_PA 0x0D // IR LED current
// Helper: write a single register via I2C.
// Implement using i2c_master_write_to_device() from driver/i2c.h.
static esp_err_t i2c_write_reg(uint8_t addr, uint8_t reg, uint8_t val) {
uint8_t buf[2] = {reg, val};
return i2c_master_write_to_device(I2C_MASTER_NUM, addr,
buf, sizeof(buf),
pdMS_TO_TICKS(100));
}
void max30102_init(void) {
// Reset
i2c_write_reg(MAX30102_ADDR, REG_MODE_CONFIG, 0x40);
vTaskDelay(pdMS_TO_TICKS(100));
// SpO2 mode (red + IR), 100 Hz sample rate, 18-bit ADC (411 us pulse width)
// Note: sample averaging is set via FIFO_CONFIG register (0x08), not here
i2c_write_reg(MAX30102_ADDR, REG_SPO2_CONFIG, 0x27);
i2c_write_reg(MAX30102_ADDR, REG_MODE_CONFIG, 0x03);
// LED current: ~6 mA (good starting point for finger-tip)
i2c_write_reg(MAX30102_ADDR, REG_LED1_PA, 0x1F);
i2c_write_reg(MAX30102_ADDR, REG_LED2_PA, 0x1F);
}The LED current may need adjustment depending on skin tone and sensor placement. Higher current gives stronger signals but increases power consumption. A perfusion index below 0.5% suggests insufficient LED drive or poor contact.
DSP pipeline
The pipeline is structured as five sequential stages, each implemented as a small C++ class with a process() method. The main loop calls them in order at the sensor’s 100 Hz sample rate.
Stage 1: DC removal and normalisation
A lowpass FIR estimates the DC component. A delay line compensates for the filter’s group delay. The AC/DC ratio normalises the pulse amplitude across different perfusion levels.
// DC removal and perfusion index calculation
// dc_filter: lowpass FIR estimating the slowly varying DC component
// delay_line: compensates for FIR group delay so subtraction is time-aligned
// dc_filter: lowpass FIR estimating DC (see Stage 1 description above)
// delay_line: 50-sample FIFO that delays raw input to align with FIR group delay
float dc = dc_filter.process(sample);
float ac = delay_line.process(sample) - dc;
float ppg = (dc > 100.0f) ? ac / dc : 0.0f; // guard against division by zeroThe delay element is critical: without it, the subtraction would compare the current sample against a time-shifted DC estimate, distorting the AC waveform. For a 101-tap FIR with 0.1 Hz cutoff at 100 Hz sample rate, the group delay is 50 samples (0.5 s).
Stage 2: Bandpass filtering
A biquad cascade isolates the heart rate band (0.5 to 5 Hz, i.e. 30 to 300 BPM). This uses transposed direct form II, which is numerically better behaved for floating-point because zeros are processed first:
// Transposed direct form II biquad, one second-order section
float Biquad::process(float in) {
float out = b[0] * in + w[0];
w[0] = w[1] + b[1] * in - a[1] * out;
w[1] = b[2] * in - a[2] * out;
return out;
}Two sections are cascaded for a 4th-order Butterworth bandpass. The state variables must be initialised to avoid a startup transient that would trigger a false first beat:
void Biquad::init(float dc_value) {
// Pre-fill states to the exact steady state for constant input = dc_value, so a
// DC baseline produces no startup transient. The steady output is
// out = dc_value * H(0), with H(0) = (b0+b1+b2)/(1+a1+a2) for this un-negated-a
// form; the transposed-DF2 states then follow from out and dc_value.
float h0 = (b[0] + b[1] + b[2]) / (1.0f + a[1] + a[2]);
float out = dc_value * h0;
w[1] = b[2] * dc_value - a[2] * out;
w[0] = out - b[0] * dc_value;
}Design the filter in Python with scipy.signal.butter(..., output='sos'), then copy the SOS coefficients into the C++ code. The biquad structure here uses un-negated a[1] and a[2] (note the subtraction in the update equations), matching SciPy’s convention directly. This is a deliberate exception to ADR-005 §6, which mandates negated denominators for the CMSIS-facing code: this is a standalone hand-written class, not a CMSIS call, and copying SciPy SOS rows verbatim is the clearer choice for a case study. The CMSIS-DSP path stores the negated form.
Stage 3: Beat detection
Rising zero crossings in the filtered signal mark heartbeat onsets. The inter-beat interval (IBI) converts to BPM, with a range check rejecting physiologically impossible values:
bool PulseDetector::process(float sample) {
bool beat = false;
if (prev_sample < 0.0f && sample >= 0.0f) {
uint32_t now = millis();
uint32_t ibi = now - last_beat_time;
if (ibi > 500 && ibi < 1500) { // 40-120 BPM
heart_rate = 60000.0f / ibi;
beat = true;
}
last_beat_time = now;
}
prev_sample = sample;
return beat;
}Stage 4: Hampel outlier rejection
A sliding-window median filter rejects heart rate estimates that deviate too far from the local trend. This catches motion artifacts that survive the bandpass filter:
float HampelFilter::process(float hr) {
buffer[idx] = hr;
idx = (idx + 1) % window_size;
// Find median via partial sort
std::copy(buffer, buffer + window_size, sorted);
std::sort(sorted, sorted + window_size);
float median = sorted[window_size / 2];
// Median absolute deviation, scaled to sigma
for (int i = 0; i < window_size; i++)
deviations[i] = std::abs(buffer[i] - median);
std::sort(deviations, deviations + window_size);
float mad = 1.4826f * deviations[window_size / 2];
if (std::abs(hr - median) > n_sigma * mad)
return median;
return hr;
}With window_size = 5 and n_sigma = 2.0, the Hampel filter replaces outliers with the local median while passing clean beats through unchanged. The cost is 2 sorts of 5 elements per beat, negligible at heart rate frequencies.
Stage 5: Exponential smoothing
The final EMA produces a stable, displayable heart rate:
float EMA::process(float hr) {
if (!initialised) {
avg = hr;
initialised = true;
} else {
avg = alpha * avg + (1.0f - alpha) * hr;
}
return avg;
}With alpha = 0.9 the smoothing time constant is about 10 beats to 63% (roughly 28 to reach 95%), since tau = -1/ln(alpha); that trades a slow response for steady readings, so lower alpha if you need faster tracking.
FreeRTOS task structure
The pipeline runs in a dedicated task pinned to core 1, keeping core 0 free for WiFi/BLE and display updates:
void ppg_task(void *param) {
// Initialise sensor and pipeline
max30102_init();
dc_filter.init();
bandpass.init(0.0f); // or first-sample DC value
hampel.init();
ema.init();
while (true) {
// Read sensor (blocks until new sample, ~10 ms at 100 Hz)
uint32_t ir_sample = max30102_read_ir();
float sample = (float)ir_sample;
// Run pipeline
float dc = dc_filter.process(sample);
float ac = delay_line.process(sample) - dc;
float ppg = (dc > 100.0f) ? ac / dc : 0.0f;
float filtered = bandpass.process(ppg);
if (pulse_detector.process(filtered)) {
float hr = pulse_detector.heart_rate;
hr = hampel.process(hr);
hr = ema.process(hr);
// Publish to other tasks via queue or shared variable
xQueueOverwrite(hr_queue, &hr);
}
}
}
void app_main(void) {
i2c_init();
hr_queue = xQueueCreate(1, sizeof(float));
xTaskCreatePinnedToCore(ppg_task, "ppg", 4096, NULL, 5, NULL, 1);
// Core 0: WiFi, BLE, display tasks...
}Respiratory rate
The same pipeline architecture extracts respiratory rate by reusing the components with different parameters:
| Parameter | Heart rate | Respiratory rate |
|---|---|---|
| Bandpass | 0.5 to 5 Hz | 0.1 to 0.5 Hz |
| BPM range | 40 to 120 | 6 to 30 |
A second biquad cascade and zero-crossing detector run in parallel on the same normalised PPG signal:
float resp_filtered = resp_bandpass.process(ppg);
if (resp_detector.process(resp_filtered)) {
float rr = resp_detector.heart_rate; // reusing same class
rr = resp_hampel.process(rr);
rr = resp_ema.process(rr);
xQueueOverwrite(rr_queue, &rr);
}Performance budget
At 100 Hz sample rate on ESP32-S3 (240 MHz), the processing budget per sample is 2,400,000 cycles (10 ms):
| Stage | Operations | Est. cycles | Est. time |
|---|---|---|---|
| DC removal (101-tap FIR) | 101 MACs | ~500 | 2.1 us |
| Delay line (50 samples) | 1 read + 1 write | ~10 | 0.04 us |
| Bandpass (2 biquad sections) | 10 MACs | ~50 | 0.2 us |
| Zero-crossing check | 1 comparison | ~5 | 0.02 us |
| Hampel (per beat, ~1 Hz) | 2 sorts of 5 | ~200 | 0.8 us |
| EMA | 2 MACs | ~10 | 0.04 us |
| Total per sample | ~775 | ~3.2 us | |
| Utilisation | 0.032% |
The pipeline uses less than 0.1% of the available CPU budget. The MAX30102 I2C read (~200 us) is the dominant cost, not the DSP.
Open questions
See the main PPG page for discussion of motion artifact removal via adaptive filtering, SpO2 estimation, and wearable vs. fingertip placement.