Skip to content

support@quartzcomponents.com

Free Shipping Over INR 500

Electronics Projects

How to Build an ESP32 Stress Detector Using a MAX30100 Sensor

by RISHABH JANGID 04 Jun 2026 0 Comments
Combine an ESP32 30 Pin CP2102 Development Board and a MAX30100 Heart Rate Oxygen Pulse Sensor to build a real-time biometric stress detector - no specialized medical hardware required. 

The MAX30100 continuously measures heart rate and Heart Rate Variability (HRV), while the ESP32's built-in capacitive touch pins serve as a Galvanic Skin Response (GSR) sensor, detecting subtle changes in skin conductance caused by stress. Together, these three physiological signals - heart rate, HRV, and skin conductance - are processed and combined into a live Stress Index Score, streamed directly to your PC over USB Serial.

This project is a practical deep-dive into I2C sensor interfacing, capacitive touch sensing, signal smoothing algorithms, and multi-parameter biometric scoring on the ESP32 platform.

Components Required

About the Components

ESP32 Development Board

  • Dual-core 32-bit processor up to 240 MHz
  • Built-in Wi-Fi and Bluetooth connectivity
  • 30 GPIO pins with ADC, DAC, PWM, I2C, SPI, and UART support
  • 520KB SRAM and 4MB Flash memory
  • Breadboard-friendly and easy to program via USB

ESP32 Development Board with Wifi and Bluetooth - 30 PIN

Role in Project:

The ESP32 is the brain of the Biometric Stress Detector. It reads heart rate and SpO₂ data from the MAX30100 over I2C, simultaneously samples skin conductance through its built-in capacitive touch pins, and runs real-time HRV and stress score calculations. Processed results are streamed to your computer via USB Serial every 500 ms.

MAX30100 Pulse Oximeter and Heart Rate Sensor

  • Measures heart rate and SpO₂ using PPG (Photoplethysmography) technology
  • Integrated red (660 nm) and infrared (880 nm) LEDs
  • I2C communication interface for easy microcontroller integration
  • Operating voltage: 3.3V–5V
  • Built-in temperature sensor for calibration
  • Proximity detection for reduced power consumption
  • Compact and suitable for wearable and health-monitoring applicationsm

Role in Project:

The MAX30100 is the primary biometric sensor in this build. It continuously measures heart rate (BPM) and blood oxygen saturation (SpO₂) by shining light through the fingertip and detecting the reflected pulse waveform. This pulse data is also used to calculate Heart Rate Variability (HRV) — a key indicator of physiological stress. The user rests a finger on the sensor during each measurement session, and all data is passed to the ESP32 via I2C.


ESP32 Touch Pins as GSR Sensor

  • Uses ESP32's built-in capacitive touch sensing capability
  • Detects changes in skin conductance without external GSR hardware
  • Higher skin moisture increases conductivity and changes capacitance readings
  • Lower touchRead() values indicate higher skin conductance
  • Requires two touch pins in contact with the user's skin
  • No additional components needed, reducing cost and complexity

Role in Project:

The ESP32's capacitive touch pins double as a simplified Galvanic Skin Response (GSR) sensor — one of the most widely used physiological markers of stress and emotional arousal. When the user places two fingers on the touch electrodes (jumper wires connected to the touch pins), changes in skin moisture alter the capacitance readings in real time. This data is combined with heart rate and HRV values to compute the overall Stress Index Score, giving the detector a multi-signal foundation rather than relying on a single metric.

How It Works ?

Stress triggers involuntary physiological changes in the body — the same responses that polygraph machines have measured for decades. This project captures three of those signals simultaneously and combines them into a single real-time Stress Index Score.

Signal Sensor Stress Indicator Weight in Score
Heart Rate (HR) MAX30100 HR rises above baseline 25%
Heart Rate Variability (HRV) MAX30100 (derived) HRV drops below baseline 35%
Skin Conductance (GSR) ESP32 Touch Pins Touch value drops (more sweat) 40% (level 25% + rate 15%)

All three signals are measured against a personal baseline captured while the subject is at rest. The composite Stress Index Score (0–100) produces one of three verdicts: Relaxed (below 25), Mild Stress (25–55), or High Stress Detected (above 55).

Finger Placement

