PulseSensor_Xiao_ESP32S3

PulseSensor + XIAO ESP32S3 Tutorial

From first blink to WiFi dashboard — a progressive guide to measuring heartbeats.

Quick Navigation

How These Examples Work

Each example builds on the last, adding new hardware and concepts. When you see paired examples (2a/2b, 3a/3b, etc.), the "a" version shows you the raw code — threshold detection, timing math, signal mapping — so you understand what's happening. The "b" version uses the PulseSensor Playground library, which handles interrupts and averaging for you. Learn with "a", ship with "b".


0. Getting Started (click to expand if you need setup help)

Before measuring heartbeats, let's make sure your computer can talk to your XIAO ESP32S3. This section takes about 10 minutes and saves hours of debugging later.

🎯 What You'll Learn

  • Installing Arduino IDE
  • Adding ESP32 board support
  • Selecting the right board and port
  • Uploading your first test sketch
  • Installing required libraries

Step 1: Install Arduino IDE

  1. Go to arduino.cc/en/software
  2. Download Arduino IDE 2.x for your operating system
  3. Install and open Arduino IDE

Step 2: Add ESP32 Board Support

Add the ESP32 Board URL:

  1. Go to File → Preferences (Mac: Arduino IDE → Settings)
  2. Find "Additional boards manager URLs"
  3. Paste this URL: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  4. Click OK

Install ESP32 Boards:

  1. Go to Tools → Board → Boards Manager
  2. Search for "esp32"
  3. Install "esp32 by Espressif Systems" (version 2.x or 3.x)
  4. Wait for installation (takes 2-3 minutes)

Step 3: Select Your Board

  1. Connect your XIAO ESP32S3 via USB-C cable
  2. Go to Tools → Board → esp32
  3. Select "XIAO_ESP32S3"
  4. Go to Tools → Port and select the port that appeared (usually contains "USB" or "XIAO")

Step 4: Test Upload (Blink)

Let's verify everything works with a simple LED blink:

Blink_Test.ino
// XIAO ESP32S3 Blink Test
// The built-in LED is on pin 21 (active LOW)

void setup() {
  pinMode(21, OUTPUT);
  Serial.begin(115200);
  Serial.println("XIAO ESP32S3 is working!");
}

void loop() {
  digitalWrite(21, LOW);   // LED ON
  delay(500);
  digitalWrite(21, HIGH);  // LED OFF
  delay(500);
}

Click the Upload button (→ arrow). After uploading, you should see the orange LED blinking.

✓ Success Looks Like

  • Console shows "Done uploading"
  • Orange LED on board blinks on/off every 0.5 seconds
  • Serial Monitor (Tools → Serial Monitor, 115200 baud) shows "XIAO ESP32S3 is working!"

⚠️ Troubleshooting

Problem Solution
No port appears Try a different USB cable (some are charge-only). Use USB-C directly, not through a hub.
"Failed to connect" Hold BOOT button while clicking Upload. Release after "Connecting..." appears.
Windows: Port not found Install CP210x drivers or check Device Manager for unknown devices.
Mac: Permission denied System Preferences → Security & Privacy → Allow the USB device.

Step 5: Install Libraries

These libraries power the advanced examples (BPM calculation, OLED display, WiFi streaming).

How to Install Libraries:

  1. Go to Sketch → Include Library → Manage Libraries
  2. Search for each library name below
  3. Click Install (accept any dependencies if prompted)
Library Author Used In
PulseSensor Playground World Famous Electronics Examples 2b, 3b, 4b, 5b
Adafruit SH110X Adafruit Examples 3, 5 (OLED)
Adafruit GFX Library Adafruit Examples 3, 5 (graphics)
WebSockets Markus Sattler Examples 4, 5 (WiFi)

⚠️ Library Troubleshooting

Problem Solution
Library not found Check spelling: "PulseSensor Playground" (not "Pulse Sensor"), "WebSockets" (not "WebSocket")
Multiple versions Choose the one by the author listed above. Install the latest version.
"Install dependencies?" Click "Install All" — this is normal for Adafruit libraries.

Setup complete! You're ready to see your heartbeat.


1. Serial Plotter

🎯 What You'll Learn

  • How the PulseSensor signal looks as a waveform
  • What a "threshold" is and why it matters
  • Basic beat detection using signal level
XIAO PulseSensor Wiring
PulseSensor Wire XIAO ESP32S3 Pin
Purple (Signal) A0 (GPIO 1)
Red (Power) 3.3V
Black (Ground) GND

💡 Concept: Threshold Detection

A threshold is a signal level that separates "beat" from "no beat." When the signal rises above the threshold, we know blood is pulsing through the fingertip. The XIAO's 12-bit ADC reads values from 0-4095. A typical threshold is around 2000 — above ambient light but below the pulse peaks. Think of it like a finish line the signal must cross to count as a heartbeat.

PulseSensor_SerialPlotter.ino
/*
 * PulseSensor_XIAO_ESP32S3_SerialPlotter.ino
 * Visualize heartbeat in Arduino Serial Plotter.
 * LED blinks on each detected pulse.
 * 
 * Hardware: XIAO ESP32S3, PulseSensor
 * >> https://pulsesensor.com
 */

const int SENSOR_PIN = 1;
const int LED_PIN = 21;
int threshold = 2000;
int lastSensorValue = 0;
unsigned long lastPulseTime = 0;

void setup() {
  Serial.begin(115200);
  Serial.println("PulseSensor SerialPlotter");
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  int val = analogRead(SENSOR_PIN);

  // Blink LED on pulse (rising edge above threshold)
  if (val > threshold && val > lastSensorValue && millis() - lastPulseTime > 300) {
    lastPulseTime = millis();
    digitalWrite(LED_PIN, LOW);
    delay(20);
    digitalWrite(LED_PIN, HIGH);
  }

  // Output for Serial Plotter
  Serial.print("Signal:");
  Serial.println(val);

  lastSensorValue = val;
  delay(20);
}

