DEV Community

DIY Electronics
DIY Electronics

Posted on

How to Build an ESP32 Stress Detector Using a MAX30100 Sensor

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

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.

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.

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.

#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;

Enter fullscreen mode Exit fullscreen mode

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.

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;
}

Enter fullscreen mode Exit fullscreen mode

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}$$

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

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;
}

Enter fullscreen mode Exit fullscreen mode

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.

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
}

Enter fullscreen mode Exit fullscreen mode

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)

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

How to Use

Upload the firmware and open Serial Monitor at 115200 baud.
Place your index finger firmly on the MAX30100 chip. Wait for a stable HR reading.
Pinch the two jumper wire metal tips with your middle and ring finger of the same hand.
Stay calm and press the calibration button. Hold completely still for 3 seconds until the baseline confirmation message appears.
Ask a few neutral control questions first ("What is your name?") to ensure the score stays near zero.
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.

uts 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.

Complete 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();
  }
}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)