All three signals are measured against a personal baseline captured while the subject is at rest. The composite Stress Index Score (0–100) produces one of three verdicts: Relaxed (below 25), Mild Stress (25–55), or High Stress Detected (above 55).

  • Index finger — pressed firmly on top of the MAX30100 sensor chip (HR + SpO2)
  • Middle finger — pinching the bare metal tip of the jumper wire on GPIO12 (T5)
  • Ring finger — pinching the bare metal tip of the jumper wire on GPIO13 (T4)

Alternatively, place the index finger on the MAX30100 with your left hand and hold both jumper wire tips between the thumb and index finger of your right hand. Both placements work equally well.

Circuit Diagram

The MAX30100 connects to the ESP32 over I2C using just four wires. The GSR circuit requires no additional components — two jumper wires plugged into GPIO12 and GPIO13 are all that's needed. The calibration button connects GPIO14 to GND, with the internal pull-up resistor enabled in firmware.

MAX30100 Pin ESP32 Pin Description
VIN 3.3V Power Supply
GND GND Common Ground
SDA GPIO21 I2C Data Line
SCL GPIO22 I2C Clock Line
Component ESP32 Pin Description
Jumper wire (Finger 1) GPIO12 (T5) GSR contact 1
Jumper wire (Finger 2) GPIO13 (T4) GSR contact 2
Push Button GPIO14 → GND Calibration trigger

Code Explanation

Libraries and Pin Definitions

The firmware uses Wire.h for I2C communication and MAX30100_PulseOximeter.h from the MAX30100lib library for sensor management. The two capacitive touch pins (for GSR) and the calibration button pin are defined at the top. Several baseline variables are declared to store the user's calm-state reference values, which are captured during the calibration phase.

ESP32 · Includes and Pin Definitions
#include <Wire.h>
#include "MAX30100_PulseOximeter.h"

#define REPORTING_PERIOD_MS 500

#define TOUCH_1   T5    // GPIO12
#define TOUCH_2   T4    // GPIO13
#define BTN_PIN   25    // Calibration button

PulseOximeter pox;

float baseHR  = 0;
float baseHRV = 0;
float baseGSR = 0;

GSR Reading with Smoothing

The readGSR() function captures skin conductance by averaging the two ESP32 capacitive touch pins. Because raw touch readings are highly susceptible to physical noise (like finger micro-movements), an exponential moving average (low-pass filter) is applied. By combining 80% of the previous smoothed value with 20% of the new raw reading, minor jitters are ignored, allowing only genuine sweat-driven conductance changes to pass through to the scoring logic.

ESP32 · GSR Read and Smoothing
float smoothGSR = 0;
float prevGSR   = 0;

float readGSR() {
  float raw = (touchRead(TOUCH_1) + touchRead(TOUCH_2)) / 2.0;
  smoothGSR = smoothGSR * 0.8 + raw * 0.2;  // low-pass filter
  return smoothGSR;
}

HRV Calculation

Heart Rate Variability (HRV) is derived from the heart rate using a mathematical approximation of RMSSD (Root Mean Square of Successive Differences). The RR interval (milliseconds between beats) is computed from the BPM, and the last 10 successive differences are tracked. A stressed state typically triggers the sympathetic nervous system, causing HRV to drop rapidly as heartbeats become unnaturally rigid.

$$RMSSD = \sqrt{\frac{1}{N} \sum_{i=1}^{N} (RR_i - RR_{i-1})^2}$$

ESP32 · HRV Calculation (RMSSD)
float lastRR      = 0;
float rrDiffs[10] = {0};
int   rrIdx       = 0;

float calcHRV(float hr) {
  if (hr < 30) return lastRR ? lastRR : 50.0;
  float rr = 60000.0 / hr;
  if (lastRR > 0) {
    rrDiffs[rrIdx % 10] = abs(rr - lastRR);
    rrIdx++;
  }
  lastRR = rr;
  if (rrIdx < 3) return 50.0;
  
  float sum = 0;
  int n = min(rrIdx, 10);
  for (int i = 0; i < n; i++) sum += rrDiffs[i] * rrDiffs[i];
  return sqrt(sum / n);
}

Stress Scoring

The stressScore() function computes a weighted composite score (0 to 100) based on four distinct biometric sub-signals: HR delta (25%), HRV delta (35%), absolute GSR delta (25%), and GSR rate of change (15%). The rate-of-change component is the most sensitive metric—it catches immediate, sharp conductance spikes that occur before the absolute levels have shifted significantly. Each sub-score is clamped to 0–100 before weighting.

