Build a Multi-Sensor Health Analyzer With ESP32 (Temp + SpO2 + Heart Rate)

by sxmnath in Circuits > Electronics

942 Views, 6 Favorites, 0 Comments

Build a Multi-Sensor Health Analyzer With ESP32 (Temp + SpO2 + Heart Rate)

WhatsApp Image 2026-03-30 at 9.30.51 PM.jpeg
hardware.jpg
dashboard.png

What if a few sensors could do more than just show numbers — what if they could interpret your health in real time?

In this project, you’ll build a smart health monitoring system using ESP32 that measures temperature, heart rate, and blood oxygen — and combines them to detect conditions like stress, fever risk, and respiratory issues.

Unlike typical sensor projects, this system doesn’t just display data — it analyzes it, sends it to the cloud, and shows meaningful insights on a live dashboard.


What makes this different from a basic Arduino serial monitor demo:

  1. Uses multiple sensors together (not isolated readings)
  2. Converts raw data into health insights
  3. Fully connected system: ESP32 → Cloud → Dashboard
  4. Supports multiple devices
  5. No WiFi hardcoding (plug-and-play setup)

This Can Be Used For:

  1. Remote patient monitoring
  2. Elderly care
  3. Fitness tracking
  4. Early illness detection


Supplies

Hardware

  1. ESP32 development board (38-pin, any variant)
  2. TMP117 High-Accuracy Temperature Sensor — SparkFun or Adafruit I2C breakout
  3. MAX30102 Pulse Oximeter and Heart-Rate Sensor module
  4. Breadboard + jumper wires
  5. Micro USB cable
  6. USB power bank or 5V adapter for standalone use


Arduino Libraries (install via Library Manager)

  1. SparkFun_MAX3010x_Sensor_Library
  2. Adafruit TMP117
  3. WiFiManager by tzapu


Software & Services (all free)

  1. Arduino IDE 2.x with ESP32 board support installed
  2. Node.js v18+ — for running the backend locally
  3. MongoDB Atlas — free M0 tier cloud database
  4. Render.com — free tier for hosting the backend (serves the dashboard too)
  5. GitHub account — to deploy to Render

How It All Fits Together

Screenshot 2026-03-30 100755.png

Fig. Architecture block diagram

Before touching any hardware, here is the full system architecture. Everything flows in one direction: sensors → ESP32 → cloud backend → dashboard.

┌─────────────────────────────────────────────────────────────┐
│ ESP32 Node │
│ TMP117 (I2C) ──┐ │
│ ├──► firmware ──► HTTP POST /data ──► WiFi │
│ MAX30102 (I2C)─┘ every 5s JSON payload │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Node.js Backend (Render.com) │
│ POST /data → saves to MongoDB Atlas │
│ GET /data/recent → time-windowed query by deviceId │
│ GET /api/dashboard → sensor fusion + risk analysis │
│ GET / → serves index.html (the dashboard) │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
MongoDB Atlas Browser Dashboard
(SensorData docs) (polls /api/dashboard
every 3 seconds)


Key design decisions: both sensors share the same I2C bus (SDA GPIO21, SCL GPIO22). The backend serves the frontend — no separate hosting needed. The sensor fusion engine runs server-side so the dashboard stays simple.

How This Project Uses Sensors

  1. TMP117 → measures precise body temperature
  2. MAX30102 → measures heart rate and SpO₂ using light
  3. ESP32 reads both sensors via I2C
  4. Data is combined to detect:
  5. Stress
  6. Fever risk
  7. Respiratory issues

Wiring the Sensors

circuit_diagram.png
FU0CW3FMN97SLSU.jpg

Fig. Wiring diagram or Fritzing schematic Fig. Breadboard wiring close-up

Both the TMP117 and MAX30102 run on 3.3V and use I2C. They share the same two data lines on the ESP32 — just 4 wires total for both sensors.

Pinout Table