✓ Success Looks Like

Open Tools → Serial Plotter (not Serial Monitor). Set baud to 115200.

  • Good waveform: Smooth rolling hills with sharp peaks every ~1 second. Each peak is a heartbeat.
  • LED blinks: The orange LED flashes in sync with the peaks on the plotter.
  • Signal range: Values typically swing between 1500-3500 with finger on sensor.

The wave should look like a smooth "M" or "W" shape — that's the characteristic pulse waveform showing the systolic peak (heart contraction) and dicrotic notch (valve closing).

⚠️ Troubleshooting

Problem Solution
Flat line (no signal) Check wiring: Purple→A0, Red→3.3V, Black→GND. Make sure sensor is pressed gently against fingertip.
Noisy/jagged line Press more firmly but don't squeeze. Rest your hand on the table to reduce movement.
Signal stuck high (~4000) Too much pressure! Lighten your touch. You're compressing the blood vessels.
LED doesn't blink Adjust threshold value. Watch the plotter — set threshold to about 60% of peak height.
Double-blinks Increase the debounce time from 300 to 400ms, or raise the threshold.

Project Set 2

BPM Monitoring

🎯 What You'll Learn

  • How to calculate BPM from timing between beats
  • Why "debouncing" prevents false triggers
  • How averaging stabilizes readings
  • The difference between raw ADC values and library-normalized values

💡 Concept: Debouncing

Debounce means ignoring rapid repeated triggers. A heartbeat signal doesn't cross the threshold once — it wobbles above and below several times as it peaks. Without debounce, one heartbeat might register as 3-4 beats. The millis() - lastBeatTime > 300 check says "ignore any beats within 300ms of the last one." Since 300ms = 200 BPM maximum, this filters noise without missing real beats (nobody's heart beats faster than 200 BPM at rest).

💡 Concept: Why Different Threshold Values?

You'll notice manual examples use threshold = 2000 while library examples use setThreshold(550). Why?

  • Manual code (2000): Reads raw 12-bit ADC values (0-4095). The signal typically swings 1500-3500, so 2000 catches the rising edge.
  • Library (550): The PulseSensor Playground library normalizes the signal to a 0-1024 range internally, similar to Arduino Uno's 10-bit ADC. So 550 on this scale ≈ 2200 on the raw scale.

Rule of thumb: For raw ESP32 code, use ~2000. For library code, use ~550.

2a. Manual BPM Calculator

Raw timing math with 4-beat averaging. This shows you exactly how BPM calculation works.

PulseSensor_BPM_Manual.ino
/*
 * PulseSensor_XIAO_ESP32S3_BPM.ino
 * Calculates heart rate from beat-to-beat timing.
 * Averages last 4 beats for stable reading.
 * 
 * THE MATH: BPM = 60,000ms / milliseconds_between_beats
 * Example: 750ms between beats = 60000/750 = 80 BPM
 * 
 * Hardware: XIAO ESP32S3, PulseSensor
 * >> https://pulsesensor.com
 */

const int SENSOR_PIN = 1;
const int LED_PIN = 21;
const int THRESHOLD = 2000;    // Raw ADC threshold (0-4095 scale)
const int FINGER_ON = 500;     // Minimum signal to detect finger

long lastBeatTime = 0;
int beatsInARow = 0;
int beatHistory[4];            // Store last 4 BPM readings
int historyIndex = 0;

void setup() {
  Serial.begin(115200);
  Serial.println("PulseSensor BPM Manual");
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  int signal = analogRead(SENSOR_PIN);

  // Check if finger is present
  if (signal < FINGER_ON) {
    Serial.println("No Finger Detected");
    beatsInARow = 0;
    delay(100);
    return;
  }

  // Detect heartbeat with 300ms debounce
  if (signal > THRESHOLD && millis() - lastBeatTime > 300) {
    long currentBeatTime = millis();
    long delta = currentBeatTime - lastBeatTime;
    int currentBPM = 60000 / delta;  // THE KEY FORMULA

    lastBeatTime = currentBeatTime;

    // Validate: ignore outliers (must be 60-150 BPM)
    if (currentBPM > 60 && currentBPM < 150) {
      beatHistory[historyIndex] = currentBPM;
      historyIndex = (historyIndex + 1) % 4;
      beatsInARow++;

      digitalWrite(LED_PIN, LOW);
      delay(20);
      digitalWrite(LED_PIN, HIGH);

      if (beatsInARow < 4) {
        Serial.print("Scanning... (");
        Serial.print(beatsInARow);
        Serial.println(" beats)");
      } else {
        // Average the last 4 readings
        int total = 0;
        for (int i = 0; i < 4; i++) { total += beatHistory[i]; }
        Serial.print("BPM: ");
        Serial.println(total / 4);
      }
    } else {
      Serial.println("Keep Finger Still!");
      beatsInARow = 0;
    }
  }
}

✓ Success Looks Like (2a)

  • No finger: Shows "No Finger Detected" repeatedly
  • Just touched: Shows "Scanning... (1 beats)", then (2 beats), (3 beats)
  • After 4 beats: Shows "BPM: 72" (or whatever your heart rate is)
  • Stable reading: BPM varies by only ±3-5 once stabilized

It takes about 4-5 seconds to get the first stable BPM reading. This is normal — the code needs 4 beats to average.

2b. Library BPM Monitor

Same result, much less code. The library handles interrupts, timing, and 10-beat averaging.

Library Required: PulseSensor Playground (install via Library Manager)

PulseSensor_BPM_Library.ino
/*
 * PulseSensor_XIAO_ESP32S3_BPM_Monitor.ino
 * Uses library's built-in averaging for stable BPM.
 * 
 * Hardware: XIAO ESP32S3, PulseSensor
 * Library: PulseSensor Playground
 * >> https://pulsesensor.com
 */