ESP32 · Stress Score (0–100)
int stressScore(float hr, float hrv, float gsr) {
  float dHR     = hr  - baseHR;
  float dHRV    = hrv - baseHRV;
  float dGSR    = baseGSR - gsr;    // lower touchRead = more stressed
  float rateGSR = prevGSR - gsr;    // sudden spike detection

  float s = 0;
  s += constrain(dHR     / 20.0 * 100, 0, 100) * 0.25;  // HR spike
  s += constrain(-dHRV   / 10.0 * 100, 0, 100) * 0.35;  // HRV drop
  s += constrain(dGSR    / 20.0 * 100, 0, 100) * 0.25;  // GSR level
  s += constrain(rateGSR /  4.0 * 100, 0, 100) * 0.15;  // GSR rate
  return (int)s;
}

Baseline Calibration

Because everyone's resting physiology is different, pressing the button triggers a non-blocking 3-second calibration window. It gathers biometric data every 300ms, averages it, and establishes a stable resting baseline. All subsequent stress scores are calculated as deviations from this saved baseline, which eliminates inter-person differences in resting HR, HRV, and skin conductance.

ESP32 · Calibration on Button Press
if (isCalibrating) {
  if (millis() - lastSampleTick >= 300) {
    sumHR  += hr;
    sumHRV += hrv;
    sumGSR += gsr;
    samplesCount++;
    lastSampleTick = millis();
  }

  if (millis() - calibrationStartTime >= 3000) {
    isCalibrating = false;
    baseHR  = sumHR  / (float)samplesCount;
    baseHRV = sumHRV / (float)samplesCount;
    baseGSR = sumGSR / (float)samplesCount;

    Serial.printf("Baseline set — HR: %.1f | HRV: %.1f | GSR: %.1f\n\n",
                  baseHR, baseHRV, baseGSR);
  }
  return; // Skip standard reporting while calibrating
}

Main Loop - Reading and Verdict Output

The main loop calls pox.update() continuously at the top to keep the MAX30100 I2C processing alive without blocking delays. Every 500ms, it evaluates the sensor values, computes the stress score (if a baseline exists), and prints the result. The output format is natively compatible with both the Serial Monitor (readable text) and the Serial Plotter (graphs automatically).

ESP32 · Main Loop
void loop() {
  pox.update();

  float hr   = pox.getHeartRate();
  float spo2 = pox.getSpO2();
  float hrv  = calcHRV(hr);
  float gsr  = readGSR();

  if (millis() - tsLastReport > REPORTING_PERIOD_MS) {
    Serial.print("HR: ");      Serial.print(hr);    Serial.print(" bpm");
    Serial.print(" | SpO2: "); Serial.print(spo2);  Serial.print(" %");
    Serial.print(" | HRV: ");  Serial.print(hrv);
    Serial.print(" | GSR: ");  Serial.print(gsr);

    if (baseHR > 0) {
      prevGSR   = smoothGSR;
      int score = stressScore(hr, hrv, gsr);
      Serial.print(" | Score: "); Serial.print(score);
      Serial.print(" | ");

      if      (score < 25) Serial.println("CALM");
      else if (score < 45) Serial.println("ANXIOUS");
      else                 Serial.println("HIGH STRESS DETECTED");
    } else {
      Serial.println(" | [press button to calibrate]");
    }

    tsLastReport = millis();
  }
}

How to Use

  1. Upload the firmware and open Serial Monitor at 115200 baud.
  2. Place your index finger firmly on the MAX30100 chip. Wait for a stable HR reading.
  3. Pinch the two jumper wire metal tips with your middle and ring finger of the same hand.
  4. Stay calm and press the calibration button. Hold completely still for 3 seconds until the baseline confirmation message appears.
  5. Ask a few neutral control questions first ("What is your name?") to ensure the score stays near zero.
  6. Ask the test questions and watch the Score column and verdict update in real time.

To visualize all signals as live graphs, switch from Serial Monitor to Tools → Serial Plotter in Arduino IDE. HR, HRV, GSR, and Score will each render as a separate trace.

GSR RANGE COMPARISON (DRY VS SWEATY FINGER)