ESP32 Pin → Sensor Pin (both TMP117 and MAX30102)
─────────────────────────────────────────────────────
3.3V → VCC
GND → GND
GPIO 21 → SDA (shared I2C bus)
GPIO 22 → SCL (shared I2C bus)


Default I2C addresses: TMP117 = 0x48, MAX30102 = 0x57. No conflicts. SparkFun and Adafruit breakout boards have onboard pull-up resistors — no external resistors needed.

Install Libraries

Screenshot 2026-03-30 211310.png
Screenshot 2026-03-30 211437.png
Screenshot 2026-03-30 211455.png

Install Libraries in Arduino IDE

Go to Sketch → Include Library → Manage Libraries and search for:

  1. Adafruit TMP117
  2. SparkFun MAX3010x Pulse and Proximity Sensor Library by SparkFun Electronics
  3. WiFiManager by tzapu

Install these and you'll be all set to upload the firmware on the ESP32.

Flash the Firmware

serial_monitor.png

Fig. Arduino IDE Serial Monitor showing live readings

Firmware Code

Create a new sketch, paste the code below. Only one thing to change: update serverURL to your own Render backend URL after deployment.

#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include "MAX30105.h"
#include "spo2_algorithm.h"
#include <Adafruit_TMP117.h>
#include <WiFiManager.h>

String serverURL = "https://health-monitor-server-2pbh.onrender.com/data";

/* -------------------- OBJECTS -------------------- */
MAX30105 particleSensor;
Adafruit_TMP117 tmp117;

/* -------------------- MAX30102 VARIABLES -------------------- */
uint32_t irBuffer[100];
uint32_t redBuffer[100];
int32_t bufferLength = 100;

int32_t spo2;
int8_t validSPO2;
int32_t heartRate;
int8_t validHeartRate;

/* -------------------- TMP117 -------------------- */
float temperatureC = 0;

/* -------------------- TIMING -------------------- */
unsigned long lastSendTime = 0;
const unsigned long sendInterval = 5000;

/* -------------------- SETUP -------------------- */
void setup() {
Serial.begin(115200);
delay(1000);

/* ---------- WIFI ---------- */

WiFiManager wm;

bool res = wm.autoConnect("HealthMonitor-Setup");

if(!res) {
Serial.println("WiFi Failed");
ESP.restart();
}

Serial.println("WiFi Connected!");
Serial.println(WiFi.localIP());

/* ---------- I2C ---------- */
Wire.begin(21, 22);

/* ---------- TMP117 ---------- */
if (!tmp117.begin()) {
Serial.println("TMP117 not found");
while (1);
}
Serial.println("TMP117 initialized");

/* ---------- MAX30102 ---------- */
if (!particleSensor.begin(Wire, I2C_SPEED_FAST)) {
Serial.println(" MAX30102 not found");
while (1);
}

particleSensor.setup(
60, // LED brightness
4, // Sample averaging
2, // Red + IR
100, // Sample rate
411, // Pulse width
4096 // ADC range
);

Serial.println(" MAX30102 initialized");
Serial.println(" Place finger on sensor");
}