#include <PulseSensorPlayground.h>

const int PULSE_PIN = 1;
const int LED_PIN = 21;

PulseSensorPlayground pulseSensor;

void setup() {
  Serial.begin(115200);
  Serial.println("PulseSensor BPM Library");

  pulseSensor.analogInput(PULSE_PIN);
  pulseSensor.blinkOnPulse(LED_PIN);
  pulseSensor.setThreshold(550);  // Library uses 0-1024 scale
  pulseSensor.begin();
}

void loop() {
  int bpm = pulseSensor.getBeatsPerMinute();

  if (pulseSensor.sawStartOfBeat()) {
    Serial.print("BPM: ");
    Serial.println(bpm);
  }
  delay(20);
}

✓ Success Looks Like (2b)

  • BPM readings appear every heartbeat
  • Readings are typically smoother than 2a (library uses 10-beat averaging vs our 4)
  • LED blinks automatically (library handles it)

⚠️ Troubleshooting (Both 2a and 2b)

Problem Solution
Always shows "No Finger" Check wiring. If wiring is correct, lower FINGER_ON to 300.
BPM jumps wildly (40→180→65) Keep finger absolutely still. Rest your hand on a table. Avoid pressing too hard.
BPM always shows 0 or very low Increase threshold (try 2200 for manual, 600 for library).
"Keep Finger Still" constantly Your movement is causing outlier readings. Try earlobe instead of fingertip — it moves less.
Library version shows nothing Make sure you installed "PulseSensor Playground" from Library Manager, not a different library.
Project Set 3

OLED Display

🎯 What You'll Learn

  • How I2C communication connects displays
  • Mapping sensor values to screen pixels
  • Using a circular buffer for scrolling waveforms
OLED Wiring Setup
Component Wire/Pin XIAO ESP32S3
PulseSensor Purple (Signal) A0 (GPIO 1)
Red (Power) 3.3V
Black (Ground) GND
OLED SDA D4 (GPIO 5)
SCL D5 (GPIO 6)
VCC 3.3V
GND GND

💡 Concept: I2C Communication

I2C (pronounced "I-squared-C") is a two-wire protocol for connecting devices. SDA carries data, SCL carries the clock signal. Each device has an address — the SH1106 OLED is usually 0x3C. If your OLED doesn't work, it might be at 0x3D instead. The XIAO ESP32S3's default I2C pins are D4 (SDA) and D5 (SCL), but we explicitly set them with Wire.begin(5, 6) to be safe.

💡 Concept: Signal-to-Pixel Mapping

The sensor outputs 0-4095, but the OLED is only 64 pixels tall. The map() function converts between ranges: map(sensorValue, 0, 4095, 63, 20) transforms sensor values to Y coordinates. Notice the output is reversed (63 to 20, not 20 to 63) because screen coordinates start at the top — Y=0 is the top of the screen, Y=63 is the bottom.

3a. Manual Waveform Display

Maps 12-bit signal to OLED pixels using a circular buffer for smooth scrolling.

Libraries Required: Adafruit SH110X, Adafruit GFX Library

PulseSensor_OLED_Manual.ino
/*
 * PulseSensor_XIAO_ESP32S3_OLED.ino
 * Displays pulse waveform on 1.3" SH1106 OLED.
 * 
 * Hardware: XIAO ESP32S3, PulseSensor, 1.3" SH1106 OLED
 * Libraries: Adafruit SH110X, Adafruit GFX
 * >> https://pulsesensor.com
 */

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>

Adafruit_SH1106G display(128, 64, &Wire, -1);

#define WAVE_WIDTH 100
int waveBuffer[WAVE_WIDTH];  // Circular buffer for waveform
int waveIndex = 0;

void setup() {
  Serial.begin(115200);
  
  // Initialize I2C on XIAO's pins
  Wire.begin(5, 6);  // SDA=GPIO5, SCL=GPIO6
  
  if (!display.begin(0x3C, true)) {
    Serial.println("OLED not found! Check wiring.");
    while(1);  // Stop here if OLED fails
  }
  
  display.setTextColor(SH110X_WHITE);
  display.clearDisplay();
  display.setCursor(20, 25);
  display.println("PulseSensor.com");
  display.display();
  delay(2000);
  
  // Initialize buffer to middle of screen
  for (int i = 0; i < WAVE_WIDTH; i++) waveBuffer[i] = 40;
}

void loop() {
  int sensorValue = analogRead(1);
  
  // Map sensor (0-4095) to screen Y (63-20)
  // Note: Y is inverted because 0 is top of screen
  int y = map(sensorValue, 0, 4095, 63, 20);
  y = constrain(y, 20, 63);
  
  // Add to circular buffer
  waveBuffer[waveIndex] = y;
  waveIndex = (waveIndex + 1) % WAVE_WIDTH;
  
  display.clearDisplay();
  
  // Header
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("PulseSensor.com");
  
  // Timer
  unsigned long secs = millis() / 1000;
  unsigned long mins = secs / 60;
  secs = secs % 60;
  display.setCursor(90, 0);
  if (mins < 10) display.print("0");
  display.print(mins);
  display.print(":");
  if (secs < 10) display.print("0");
  display.print(secs);
  
  // Signal value
  display.setCursor(0, 10);
  display.print("Signal: ");
  display.print(sensorValue);
  
  // Draw waveform from circular buffer
  for (int i = 0; i < WAVE_WIDTH - 1; i++) {
    int idx = (waveIndex + i) % WAVE_WIDTH;
    int idxNext = (waveIndex + i + 1) % WAVE_WIDTH;
    display.drawLine(14 + i, waveBuffer[idx], 14 + i + 1, waveBuffer[idxNext], SH110X_WHITE);
  }
  
  display.display();
  delay(20);
}