Tuning the Thresholds

Before testing on others, run this quick calibration sketch to understand your ESP32's specific capacitive touch baseline:

ESP32 · GSR Range Check
void loop() {
  Serial.printf("T5: %d   T4: %d\n", touchRead(T5), touchRead(T4));
  delay(200);
}
State Typical touchRead value
Not touching 60 – 80
Dry finger, relaxed 20 – 40
Normal hold, calm 10 – 25
Stressed or sweating 3 – 12

If your personal calm-to-stressed delta is smaller than 15 points, increase GSR sensitivity by lowering the / 20.0 divisor in the stressScore() function. If the score triggers "HIGH STRESS" too easily from normal breathing or motion, raise the divisor to require a stronger physiological reaction.

Result

The ESP32 successfully operates as a three-signal biometric stress detector using only its built-in capacitive touch pins and an I2C MAX30100 sensor. The system establishes a personalized baseline in 3 seconds, outputs a live composite stress score every 500ms, and delivers a real-time, three-level verdict (CALM, ANXIOUS, or HIGH STRESS DETECTED) over USB Serial. It achieves this with zero external circuitry beyond the sensor module, a push button, and two jumper wires.

In the final prototype, the exponential low-pass filter on the GSR inputs successfully eliminates micro-motion noise while preserving genuine conductance spikes. The HRV (RMSSD) drop component provides the most reliable long-duration indicator of stress, while the GSR rate-of-change metric catches immediate sympathetic nervous system responses within 1–2 seconds of a question or stimulus. Working in tandem, these core signals cover the same physiological channels monitored by professional polygraph systems.

Checkout the full tutorial :

Complete Code

The following ESP32 Arduino sketch implements the complete pocket stress detector firmware. It integrates MAX30100 heart rate and SpO2 processing, HRV derivation, capacitive touch GSR sensing with exponential smoothing, and a non-blocking 3-second baseline calibration. Using these inputs, the algorithm calculates a weighted composite stress score and pushes the live data to the Serial Monitor and Serial Plotter every 500ms.

Pocket Lie Detector — Complete ESP32 Code
#include <Wire.h>
#include "MAX30100_PulseOximeter.h"

#define REPORTING_PERIOD_MS 500

// Touch GSR pins
#define TOUCH_1  T5   // GPIO12
#define TOUCH_2  T4   // GPIO13

// Calibration button
#define BTN_PIN  25

PulseOximeter pox;
uint32_t tsLastReport = 0;

// Baselines (set on button press)

float baseHR  = 0;
float baseHRV = 0;
float baseGSR = 0;

// HRV tracking
float lastRR      = 0;
float rrDiffs[10] = {0};
int   rrIdx       = 0;

// GSR smoothing
float smoothGSR = 0;
float prevGSR   = 0;

// Beat callback — also used for HRV
void onBeatDetected() {
  Serial.println("Beat!");
}

// ── GSR read with smoothing ────────────────────────
float readGSR() {
  float raw = (touchRead(TOUCH_1) + touchRead(TOUCH_2)) / 2.0;
  smoothGSR = smoothGSR * 0.8 + raw * 0.2;
  return smoothGSR;
}

// ── HRV from heart rate (RMSSD approximation) ─────
float calcHRV(float hr) {
  if (hr < 30) return lastRR ? lastRR : 50.0;
  float rr = 60000.0 / hr;
  if (lastRR > 0) {
    rrDiffs[rrIdx % 10] = abs(rr - lastRR);
    rrIdx++;
  }
  lastRR = rr;
  if (rrIdx < 3) return 50.0;
  float sum = 0;
  int n = min(rrIdx, 10);
  for (int i = 0; i < n; i++) sum += rrDiffs[i] * rrDiffs[i];
  return sqrt(sum / n);
}

// ── Stress score 0–100 ─────────────────────────────
int stressScore(float hr, float hrv, float gsr) {
  float dHR      = hr - baseHR;
  float dHRV     = hrv - baseHRV;
  float dGSR     = baseGSR - gsr;      // lower = more stressed
  float rateGSR  = prevGSR - gsr;      // sudden spike

  float s = 0;
  s += constrain(dHR     / 20.0 * 100, 0, 100) * 0.25;
  s += constrain(-dHRV   / 10.0 * 100, 0, 100) * 0.35;
  s += constrain(dGSR    / 20.0 * 100, 0, 100) * 0.25;
  s += constrain(rateGSR /  4.0 * 100, 0, 100) * 0.15;
  return (int)s;
}