/* -------------------- LOOP -------------------- */
void loop() {

/* ---------- COLLECT MAX30102 SAMPLES ---------- */
for (byte i = 0; i < bufferLength; i++) {
while (!particleSensor.available()) {
particleSensor.check();
}

redBuffer[i] = particleSensor.getRed();
irBuffer[i] = particleSensor.getIR();
particleSensor.nextSample();
}

/* ---------- CALCULATE HR & SPO2 ---------- */
maxim_heart_rate_and_oxygen_saturation(
irBuffer, bufferLength,
redBuffer,
&spo2, &validSPO2,
&heartRate, &validHeartRate
);

/* ---------- READ TMP117 ---------- */
sensors_event_t tempEvent;
tmp117.getEvent(&tempEvent);
temperatureC = tempEvent.temperature;

/* ---------- VALIDATED VALUES ---------- */
int finalHR = validHeartRate ? heartRate : -1;
int finalSpO2 = validSPO2 ? spo2 : -1;

/* ---------- SERIAL DEBUG ---------- */
Serial.print("Temp: ");
Serial.print(temperatureC, 2);
Serial.print(" °C | HR: ");
Serial.print(finalHR);
Serial.print(" | SpO2: ");
Serial.println(finalSpO2);

/* ---------- SEND TO DB EVERY 5s ---------- */
if (millis() - lastSendTime >= sendInterval && WiFi.status() == WL_CONNECTED) {
lastSendTime = millis();

HTTPClient http;
http.begin(serverURL);
http.addHeader("Content-Type", "application/json");

String jsonData = "{";
jsonData += "\"temperature\":" + String(temperatureC, 2) + ",";
jsonData += "\"heartRate\":" + String(finalHR) + ",";
jsonData += "\"spo2\":" + String(finalSpO2) + ",";
jsonData += "\"deviceId\":\"ESP32_01\"";
jsonData += "}";

int response = http.POST(jsonData);
Serial.print(" HTTP Response: ");
Serial.println(response);

http.end();
}
}

Downloads

Connect ESP32 to WiFi (First Boot Setup)

Screenshot 2026-03-30 183301.png
Screenshot 2026-03-30 183330.png

Fig. WiFi Manager Page

This step connects your ESP32 to your home WiFi using WiFiManager — no coding required.

Steps

1.Power ON your ESP32

2.On your phone, open WiFi settings

3.Connect to:

HealthMonitor-Setup

4.A browser page will open automatically

(If not, go to 192.168.4.1)

5.Select your home WiFi network

6.Enter your password and click Save

-> if you cant see the "HealthMonitor-Setup" network, restart the ESP32.

Backend — Node.js + MongoDB Atlas

Screenshot 2026-03-30 100755.png

Fig. Render dashboard showing backend deployed and live

MongoDB Atlas Setup

1. Create a free account at mongodb.com/atlas and create an M0 (free) cluster.

2. Add a database user and set network access to 0.0.0.0/0.

3. Copy your connection string — it looks like:

mongodb+srv://username:password@cluster0.xxxxx.mongodb.net/healthmonitor

File structure


esp32-backend/
server.js ← main Express app
models/
SensorData.js ← Mongoose schema
routes/
dashboard.js ← /api/dashboard route
fusion/
healthFusion.js ← sensor fusion engine
.env ← MONGO_URI (never commit this)
package.json


models/SensorData.js


const mongoose = require("mongoose");

const SensorSchema = new mongoose.Schema({
temperature: Number,
heartRate: Number,
spo2: Number,
deviceId: String,
time: { type: Date, default: Date.now }
});

module.exports = mongoose.model("SensorData", SensorSchema);


fusion/healthFusion.js — the sensor fusion engine

This is the most interesting backend file. It takes all three vitals together and applies rule-based logic to flag health conditions:

function analyzeHealth(hr, spo2, temp) {
let status = [];
let riskScore = 0;

if (hr > 100 && spo2 >= 95 && temp < 37.5) {
status.push("Stress detected"); riskScore += 2;
}
if (temp >= 37.8 && hr > 95) {
status.push("Fever risk"); riskScore += 3;
}
if (spo2 < 92 && hr <= 100) {
status.push("Respiratory concern"); riskScore += 4;
}
if (status.length === 0) status.push("Normal");

return { indicators: status, riskScore };
}
module.exports = analyzeHealth;

Risk score thresholds used by the dashboard: 0-1 = Normal, 2-3 = Mild Risk, 4-6 = Moderate Risk, 7+ = High Risk.


routes/dashboard.js

const express = require("express");
const router = express.Router();
const SensorData = require("../models/SensorData");
const analyzeHealth = require("../fusion/healthFusion");

