Community project

Sensor Biofeedback Visual

Photo of Sensor Biofeedback Visual
Generated with AI

Jesús Olivera

Published November 14, 2026

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

Wiring diagram for Sensor Biofeedback Visual
Loading diagram…

Gather all the parts

QtyComponent
1

SSD1306 OLED

0.96-inch 128 x 64 white monochrome OLED display module driven by an SSD1306 controller over I2C (address 0x3C). It runs from 3 V to 5 V and connects through a 4-pin 2.54 mm header (GND, VCC, SCL, SDA); the self-emitting pixels need no backlight.

1

MAX30102

High-sensitivity pulse oximeter and heart-rate sensor by Analog Devices (Maxim). Measures SpO2 and heart rate via PPG (photoplethysmography) using integrated red and IR LEDs with photodetector. Communicates over I2C at fixed 7-bit address 0x57. The IC uses a 1.8V core rail and separate LED supply; typical maker breakout modules regulate from a 3.3V input and provide suitable I2C pull-ups. Place sensor directly against skin for accurate PPG readings.

1

DS18B20

Digital temperature sensor using OneWire protocol

1

Resistor

4.7 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

Passive buzzer module with transistor (3.3 V)

Módulo pasivo de tres pines con transistor integrado, alimentación de 3.3 a 5 V.

1

Push Button

Momentary push button switch

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"

FunctionoledESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

2. Connections between "optical" and "ESP32"

FunctionopticalESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

3. Connections between "thermal" and "ESP32"

FunctionthermalESP32
powerVCC3V3
groundGNDGND
dataDATAGPIO 4

4. Connections between "pullup" and "ESP32"

FunctionpullupESP32
digitalP1 → DS18B20 DATAEXT
powerP23V3

5. Connections between "buzzer" and "ESP32"

FunctionbuzzerESP32
powerVCC3V3
groundGNDGND
pwmIOGPIO 18

6. Connections between "button" and "ESP32"

FunctionbuttonESP32
digitalSIGNALGPIO 19
groundGNDGND

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.

Open in Schematik