// ── Setup ──────────────────────────────────────────
void setup() {
  delay(10000);
  Serial.begin(115200);
  pinMode(BTN_PIN, INPUT_PULLUP);

  Serial.println("Initializing MAX30100...");
  if (!pox.begin()) {
    Serial.println("MAX30100 FAILED");
    while (true);
  }
  Serial.println("MAX30100 SUCCESS");

  pox.setIRLedCurrent(MAX30100_LED_CURR_7_6MA);
  pox.setOnBeatDetectedCallback(onBeatDetected);
  Serial.println("\n=== Pocket Stress Detector ===");
  Serial.println("1. Place index finger on MAX30100");
  Serial.println("2. Hold both GSR jumper wires (GPIO12 & GPIO13)");
  Serial.println("3. Stay CALM and press button (GPIO25) to calibrate\n");
}

// Global variables to track non-blocking calibration state
bool isCalibrating = false;
uint32_t calibrationStartTime = 0;
uint32_t lastSampleTick = 0;
float sumHR = 0, sumHRV = 0, sumGSR = 0;
int samplesCount = 0;
void loop() {
  // Always call this at the absolute top of the loop with no barriers
  pox.update();
  float hr   = pox.getHeartRate();
  float spo2 = pox.getSpO2();
  float hrv  = calcHRV(hr);
  float gsr  = readGSR();

  // ── 1. Check if the physical button is pressed ──
  if (digitalRead(BTN_PIN) == LOW && !isCalibrating) {
    delay(50); // Small debounce
    if (digitalRead(BTN_PIN) == LOW) {
      isCalibrating = true;
      calibrationStartTime = millis();
      lastSampleTick = millis();
      sumHR = 0; sumHRV = 0; sumGSR = 0;
      samplesCount = 0;
      Serial.println("Calibrating... Keep your finger steady for 3 seconds.");
    }
  }

  // ── 2. Run the Calibration collection seamlessly ──
  if (isCalibrating) {
    // Collect a sample every 300ms
    if (millis() - lastSampleTick >= 300) {
      sumHR   += hr;
      sumHRV  += hrv;
      sumGSR  += gsr;
      samplesCount++;
      lastSampleTick = millis();
      Serial.print("."); // Progress indicator
    }

    // Check if 3 seconds have passed
    if (millis() - calibrationStartTime >= 3000) {
      isCalibrating = false; // End calibration window
      
      if (samplesCount > 0) {
        baseHR  = sumHR  / (float)samplesCount;
        baseHRV = sumHRV / (float)samplesCount;
        baseGSR = sumGSR / (float)samplesCount;
      }
      
      Serial.println("\n=== BASELINE SAVED ===");
      Serial.printf("Base HR: %.1f | Base HRV: %.1f | Base GSR: %.1f\n\n", baseHR, baseHRV, baseGSR);
    }
    return; // Don't print regular reports while calibrating
  }

  // ── 3. Regular Report Every 500ms ──
  if (millis() - tsLastReport > REPORTING_PERIOD_MS) {
    Serial.print("HR: ");    Serial.print(hr);    Serial.print(" bpm");
    Serial.print(" | SpO2: "); Serial.print(spo2); Serial.print(" %");
    Serial.print(" | HRV: "); Serial.print(hrv);
    Serial.print(" | GSR: "); Serial.print(gsr);

    if (baseHR > 0) {
      prevGSR   = smoothGSR;
      int score = stressScore(hr, hrv, gsr);
      Serial.print(" | Score: "); Serial.print(score);
      Serial.print(" | ");

      if      (score < 25) Serial.println("CALM");
      else if (score < 45) Serial.println("ANXIOUS");
      else                 Serial.println("HIGH STRESS DETECTED");
    } else {
      Serial.println(" | [press button to calibrate]");
    }

    tsLastReport = millis();
  }
}
Prev Post
Next Post

Leave a comment

Please note, comments need to be approved before they are published.

Thanks for subscribing!

This email has been registered!

Shop the look

Choose Options

Edit Option
Back In Stock Notification
is added to your shopping cart.
this is just a warning
Login