router.get("/dashboard", async (req, res) => {
const latest = await SensorData.findOne().sort({ time: -1 });
if (!latest) return res.json({ message: "No data yet" });

const fusion = analyzeHealth(
latest.heartRate, latest.spo2, latest.temperature
);

res.json({
deviceId: latest.deviceId,
vitals: {
heartRate: latest.heartRate,
spo2: latest.spo2,
temperature: latest.temperature
},
fusion,
time: latest.time
});
});

module.exports = router;


server.js

require("dotenv").config();
const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");
const path = require("path");

const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, "../esp32-frontend")));

mongoose.connect(process.env.MONGO_URI)
.then(() => console.log("MongoDB Connected"))
.catch(err => console.log(err));

const SensorData = require("./models/SensorData");

/* Receive data from ESP32 */
app.post("/data", async (req, res) => {
try {
await SensorData.create(req.body);
res.status(200).send("Data Stored");
} catch (err) { res.status(500).send("Error"); }
});

/* Time-windowed query: GET /data/recent?deviceId=ESP32_01&seconds=30 */
app.get("/data/recent", async (req, res) => {
const { deviceId, seconds } = req.query;
if (!deviceId || !seconds)
return res.status(400).json({ error: "deviceId and seconds required" });

const windowStart = new Date(Date.now() - parseInt(seconds) * 1000);
const data = await SensorData.find({
deviceId, timestamp: { $gte: windowStart }
}).sort({ timestamp: 1 }).lean();

res.json({ deviceId, windowSeconds: seconds, count: data.length, data });
});

app.use("/api", require("./routes/dashboard"));

app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "../esp32-frontend/index.html"));
});

app.listen(process.env.PORT || 3000, () =>
console.log("Server running"));


package.json dependencies

"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"mongoose": "^9.0.1"
}


Deploy to Render

1. Push the full repo to GitHub.

2. On Render.com create a new Web Service, connect your repo.

3. Set Root Directory to esp32-backend, Build Command to npm install, Start Command to node server.js.

4. Add environment variable: MONGO_URI = your Atlas connection string.

5. Deploy. Render gives you a public URL — paste it into the serverURL in your firmware.

The Dashboard

FWD87NIMN97SLSZ.png
9a8043d0-513b-43f2-9928-8905e29950bc.jpg

Fig. Full Dashboard and Its Mobile View

The dashboard is pure HTML + CSS + JS — no framework, no build step. It is served directly by the Express backend via express.static, so there is nothing extra to deploy.


How it works

app.js polls GET /api/dashboard every 3 seconds. The response includes the latest vitals and the fusion result. The dashboard renders vitals as large numbers with animated progress bars, and the fusion indicators as insight cards with color-coded risk status.


app.js -core polling logic

const API = "/api/dashboard";

function healthLabel(score) {
if (score <= 1) return "Normal";
if (score <= 3) return "Mild Risk";
if (score <= 6) return "Moderate Risk";
return "High Risk";
}

async function loadDashboard() {
const res = await fetch(API);
const data = await res.json();

const label = healthLabel(data.fusion.riskScore);
document.getElementById("status-label").innerText = label;

document.getElementById("hr").innerText = data.vitals.heartRate;
document.getElementById("spo2").innerText = data.vitals.spo2;
document.getElementById("temp").innerText = data.vitals.temperature;

// Render fusion insight cards
document.getElementById("fusion").innerHTML = "";
data.fusion.indicators.forEach(i => {
const div = document.createElement("div");
div.innerText = `${i} — ${explanations[i]}`;
document.getElementById("fusion").appendChild(div);
});

document.getElementById("updated").innerText =
`Last updated: ${new Date(data.time).toLocaleTimeString()}`;
}

setInterval(loadDashboard, 3000);
loadDashboard();

The dashboard also supports multiple devices via a deviceNameMap object — ESP32_01 shows as Patient 1, ESP32_02 as Patient 2, and so on.

Testing & Results

serial_monitor.png
WhatsApp Image 2026-03-30 at 9.28.14 PM.jpeg