3b. Library OLED Display

Uses PulseSensor Playground for beat detection, adds BPM display to the waveform.

PulseSensor_OLED_Library.ino
/*
 * PulseSensor_XIAO_ESP32S3_OLED_Lib.ino
 * OLED display with library-powered beat detection.
 * 
 * Hardware: XIAO ESP32S3, PulseSensor, 1.3" SH1106 OLED
 * Libraries: PulseSensor Playground, Adafruit SH110X, Adafruit GFX
 * >> https://pulsesensor.com
 */

#include <PulseSensorPlayground.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>

const int PULSE_PIN = 1;
const int LED_PIN = 21;

PulseSensorPlayground pulseSensor;
Adafruit_SH1106G display(128, 64, &Wire, -1);

#define WAVE_WIDTH 100
int waveBuffer[WAVE_WIDTH];
int waveIndex = 0;

void setup() {
  Serial.begin(115200);
  
  pulseSensor.analogInput(PULSE_PIN);
  pulseSensor.blinkOnPulse(LED_PIN);
  pulseSensor.setThreshold(550);
  pulseSensor.begin();
  
  Wire.begin(5, 6);
  display.begin(0x3C, true);
  display.setTextColor(SH110X_WHITE);
  
  display.clearDisplay();
  display.setCursor(20, 20);
  display.println("PulseSensor");
  display.setCursor(20, 35);
  display.println("OLED + Library");
  display.display();
  delay(2000);
  
  for (int i = 0; i < WAVE_WIDTH; i++) waveBuffer[i] = 32;
}

void loop() {
  int signal = pulseSensor.getLatestSample();
  int bpm = pulseSensor.getBeatsPerMinute();
  bool beat = pulseSensor.sawStartOfBeat();
  
  int y = map(signal, 0, 4095, 63, 20);
  y = constrain(y, 20, 63);
  waveBuffer[waveIndex] = y;
  waveIndex = (waveIndex + 1) % WAVE_WIDTH;
  
  display.clearDisplay();
  
  // BPM display
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("BPM: ");
  if (bpm > 40 && bpm < 200) {
    display.print(bpm);
  } else {
    display.print("--");
  }
  
  // Beat indicator
  if (beat) {
    display.setCursor(60, 0);
    display.print("*");
  }
  
  // Draw waveform
  for (int i = 0; i < WAVE_WIDTH - 1; i++) {
    int idx = (waveIndex + i) % WAVE_WIDTH;
    int idxNext = (waveIndex + i + 1) % WAVE_WIDTH;
    display.drawLine(14 + i, waveBuffer[idx], 14 + i + 1, waveBuffer[idxNext], SH110X_WHITE);
  }
  
  display.display();
  delay(20);
}

✓ Success Looks Like

  • Boot screen: "PulseSensor.com" displays for 2 seconds
  • Running: Scrolling waveform moves left-to-right across screen
  • Peaks visible: You should see clear spikes in the waveform for each heartbeat
  • 3b only: BPM number in top-left, asterisk (*) flashes on each beat

⚠️ Troubleshooting

Problem Solution
Blank screen (nothing at all) Check VCC→3.3V and GND→GND. Make sure SDA→D4 and SCL→D5 (not swapped).
"OLED not found" in Serial Try I2C address 0x3D instead of 0x3C. Some OLEDs differ.
Screen shows garbage Wrong display type? This code is for SH1106. If you have SSD1306, use Adafruit_SSD1306 library instead.
Waveform is flat line OLED is working but sensor isn't. Check PulseSensor wiring separately using Example 1.
Display flickers or resets Power issue. Use a shorter USB cable or power OLED from a separate 3.3V source.
Project Set 4

WiFi Dashboard

🎯 What You'll Learn

  • Creating a WiFi hotspot (Access Point mode)
  • Serving a web page from the microcontroller
  • Real-time data streaming with WebSockets
  • Peak detection algorithm for accurate beat timing
WiFi Antenna XIAO

💡 Concept: Access Point vs. Station Mode

WiFi devices can work in two modes: Station (connects to your router like a phone) or Access Point (creates its own network like a router). We use Access Point mode (WiFi.softAP()) so the XIAO creates a network called "PulseSensor.com" — no internet required, works anywhere. Your phone connects directly to the XIAO.

💡 Concept: WebSockets

WebSockets create a persistent two-way connection between browser and server. Unlike regular HTTP (request → response → done), WebSockets stay open for continuous data streaming. The XIAO sends signal,bpm,beat data 50 times per second, and the browser's JavaScript receives it instantly to update the chart. This is why the waveform animates smoothly — there's no page refresh, just a constant data stream.

💡 Concept: Peak Detection

Example 4a uses peak detection instead of simple threshold crossing. The algorithm waits for the signal to rise (going up) and then start falling (going down) while above threshold. This catches the actual peak of each heartbeat, not just when it crosses a line. It's more accurate but slightly more complex.

4a. Manual WiFi Hotspot

Creates hotspot "PulseSensor.com" with WebSocket streaming to a browser dashboard.

Library Required: WebSockets by Markus Sattler

PulseSensor_WiFi_Manual.ino
/*
 * PulseSensor_XIAO_ESP32S3_WiFi.ino
 * Creates WiFi hotspot - Connect any device to see your pulse!
 * 
 * HOW TO USE:
 * 1. Upload this code
 * 2. On phone/laptop, connect to WiFi "PulseSensor.com"
 * 3. Open browser, go to 192.168.4.1
 * 4. See your pulse!
 * 
 * Hardware: XIAO ESP32S3, PulseSensor
 * Library: WebSockets by Markus Sattler
 * >> https://pulsesensor.com
 */

#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>

const char *WIFI_NAME = "PulseSensor.com";
#define PULSE_PIN 1
#define DEBOUNCE_MS 300

WebServer server(80);
WebSocketsServer webSocket(81);

