This biofeedback visual project transforms the ESP32 into a wearable health monitor that displays real-time heart rate, blood oxygen levels, and body temperature on a compact OLED screen. The MAX30102 optical sensor reads pulse and SpO2 data from a fingertip, the DS18B20 measures core temperature, and audio feedback from the buzzer module reinforces biofeedback cues. Multiple display modes let users switch between normal readouts, guided biofeedback sessions, and pre-EVA (extravehicular activity) monitoring.
This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions to build the sensor stack on breadboard or PCB. The included firmware handles sensor initialization, heart rate variability calculations, and mode switching via push button. Makers will learn how to integrate optical and thermal sensors with the ESP32, render live data on a 128x64 display, and implement multi-mode logic for different biofeedback workflows.
Wiring diagram

Gather all the parts
Assemble it in 5 steps
1. Preparar sin alimentación
Desconecta USB antes de montar. Alimenta todos los módulos desde 3V3 y conecta sus GND a GND del ESP32.
- No conectar estos módulos a 5 V en este montaje.
2. Conectar pantalla y sensor óptico
En ambos módulos conecta SDA a GPIO21 (datos) y SCL a GPIO22 (reloj). OLED VCC y MAX30102 VIN o VCC van a 3V3; ambos GND a GND. MAX30102 INT queda libre.
- Utiliza un módulo MAX30102 con regulador y resistencias I2C integradas; no el chip desnudo.
- Comprueba las etiquetas de alimentación; el orden de pines puede variar.
3. Conectar temperatura
Conecta VDD o VCC del DS18B20 a 3V3, GND a GND y DQ o DATA a GPIO4. Coloca la resistencia de 4.7 kΩ entre DQ y 3V3.
- Identifica las conexiones por el esquema del sensor; no supongas los colores del cable. La temperatura es la del sensor en contacto
- no la temperatura corporal interna.
4. Conectar sonido y botón
Usa un buzzer pasivo de tres pines con transistor integrado compatible con 3.3 V. Conecta VCC a 3V3, GND a GND e I/O a GPIO18. Conecta dos contactos del botón que se unen al pulsarlo entre GPIO19 y GND.
- En un pulsador de cuatro patas dos patas pueden estar unidas permanentemente; usa contactos que solo se unan al pulsar.
5. Revisar el montaje
Revisa que no haya cortocircuitos entre 3V3 y GND. Coloca el dedo suavemente sobre el MAX30102, sin apretar y sin moverlo. El botón cambia entre telemetría, respiración de 4 y 6 segundos y temporizador de 15 minutos.
- Las cifras ópticas son experimentales y no calibradas. No permiten diagnosticar ni valorar seguridad o salud. Respira con comodidad y detén el ejercicio si te mareas.
Review all connections
1. Connections between "oled" and "ESP32"
2. Connections between "optical" and "ESP32"
3. Connections between "thermal" and "ESP32"
4. Connections between "pullup" and "ESP32"
5. Connections between "buzzer" and "ESP32"
6. Connections between "button" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <math.h>
#include <string.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "MAX30105.h"
#include "heartRate.h"
#include <OneWire.h>
#include <DallasTemperature.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define ONE_WIRE_BUS 4 // GPIO 4 para el sensor térmico DS18B20
#define BUZZER_PIN 18 // GPIO 18 para el buzzer pasivo
#define MODE_BTN 19 // GPIO 19 para el botón selector de modos
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
MAX30105 particleSensor;
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature tempSensor(&oneWire);
float bpm = 0;
int spo2 = -1;
float tempC = NAN;
float hrvRMSSD = NAN;
unsigned long lastBeat = 0;
bool opticalReady = false;
bool oledReady = false;
uint8_t previousFrame[1024];
void ejecutarModoNormal(bool, unsigned long);
void ejecutarModoBiofeedback(unsigned long);
void ejecutarModoPreEVA(bool, unsigned long);
const byte RATE_SIZE = 5;
int ibiHistory[RATE_SIZE];
byte ibiIndex = 0;
byte validBeats = 0;
uint32_t redMin = 999999, redMax = 0;
uint32_t irMin = 999999, irMax = 0;
unsigned long lastTempRequest = 0;
unsigned long lastDisplayUpdate = 0;
unsigned long lastButtonPress = 0;
unsigned long modeStartTime = 0;
bool tempRequested = false;
int currentMode = 0;
void setup() {
Serial.begin(115200);
pinMode(BUZZER_PIN, OUTPUT);
pinMode(MODE_BTN, INPUT_PULLUP);
// Iniciar bus I2C en pines estándar del ESP32: GPIO 21 (SDA) y GPIO 22 (SCL)
Wire.begin(21, 22);
Wire.beginTransmission(0x3C);
bool screenFound = Wire.endTransmission() == 0;
bool screenAllocated = display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
if (!screenFound || !screenAllocated) {
Serial.println("Error: No se detecta OLED SSD1306");
if (!screenAllocated) { while (true) { delay(1000); } }
}
oledReady = screenFound;
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(10, 20);
display.println("SENSOR DEMO UNIT");
display.setCursor(10, 35);
display.println("Iniciando modulos...");
display.display();
if (!particleSensor.begin(Wire, I2C_SPEED_FAST)) {
Serial.println("Advertencia: MAX30102 no detectado en simulacion");
} else {
opticalReady = true;
particleSensor.setup(0x1F, 4, 2, 400, 411, 4096);
particleSensor.setPulseAmplitudeRed(0x1F);
particleSensor.setPulseAmplitudeIR(0x1F);
}
tempSensor.begin();
tempSensor.setResolution(10);
tempSensor.setWaitForConversion(false);
tempSensor.requestTemperatures();
tempRequested = true;
lastTempRequest = millis();
tone(BUZZER_PIN, 1000, 150);
delay(1000);
}
void loop() {
unsigned long now = millis();
static bool rawLast = HIGH, stable = HIGH;
bool raw = digitalRead(MODE_BTN);
if (raw != rawLast) { rawLast = raw; lastButtonPress = now; }
if (raw != stable && now - lastButtonPress >= 40) {
stable = raw;
if (stable == LOW) {
currentMode = (currentMode + 1) % 3;
modeStartTime = now;
noTone(BUZZER_PIN);
tone(BUZZER_PIN, 2400, 80);
}
}
if (tempRequested && (now - lastTempRequest >= 250)) {
float t = tempSensor.getTempCByIndex(0);
tempC = (t != DEVICE_DISCONNECTED_C && t >= -55 && t <= 125) ? t : NAN;
tempRequested = false;
}
if (now - lastTempRequest >= 1000) {
tempSensor.requestTemperatures();
tempRequested = true;
lastTempRequest = now;
}
long irValue = opticalReady ? particleSensor.getIR() : 0;
long redValue = opticalReady ? particleSensor.getRed() : 0;
bool fingerPresent = (irValue > 50000);
if (lastBeat && now - lastBeat > 3000) {
bpm = 0; validBeats = 0; ibiIndex = 0; lastBeat = 0;
hrvRMSSD = NAN; spo2 = -1;
}
if (fingerPresent) {
if (irValue < irMin) irMin = irValue;
if (irValue > irMax) irMax = irValue;
if (redValue < redMin) redMin = redValue;
if (redValue > redMax) redMax = redValue;
if (checkForBeat(irValue)) {
long ibi = lastBeat ? now - lastBeat : 0;
lastBeat = now;
if (ibi > 300 && ibi < 1700) {
float instantBPM = 60000.0 / ibi;
bpm = (bpm == 0) ? instantBPM : (bpm * 0.7 + instantBPM * 0.3);
ibiHistory[ibiIndex] = ibi;
ibiIndex = (ibiIndex + 1) % RATE_SIZE;
if (validBeats < RATE_SIZE) validBeats++;
if (validBeats >= 3) {
float sumSqDiff = 0;
for (int i = 1; i < validBeats; i++) {
int oldest = (validBeats == RATE_SIZE) ? ibiIndex : 0;
int diff = ibiHistory[(oldest+i)%RATE_SIZE] - ibiHistory[(oldest+i-1)%RATE_SIZE];
sumSqDiff += (float)(diff * diff);
}
hrvRMSSD = sqrt(sumSqDiff / (validBeats - 1));
}
if (irMax > irMin && redMax > redMin) {
float redAC = redMax - redMin;
float redDC = (redMax + redMin) / 2.0;
float irAC = irMax - irMin;
float irDC = (irMax + irMin) / 2.0;
float R = (redAC / redDC) / (irAC / irDC);
int calcSpo2 = 110 - (25 * R);
spo2 = constrain(calcSpo2, 85, 100);
}
redMin = 999999; redMax = 0;
irMin = 999999; irMax = 0;
}
}
} else {
bpm = 0;
validBeats = 0;
ibiIndex = 0; lastBeat = 0; spo2 = -1; hrvRMSSD = NAN;
redMin = irMin = 999999; redMax = irMax = 0;
}
if (now - lastDisplayUpdate >= 200) {
lastDisplayUpdate = now;
display.clearDisplay();
if (currentMode == 0) {
ejecutarModoNormal(fingerPresent, now);
} else if (currentMode == 1) {
ejecutarModoBiofeedback(now);
} else if (currentMode == 2) {
ejecutarModoPreEVA(fingerPresent, now);
}
if (oledReady && memcmp(previousFrame, display.getBuffer(), 1024) != 0) {
display.display();
memcpy(previousFrame, display.getBuffer(), 1024);
}
Serial.printf("modo=%d pulso=%.1f spo2_exp=%d temp=%.1f rmssd=%.1f\n", currentMode+1,bpm,spo2,tempC,hrvRMSSD);
}
}
void ejecutarModoNormal(bool fingerPresent, unsigned long now) {
display.setTextSize(1);
display.setCursor(0, 0);
display.println("MODO 1: TELEMETRIA");
display.drawLine(0, 10, 128, 10, SSD1306_WHITE);
if (!fingerPresent) {
display.setCursor(10, 28);
display.println("Coloque el dedo en");
display.setCursor(10, 40);
display.println("el sensor optico...");
noTone(BUZZER_PIN);
return;
}
display.setCursor(0, 15);
display.print("Pulso: "); display.print((int)bpm); display.println(" BPM");
display.setCursor(0, 26);
display.print("O2 exp: "); if (spo2 >= 0) display.print(spo2); else display.print("--"); display.println(" %");
display.setCursor(0, 37);
display.print("Temp : "); if (!isnan(tempC)) display.print(tempC, 1); else display.print("--"); display.println(" C");
display.setCursor(0, 48);
display.print("HRV : "); if (!isnan(hrvRMSSD)) display.print((int)hrvRMSSD); else display.print("--"); display.println(" ms");
display.setCursor(0, 56);
display.print("DEMO NO CLINICA");
}
void ejecutarModoBiofeedback(unsigned long now) {
display.setTextSize(1);
display.setCursor(0, 0);
display.println("MODO 2: RESPIRACION");
display.drawLine(0, 10, 128, 10, SSD1306_WHITE);
unsigned long cyclePos = (now - modeStartTime) % 10000;
int barWidth = 0;
if (cyclePos < 4000) {
display.setCursor(25, 18);
display.println("INHALA SUAVE...");
barWidth = map(cyclePos, 0, 4000, 10, 118);
if (cyclePos < 300) tone(BUZZER_PIN, 2200);
else noTone(BUZZER_PIN);
} else {
display.setCursor(25, 18);
display.println("EXHALA LENTO...");
barWidth = map(cyclePos, 4000, 10000, 118, 10);
if (cyclePos < 4300) tone(BUZZER_PIN, 2000);
else noTone(BUZZER_PIN);
}
display.drawRect(5, 32, 118, 16, SSD1306_WHITE);
display.fillRect(5, 32, barWidth, 16, SSD1306_WHITE);
display.setCursor(10, 54);
display.print("RMSSD exp: "); if (!isnan(hrvRMSSD)) display.print((int)hrvRMSSD); else display.print("--"); display.print(" ms");
}
void ejecutarModoPreEVA(bool fingerPresent, unsigned long now) {
display.setTextSize(1);
display.setCursor(0, 0);
display.println("MODO 3: TEMPORIZADOR");
display.drawLine(0, 10, 128, 10, SSD1306_WHITE);
unsigned long elapsedSec = (now - modeStartTime) / 1000;
int remainingSec = max(0, 900 - (int)elapsedSec);
int mins = remainingSec / 60;
int secs = remainingSec % 60;
display.setCursor(0, 16);
display.print("Tiempo: ");
if (mins < 10) display.print("0");
display.print(mins); display.print(":");
if (secs < 10) display.print("0");
display.println(secs);
display.setCursor(0, 30);
display.print("Pulso: ");
if (fingerPresent) {
display.print((int)bpm); display.println(" BPM");
} else {
display.println("-- BPM");
}
display.setCursor(0, 48);
display.println(remainingSec == 0 ? "Temporizador completo" : "DEMO NO CLINICA");
static bool finished = false;
if (remainingSec == 0 && !finished) tone(BUZZER_PIN, 2400, 300);
finished = remainingSec == 0;
}Download project files
Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