Fig. Serial Monitor showing Data and sending it with confirmation 200 And Final Working Model

With firmware flashed and backend deployed:

  1. On first boot connect your phone to the HealthMonitor-Setup WiFi hotspot and enter your credentials in the portal
  2. Open the Serial Monitor at 115200 baud — you should see WiFi connected, both sensors initialised, and readings printing every loop
  3. Place your fingertip firmly on the MAX30102 sensor (the red/IR emitter side) and hold still
  4. If HR or SpO2 shows -1, the finger is not fully detected — keep still for 5-10 seconds until the algorithm validates the signal
  5. Open your Render URL in any browser to see the live dashboard
  6. The dashboard updates every 3 seconds, the ESP32 posts every 5 seconds


Expected readings at rest:

  1. Body temperature: 36.0°C – 37.5°C (fingertip reads slightly lower than core temp)
  2. Heart rate: 60–100 BPM
  3. SpO2: 95–100% for healthy individuals
  4. Fusion status: Normal (risk score 0-1)

How the Sensors Work

TMP117 — Why precision matters

The TMP117 is not a hobbyist temp sensor. It uses a silicon bandgap reference and a 16-bit ADC to achieve ±0.1°C accuracy with NIST-traceable calibration — the same spec used in medical devices. Compare that to DHT22 (±0.5°C) or LM35 (requires external calibration). For body temperature where 0.5°C is the difference between normal and low-grade fever, this accuracy is not a luxury. The Adafruit library wraps it in the Adafruit unified sensor abstraction (sensors_event_t), making it drop-in compatible.


MAX30102 — Photoplethysmography (PPG)

The MAX30102 shines red (660nm) and infrared (880nm) LEDs through your fingertip and measures reflected light. Oxygenated and deoxygenated haemoglobin absorb these wavelengths at different ratios. The pulsatile component of the signal gives heart rate (peaks = beats), and the red-to-IR absorption ratio gives SpO2. Maxim's spo2_algorithm.h handles all the DSP — it needs 100 samples to calculate a result, which is why the firmware fills a buffer of 100 before running the calculation. The validHeartRate and validSPO2 flags tell you whether the finger contact was good enough to trust the result.


Sensor fusion

Most health monitors just display raw numbers. healthFusion.js cross-references all three vitals simultaneously. For example: high HR + normal SpO2 + normal temp = likely stress, not fever. Low SpO2 + normal HR = respiratory concern, not exertion. This multi-variable logic is what makes the AI Health Insights section meaningful rather than just repeating the numbers.


Possible Improvements

  1. Add an OLED display (SSD1306) for standalone readings without needing a phone — the I2C bus already has capacity
  2. Use FreeRTOS tasks to read TMP117 and MAX30102 concurrently instead of sequentially
  3. Add Telegram or email alerts when SpO2 drops below 94% or temperature exceeds 38°C
  4. Expand the fusion rules in healthFusion.js — add dehydration heuristics, recovery tracking
  5. Add a chart view to the dashboard showing historical trends using Chart.js
  6. Switch from polling to WebSockets for true push-based updates
  7. Add a second ESP32 node and test the multi-device deviceId routing
  8. 3D print a wearable enclosure


Source Code & Live Demo

GitHub: github.com/sxmnath/healthMonitor

Live dashboard: health-monitor-server-2pbh.onrender.com


Conclusion

This project covers the full IoT stack from sensor hardware to cloud backend to live dashboard — and adds a layer of intelligence with the sensor fusion engine that most similar projects skip entirely. The TMP117 and MAX30102 are genuinely capable sensors, WiFiManager makes the device actually deployable without recompiling for every new WiFi network, and the multi-device architecture means it scales beyond a single node.


Everything runs for free: MongoDB Atlas free tier, Render.com free tier, and the ESP32 itself costs under ₹500. If you build it, share your readings in the comments — especially if you extend the fusion rules or add new sensors.