int sensorValue = 0;
int lastValue = 0;
int sensorHigh = 0;
int sensorLow = 4095;
bool rising = false;

unsigned long lastBeatTime = 0;
int BPM = 0;
bool beatDetected = false;
int clientCount = 0;

// The entire web dashboard is embedded in this string!
const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PulseSensor Dashboard</title>
  <style>
    body { font-family: sans-serif; background: #111; color: white; padding: 20px; }
    .bpm { font-size: 4em; text-align: center; color: #ff4444; }
    canvas { width: 100%; height: 180px; background: #222; border-radius: 10px; }
  </style>
</head>
<body>
  <h1 style="text-align:center">PulseSensor Dashboard</h1>
  <div class="bpm" id="bpm">--</div>
  <p style="text-align:center">BPM</p>
  <canvas id="wave"></canvas>
  <script>
    const canvas = document.getElementById('wave');
    const ctx = canvas.getContext('2d');
    canvas.width = canvas.offsetWidth;
    canvas.height = 180;
    let data = [];
    const ws = new WebSocket('ws://' + location.hostname + ':81');
    ws.onmessage = (e) => {
      const p = e.data.split(',');
      const sig = parseInt(p[0]);
      const bpm = parseInt(p[1]);
      if (bpm >= 40 && bpm <= 200) {
        document.getElementById('bpm').textContent = bpm;
      }
      data.push(sig);
      if (data.length > 150) data.shift();
      ctx.fillStyle = '#222';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      if (data.length > 1) {
        const min = Math.min(...data);
        const max = Math.max(...data);
        const range = max - min || 1;
        ctx.strokeStyle = '#ff4444';
        ctx.lineWidth = 2;
        ctx.beginPath();
        for (let i = 0; i < data.length; i++) {
          const x = (i / 150) * canvas.width;
          const y = canvas.height - ((data[i] - min) / range) * 160 - 10;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        ctx.stroke();
      }
    };
  </script>
</body>
</html>
)rawliteral";

void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) {
  if (type == WStype_CONNECTED) clientCount++;
  else if (type == WStype_DISCONNECTED) clientCount = max(0, clientCount - 1);
}

void setup() {
  Serial.begin(115200);
  
  WiFi.softAP(WIFI_NAME);
  Serial.println("================================");
  Serial.println("WiFi Started!");
  Serial.println("1. Connect to: PulseSensor.com");
  Serial.println("2. Open browser: 192.168.4.1");
  Serial.println("================================");
  
  server.on("/", []() { server.send_P(200, "text/html", DASHBOARD_HTML); });
  server.begin();
  
  webSocket.begin();
  webSocket.onEvent(onWebSocketEvent);
  
  analogReadResolution(12);
}

void loop() {
  server.handleClient();
  webSocket.loop();
  
  sensorValue = analogRead(PULSE_PIN);
  
  // Auto-calibrate threshold based on signal range
  if (sensorValue > sensorHigh) sensorHigh = sensorValue;
  if (sensorValue < sensorLow) sensorLow = sensorValue;
  int threshold = (sensorHigh + sensorLow) / 2;
  
  // Peak detection: find when signal starts falling after rising
  beatDetected = false;
  if (sensorValue > lastValue) {
    rising = true;
  } else if (rising && lastValue > threshold && millis() - lastBeatTime > DEBOUNCE_MS) {
    beatDetected = true;
    rising = false;
    unsigned long interval = millis() - lastBeatTime;
    if (lastBeatTime > 0) BPM = 60000 / interval;
    lastBeatTime = millis();
  }
  
  // Send data to all connected browsers
  String data = String(sensorValue) + "," + String(BPM) + "," +
                (beatDetected ? "1" : "0");
  webSocket.broadcastTXT(data);
  
  lastValue = sensorValue;
  delay(10);
}

4b. Library WiFi Dashboard

Uses PulseSensor Playground for reliable beat detection while WiFi is running.

PulseSensor_WiFi_Library.ino
/*
 * PulseSensor_XIAO_ESP32S3_WiFi_Lib.ino
 * WiFi Dashboard with library-powered beat detection.
 * 
 * Connect to: "PulseSensor.com" WiFi
 * Open browser: 192.168.4.1
 * 
 * Hardware: XIAO ESP32S3, PulseSensor
 * Libraries: PulseSensor Playground, WebSockets
 * >> https://pulsesensor.com
 */

#include <PulseSensorPlayground.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>

const char* WIFI_NAME = "PulseSensor.com";
const int PULSE_PIN = 1;
const int LED_PIN = 21;

PulseSensorPlayground pulseSensor;
WebServer server(80);
WebSocketsServer webSocket(81);

const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PulseSensor</title>
  <style>
    body { font-family: sans-serif; background: #111; color: white; padding: 20px; text-align: center; }
    .bpm { font-size: 4em; color: #ff4444; }
    canvas { width: 100%; height: 180px; background: #222; border-radius: 10px; margin-top: 20px; }
  </style>
</head>
<body>
  <h1>PulseSensor (Library)</h1>
  <div class="bpm" id="bpm">--</div>
  <p>BPM</p>
  <canvas id="wave"></canvas>
  <script>
    const c = document.getElementById('wave');
    const ctx = c.getContext('2d');
    c.width = c.offsetWidth; c.height = 180;
    let d = [];
    const ws = new WebSocket('ws://' + location.hostname + ':81');
    ws.onmessage = (e) => {
      const p = e.data.split(',');
      const sig = parseInt(p[0]), bpm = parseInt(p[1]);
      if (bpm >= 40 && bpm <= 200) document.getElementById('bpm').textContent = bpm;
      d.push(sig); if (d.length > 150) d.shift();
      ctx.fillStyle = '#222'; ctx.fillRect(0, 0, c.width, c.height);
      if (d.length > 1) {
        const min = Math.min(...d), max = Math.max(...d), r = max - min || 1;
        ctx.strokeStyle = '#ff4444'; ctx.lineWidth = 2; ctx.beginPath();
        for (let i = 0; i < d.length; i++) {
          const x = (i / 150) * c.width, y = c.height - ((d[i] - min) / r) * 160 - 10;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        ctx.stroke();
      }
    };
  </script>
</body>
</html>
)rawliteral";

void setup() {
  Serial.begin(115200);
  
  pulseSensor.analogInput(PULSE_PIN);
  pulseSensor.blinkOnPulse(LED_PIN);
  pulseSensor.setThreshold(550);
  pulseSensor.begin();
  
  WiFi.softAP(WIFI_NAME);
  server.on("/", []() { server.send_P(200, "text/html", DASHBOARD_HTML); });
  server.begin();
  webSocket.begin();
  
  Serial.println("WiFi: PulseSensor.com");
  Serial.println("Open: 192.168.4.1");
}

void loop() {
  server.handleClient();
  webSocket.loop();
  
  int signal = pulseSensor.getLatestSample();
  int bpm = pulseSensor.getBeatsPerMinute();
  bool beat = pulseSensor.sawStartOfBeat();
  
  String data = String(signal) + "," + String(bpm) + "," + (beat ? "1" : "0");
  webSocket.broadcastTXT(data);
  
  delay(10);
}

✓ Success Looks Like

  • Serial Monitor: Shows "WiFi Started!" and instructions
  • Phone/Laptop: WiFi network "PulseSensor.com" appears (no password)
  • Browser at 192.168.4.1: Dark dashboard with large red BPM number and animated waveform
  • Live data: Waveform scrolls smoothly, BPM updates with each beat

⚠️ Troubleshooting

Problem Solution
WiFi network doesn't appear Wait 5-10 seconds after upload. Check Serial Monitor for errors. Try resetting the board.
"192.168.4.1 refused to connect" Turn off mobile data! Your phone is trying to use 4G instead of the XIAO's local server.
Page loads but waveform is flat WebSocket connected but no sensor data. Check PulseSensor wiring.
Waveform freezes after a few seconds WebSocket disconnected. Refresh the page. If it keeps happening, use a better USB cable.
BPM shows "--" constantly Beats aren't being detected. Put finger on sensor, check threshold value.
Multiple devices can't connect ESP32 supports ~4 simultaneous WiFi clients. This is normal.
Project Set 5

WiFi + OLED Combined

🎯 What You'll Learn

  • Running multiple systems simultaneously (display + WiFi + sensor)
  • Managing shared resources and timing
  • Building a complete standalone pulse monitor
WiFi + OLED Setup
Component Wire/Pin XIAO ESP32S3
PulseSensor Purple (Signal) A0 (GPIO 1)
Red (Power) 3.3V
Black (Ground) GND
OLED SDA D4 (GPIO 5)
SCL D5 (GPIO 6)
VCC 3.3V
GND GND

💡 Concept: Juggling Multiple Tasks

This example runs three systems at once: sensor reading, OLED display, and WiFi server. Each loop() iteration must: read the sensor, check for beats, update the OLED buffer, refresh the display, handle HTTP requests, and broadcast WebSocket data. The delay(10) at the end gives WiFi time to process. If you remove it, WiFi becomes unreliable. If you increase it too much, the waveform gets choppy. 10ms is the sweet spot — 100 updates per second.

5a. Manual WiFi + OLED

Full implementation combining everything from Examples 3a and 4a.

PulseSensor_WiFi_OLED_Manual.ino
/*
 * PulseSensor_XIAO_ESP32S3_WiFi_OLED.ino
 * The Complete System: WiFi + OLED + BPM
 * 
 * Watch your pulse on OLED AND phone simultaneously!
 * 
 * Connect to: "PulseSensor.com" WiFi
 * Open browser: 192.168.4.1
 * 
 * Hardware: XIAO ESP32S3, PulseSensor, 1.3" SH1106 OLED
 * Libraries: WebSockets, Adafruit SH110X, Adafruit GFX
 * >> https://pulsesensor.com
 */

#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>

const char *WIFI_NAME = "PulseSensor.com";
#define PULSE_PIN 1
#define SDA_PIN 5
#define SCL_PIN 6
#define DEBOUNCE_MS 300

WebServer server(80);
WebSocketsServer webSocket(81);
Adafruit_SH1106G display(128, 64, &Wire, -1);

int sensorValue = 0, lastValue = 0;
int sensorHigh = 0, sensorLow = 4095;
bool rising = false;
unsigned long lastBeatTime = 0;
int BPM = 0;
bool beatDetected = false;
int clientCount = 0;

#define WAVE_WIDTH 50
int waveBuffer[WAVE_WIDTH];
int waveIndex = 0;

const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PulseSensor</title>
<style>body{font-family:sans-serif;background:#111;color:white;padding:20px;text-align:center}.bpm{font-size:4em;color:#ff4444}canvas{width:100%;height:180px;background:#222;border-radius:10px;margin-top:20px}</style>
</head><body>
<h1>PulseSensor Dashboard</h1>
<div class="bpm" id="bpm">--</div><p>BPM</p>
<canvas id="wave"></canvas>
<script>const c=document.getElementById('wave'),ctx=c.getContext('2d');c.width=c.offsetWidth;c.height=180;let d=[];const ws=new WebSocket('ws://'+location.hostname+':81');ws.onmessage=(e)=>{const p=e.data.split(','),sig=parseInt(p[0]),bpm=parseInt(p[1]);if(bpm>=40&&bpm<=200)document.getElementById('bpm').textContent=bpm;d.push(sig);if(d.length>150)d.shift();ctx.fillStyle='#222';ctx.fillRect(0,0,c.width,c.height);if(d.length>1){const min=Math.min(...d),max=Math.max(...d),r=max-min||1;ctx.strokeStyle='#ff4444';ctx.lineWidth=2;ctx.beginPath();for(let i=0;i<d.length;i++){const x=(i/150)*c.width,y=c.height-((d[i]-min)/r)*160-10;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)}ctx.stroke()}}</script>
</body></html>
)rawliteral";

void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) {
  if (type == WStype_CONNECTED) clientCount++;
  else if (type == WStype_DISCONNECTED) clientCount = max(0, clientCount - 1);
}

void updateDisplay() {
  display.clearDisplay();
  display.setTextSize(1);
  
  // Header
  display.setCursor(0, 0);
  display.print("PulseSensor.com");
  
  // Timer
  unsigned long secs = millis() / 1000;
  unsigned long mins = secs / 60;
  secs = secs % 60;
  display.setCursor(90, 0);
  if (mins < 10) display.print("0");
  display.print(mins);
  display.print(":");
  if (secs < 10) display.print("0");
  display.print(secs);
  
  // IP & Client count
  display.setCursor(0, 12);
  display.print("Open: 192.168.4.1");
  display.setCursor(0, 24);
  display.print("Clients: ");
  display.print(clientCount);
  
  // BPM
  display.setCursor(0, 36);
  display.print("BPM: ");
  bool validBPM = BPM >= 40 && BPM <= 200 && millis() - lastBeatTime < 3000;
  if (validBPM) display.print(BPM);
  else display.print("--");
  
  // Mini waveform in bottom-right
  for (int i = 0; i < WAVE_WIDTH - 1; i++) {
    int idx = (waveIndex + i) % WAVE_WIDTH;
    int idxNext = (waveIndex + i + 1) % WAVE_WIDTH;
    display.drawLine(64 + i, 52 + waveBuffer[idx], 64 + i + 1, 52 + waveBuffer[idxNext], SH110X_WHITE);
  }
  
  display.display();
}

void setup() {
  Serial.begin(115200);
  
  // Initialize OLED
  Wire.begin(SDA_PIN, SCL_PIN);
  if (!display.begin(0x3C, true)) {
    Serial.println("OLED not found!");
    while(1);
  }
  display.setTextColor(SH110X_WHITE);
  display.clearDisplay();
  display.setCursor(10, 25);
  display.println("PulseSensor.com");
  display.display();
  delay(2000);
  
  // Initialize WiFi
  WiFi.softAP(WIFI_NAME);
  server.on("/", []() { server.send_P(200, "text/html", DASHBOARD_HTML); });
  server.begin();
  webSocket.begin();
  webSocket.onEvent(onWebSocketEvent);
  
  analogReadResolution(12);
  for (int i = 0; i < WAVE_WIDTH; i++) waveBuffer[i] = 4;
  
  Serial.println("WiFi + OLED Ready!");
  Serial.println("Connect to: PulseSensor.com");
  Serial.println("Open: 192.168.4.1");
}

void loop() {
  server.handleClient();
  webSocket.loop();
  
  sensorValue = analogRead(PULSE_PIN);
  
  // Auto-calibrate
  if (sensorValue > sensorHigh) sensorHigh = sensorValue;
  if (sensorValue < sensorLow) sensorLow = sensorValue;
  int threshold = (sensorHigh + sensorLow) / 2;
  
  // Peak detection
  beatDetected = false;
  if (sensorValue > lastValue) {
    rising = true;
  } else if (rising && lastValue > threshold && millis() - lastBeatTime > DEBOUNCE_MS) {
    beatDetected = true;
    rising = false;
    unsigned long interval = millis() - lastBeatTime;
    if (lastBeatTime > 0) BPM = 60000 / interval;
    lastBeatTime = millis();
  }
  
  // Update OLED waveform buffer
  int waveY = map(sensorValue, sensorLow, sensorHigh, 8, 0);
  waveY = constrain(waveY, 0, 8);
  waveBuffer[waveIndex] = waveY;
  waveIndex = (waveIndex + 1) % WAVE_WIDTH;
  
  // Send to browsers
  String data = String(sensorValue) + "," + String(BPM) + "," + (beatDetected ? "1" : "0");
  webSocket.broadcastTXT(data);
  
  updateDisplay();
  
  lastValue = sensorValue;
  delay(10);
}

5b. Library WiFi + OLED

Uses PulseSensor Playground for reliable beat detection. Best for production use.

PulseSensor_WiFi_OLED_Library.ino
/*
 * PulseSensor_XIAO_ESP32S3_WiFi_OLED_Lib.ino
 * WiFi + OLED + Library-powered beat detection
 * 
 * Connect to: "PulseSensor.com" WiFi
 * Open browser: 192.168.4.1
 * 
 * Hardware: XIAO ESP32S3, PulseSensor, 1.3" SH1106 OLED
 * Libraries: PulseSensor Playground, WebSockets, Adafruit SH110X
 * >> https://pulsesensor.com
 */

#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#define USE_ARDUINO_INTERRUPTS false
#include <PulseSensorPlayground.h>

const char *WIFI_NAME = "PulseSensor.com";
#define PULSE_PIN 1
#define SDA_PIN 5
#define SCL_PIN 6
#define THRESHOLD 550

WebServer server(80);
WebSocketsServer webSocket(81);
Adafruit_SH1106G display(128, 64, &Wire, -1);
PulseSensorPlayground pulseSensor;

int clientCount = 0;
int BPM = 0;
bool beatDetected = false;
unsigned long lastBeatTime = 0;

#define WAVE_WIDTH 50
int waveBuffer[WAVE_WIDTH];
int waveIndex = 0;

const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PulseSensor (Library)</title>
<style>body{font-family:sans-serif;background:#111;color:white;padding:20px;text-align:center}.bpm{font-size:4em;color:#ff4444}canvas{width:100%;height:180px;background:#222;border-radius:10px;margin-top:20px}</style>
</head><body>
<h1>PulseSensor (Library)</h1>
<div class="bpm" id="bpm">--</div><p>BPM</p>
<canvas id="wave"></canvas>
<script>const c=document.getElementById('wave'),ctx=c.getContext('2d');c.width=c.offsetWidth;c.height=180;let d=[];const ws=new WebSocket('ws://'+location.hostname+':81');ws.onmessage=(e)=>{const p=e.data.split(','),sig=parseInt(p[0]),bpm=parseInt(p[1]);if(bpm>=40&&bpm<=200)document.getElementById('bpm').textContent=bpm;d.push(sig);if(d.length>150)d.shift();ctx.fillStyle='#222';ctx.fillRect(0,0,c.width,c.height);if(d.length>1){const min=Math.min(...d),max=Math.max(...d),r=max-min||1;ctx.strokeStyle='#ff4444';ctx.lineWidth=2;ctx.beginPath();for(let i=0;i<d.length;i++){const x=(i/150)*c.width,y=c.height-((d[i]-min)/r)*160-10;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)}ctx.stroke()}}</script>
</body></html>
)rawliteral";

void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) {
  if (type == WStype_CONNECTED) clientCount++;
  else if (type == WStype_DISCONNECTED) clientCount = max(0, clientCount - 1);
}

void updateDisplay() {
  display.clearDisplay();
  display.setTextSize(1);
  
  display.setCursor(0, 0);
  display.print("PulseSensor.com");
  
  unsigned long secs = millis() / 1000;
  unsigned long mins = secs / 60;
  secs = secs % 60;
  display.setCursor(90, 0);
  if (mins < 10) display.print("0");
  display.print(mins);
  display.print(":");
  if (secs < 10) display.print("0");
  display.print(secs);
  
  display.setCursor(0, 12);
  display.print("Open: 192.168.4.1");
  display.setCursor(0, 24);
  display.print("Clients: ");
  display.print(clientCount);
  
  display.setCursor(0, 36);
  display.print("BPM: ");
  bool validBPM = BPM >= 40 && BPM <= 200 && millis() - lastBeatTime < 3000;
  if (validBPM) display.print(BPM);
  else display.print("--");
  
  for (int i = 0; i < WAVE_WIDTH - 1; i++) {
    int idx = (waveIndex + i) % WAVE_WIDTH;
    int idxNext = (waveIndex + i + 1) % WAVE_WIDTH;
    display.drawLine(64 + i, 52 + waveBuffer[idx], 64 + i + 1, 52 + waveBuffer[idxNext], SH110X_WHITE);
  }
  
  display.display();
}

void setup() {
  Serial.begin(115200);
  
  // OLED first
  Wire.begin(SDA_PIN, SCL_PIN);
  display.begin(0x3C, true);
  display.setTextColor(SH110X_WHITE);
  display.clearDisplay();
  display.setCursor(15, 20);
  display.println("PulseSensor.com");
  display.setCursor(30, 35);
  display.println("(Library)");
  display.display();
  delay(2000);
  
  // PulseSensor Library
  pulseSensor.analogInput(PULSE_PIN);
  pulseSensor.setThreshold(THRESHOLD);
  pulseSensor.begin();
  
  // WiFi
  WiFi.softAP(WIFI_NAME);
  server.on("/", []() { server.send_P(200, "text/html", DASHBOARD_HTML); });
  server.begin();
  webSocket.begin();
  webSocket.onEvent(onWebSocketEvent);
  
  analogReadResolution(12);
  for (int i = 0; i < WAVE_WIDTH; i++) waveBuffer[i] = 4;
  
  Serial.println("WiFi + OLED + Library Ready!");
}

void loop() {
  server.handleClient();
  webSocket.loop();
  
  int sensorValue = pulseSensor.getLatestSample();
  
  // Library handles beat detection
  beatDetected = false;
  if (pulseSensor.sawStartOfBeat()) {
    beatDetected = true;
    lastBeatTime = millis();
    BPM = pulseSensor.getBeatsPerMinute();
  }
  
  // Update OLED waveform buffer
  static int sensorHigh = 0, sensorLow = 4095;
  if (sensorValue > sensorHigh) sensorHigh = sensorValue;
  if (sensorValue < sensorLow) sensorLow = sensorValue;
  
  int waveY = map(sensorValue, sensorLow, sensorHigh, 8, 0);
  waveY = constrain(waveY, 0, 8);
  waveBuffer[waveIndex] = waveY;
  waveIndex = (waveIndex + 1) % WAVE_WIDTH;
  
  // Send to browsers
  String data = String(sensorValue) + "," + String(BPM) + "," + (beatDetected ? "1" : "0");
  webSocket.broadcastTXT(data);
  
  updateDisplay();
  
  delay(10);
}

✓ Success Looks Like

  • OLED: Shows IP address, client count, BPM, timer, and mini waveform
  • Phone browser: Full dashboard with large BPM and animated waveform
  • Both update simultaneously: Beat appears on OLED and phone at the same time
  • Client count: OLED shows "Clients: 1" when your phone connects

🎉 Congratulations! You've built a complete wireless pulse monitor!

⚠️ Troubleshooting

Problem Solution
OLED works but WiFi doesn't Power issue. OLED + WiFi draws more current. Use a good USB cable directly to computer (not hub).
WiFi works but OLED is blank I2C initialization failed. Check OLED wiring: SDA→D4, SCL→D5. Try swapping if reversed.
Everything starts then freezes Memory issue. Close Serial Plotter if open — it consumes resources.
Waveform choppy on phone WiFi interference. Move away from router or microwave. Try different location.
BPM different on OLED vs phone They use the same data — this shouldn't happen. Refresh the browser page.

🎓 What's Next?

You've mastered the fundamentals! Explore more PulseSensor projects:


Need Help?

Check out these resources: