Oximeter Monitor with OLED screen and MAX30105 sensor on NodeMCU

I made a device that uses an OLED screen, a NodeMCU microcontroller, and an MAX30105 sensor to display users' pulse and oxygen saturation.
Feb 14, 2024 — 11 mins read — Projects

Oximeter Monitor with OLED screen and MAX30105 sensor on NodeMCU

Measuring pulse is a very common thing nowadays as there are pulse meters in almost all smart watches and bands and some are going even further with additional measurements. During the recent pandemic, measuring blood oxygen saturation was made popular as an indication of the general health of people so I then purchased a sensor to try out, namely the MAX30105.


Device Properties

The MAX30105 sensor is an optical particle sensor but since it is very sensitive, it is commonly used to measure human heart rate as well as the oxygen saturation of the blood by tracking the color and illumination changes of the finger as blood passes through it.

The final device consists of a NodeMCU microcontroller, an SSD1306 OLED screen, and the MAX30105 sensor. Both the OLED and the MAX30105 sensor are connected to the I2C interface on the NodeMCU on pins D1 and D2. The OLED is powered from 3.3V while the MAX30105 sensor is powered from 5V instead for better stability, but it can also be powered from 3.3V.

This device is made for educational purposes only and it should not be used in any way to detect or diagnose medical issues.


Video explanation

You can see the full video for the oximeter device below.


Tools and materials used in the project


Arduino Code

The device is programmed using the Arduino IDE with the code below.

#include <MAX3010x.h>
#include <Adafruit_SSD1306.h>
#include "filters.h"

#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels

#define OLED_RESET     -1 // Reset pin # (or -1 if sharing Arduino reset pin)
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// Sensor (adjust to your sensor type)
MAX30105 sensor;
const auto kSamplingRate = sensor.SAMPLING_RATE_400SPS;
const float kSamplingFrequency = 400.0;

// Finger Detection Threshold and Cooldown
const unsigned long kFingerThreshold = 10000;
const unsigned int kFingerCooldownMs = 500;

// Edge Detection Threshold (decrease for MAX30100)
const float kEdgeThreshold = -2000.0;

// Filters
const float kLowPassCutoff = 5.0;
const float kHighPassCutoff = 0.5;

// Averaging
const bool kEnableAveraging = true;
const int kAveragingSamples = 5;
const int kSampleThreshold = 5;

void setup() {
  Serial.begin(9600);

  // SSD1306_SWITCHCAPVCC = generate display voltage from 3.3V internally
  if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { 
    Serial.println(F("SSD1306 allocation failed"));
    while (1);
  }

  if(sensor.begin() && sensor.setSamplingRate(kSamplingRate)) { 
    Serial.println("Sensor initialized");
  }
  else {
    Serial.println("Sensor not found");  
    while(1);
  }

  display.clearDisplay();
  initDrawScreen(); 
}

// Filter Instances
LowPassFilter low_pass_filter_red(kLowPassCutoff, kSamplingFrequency);
LowPassFilter low_pass_filter_ir(kLowPassCutoff, kSamplingFrequency);
HighPassFilter high_pass_filter(kHighPassCutoff, kSamplingFrequency);
Differentiator differentiator(kSamplingFrequency);
MovingAverageFilter<kAveragingSamples> averager_bpm;
MovingAverageFilter<kAveragingSamples> averager_r;
MovingAverageFilter<kAveragingSamples> averager_spo2;

// Statistic for pulse oximetry
MinMaxAvgStatistic stat_red;
MinMaxAvgStatistic stat_ir;

// R value to SpO2 calibration factors
// See https://www.maximintegrated.com/en/design/technical-documents/app-notes/6/6845.html
float kSpO2_A = 1.5958422;
float kSpO2_B = -34.6596622;
float kSpO2_C = 112.6898759;

// Timestamp of the last heartbeat
long last_heartbeat = 0;

// Timestamp for finger detection
long finger_timestamp = 0;
bool finger_detected = false;

// Last diff to detect zero crossing
float last_diff = NAN;
bool crossed = false;
long crossed_time = 0;

void loop() {
  auto sample = sensor.readSample(1000);
  float current_value_red = sample.red;
  float current_value_ir = sample.ir;
  
  // Detect Finger using raw sensor value
  if(sample.red > kFingerThreshold) {
    if(millis() - finger_timestamp > kFingerCooldownMs) {
      finger_detected = true;
    }
  }
  else {
    // Reset values if the finger is removed
    differentiator.reset();
    averager_bpm.reset();
    averager_r.reset();
    averager_spo2.reset();
    low_pass_filter_red.reset();
    low_pass_filter_ir.reset();
    high_pass_filter.reset();
    stat_red.reset();
    stat_ir.reset();
    
    finger_detected = false;
    finger_timestamp = millis();
  }

  if(finger_detected) {
    displayMeasuredValues(false, 0, 0);
    current_value_red = low_pass_filter_red.process(current_value_red);
    current_value_ir = low_pass_filter_ir.process(current_value_ir);

    // Statistics for pulse oximetry
    stat_red.process(current_value_red);
    stat_ir.process(current_value_ir);

    // Heart beat detection using value for red LED
    float current_value = high_pass_filter.process(current_value_red);
    float current_diff = differentiator.process(current_value);

    // Valid values?
    if(!isnan(current_diff) && !isnan(last_diff)) {
      
      // Detect Heartbeat - Zero-Crossing
      if(last_diff > 0 && current_diff < 0) {
        crossed = true;
        crossed_time = millis();
      }
      
      if(current_diff > 0) {
        crossed = false;
      }
  
      // Detect Heartbeat - Falling Edge Threshold
      if(crossed && current_diff < kEdgeThreshold) {
        if(last_heartbeat != 0 && crossed_time - last_heartbeat > 300) {
          // Show Results
          int bpm = 60000/(crossed_time - last_heartbeat);
          float rred = (stat_red.maximum()-stat_red.minimum())/stat_red.average();
          float rir = (stat_ir.maximum()-stat_ir.minimum())/stat_ir.average();
          float r = rred/rir;
          float spo2 = kSpO2_A * r * r + kSpO2_B * r + kSpO2_C;
          
          if(bpm > 50 && bpm < 250) {
            // Average?
            if(kEnableAveraging) {
              int average_bpm = averager_bpm.process(bpm);
              int average_r = averager_r.process(r);
              int average_spo2 = averager_spo2.process(spo2);
  
              // Show if enough samples have been collected
              if(averager_bpm.count() >= kSampleThreshold) {
                Serial.print("Time (ms): ");
                Serial.println(millis()); 
                Serial.print("Heart Rate (avg, bpm): ");
                Serial.println(average_bpm);
                Serial.print("R-Value (avg): ");
                Serial.println(average_r);  
                Serial.print("SpO2 (avg, %): ");
                Serial.println(average_spo2);  
                displayMeasuredValues(false, average_bpm, average_spo2);
              }
            }
            else {
              Serial.print("Time (ms): ");
              Serial.println(millis()); 
              Serial.print("Heart Rate (current, bpm): ");
              Serial.println(bpm);  
              Serial.print("R-Value (current): ");
              Serial.println(r);
              Serial.print("SpO2 (current, %): ");
              Serial.println(spo2);   
              displayMeasuredValues(false, bpm, spo2);
            }
          }

          // Reset statistic
          stat_red.reset();
          stat_ir.reset();
        }
  
        crossed = false;
        last_heartbeat = crossed_time;
      }
    }

    last_diff = current_diff;
  } else {
    displayMeasuredValues(true, 0, 0);
  }
}

void initDrawScreen(void) {
  display.clearDisplay();

  display.setTextSize(1);             // Normal 1:1 pixel scale
  display.setTextColor(WHITE);        // Draw white text
  display.setCursor(0,0);             // Start at top-left corner
  display.println(F("    Taste The Code"));
  display.println(F(""));
  display.setCursor(5, display.getCursorY());
  display.setTextSize(2);
  display.println(F("BPM  %SpO2"));
  display.display();
}
bool display_reset = true;
void displayMeasuredValues(bool no_finger, int32_t beatAvg, int32_t spo2) {
  display.setCursor(5,35);
  display.setTextColor(WHITE, BLACK);
  if(no_finger) {
    display.setTextSize(2);
    display.println(F("NO Finger            "));
    display_reset = true;
    display.display();
  } else if(beatAvg < 30 && display_reset) {
    display.setTextSize(2);
    display.println(F("Pls.  Wait             "));
    display_reset = false;
    display.display();
  } else if(beatAvg >= 30) {
    display.setTextSize(2);
    display.println(F("             "));
    display.setCursor(5,35);
    display.setTextSize(3);
    display.print(beatAvg);
    display.print(F(" "));
    if(spo2 >= 20 && spo2 <= 100) {
      display.print(spo2);
    } else {
      display.print(F("--"));
    }
    display.println(F("    "));
    display.display();
  }
}


The content of the filters.h file is as follows:

#ifndef FILTERS_H
#define FILTERS_H

/**
 * @brief Statistic block for min/nax/avg
 */
class MinMaxAvgStatistic {
 float min_;
 float max_;
 float sum_;
 int count_;
public:
 /**
  * @brief Initialize the Statistic block
  */
 MinMaxAvgStatistic() :
  min_(NAN),
  max_(NAN),
  sum_(0),
  count_(0){}

 /**
  * @brief Add value to the statistic
  */
 void process(float value) {  
  min_ = isnan(min_) ? value : min(min_, value);
  max_ = isnan(max_) ? value : max(max_, value);
  sum_ += value;
  count_++;
 }

 /**
  * @brief Resets the stored values
  */
 void reset() {
  min_ = NAN;
  max_ = NAN;
  sum_ = 0;
  count_ = 0;
 }

 /**
  * @brief Get Minimum
  * @return Minimum Value
  */
 float minimum() const {
  return min_;
 }

 /**
  * @brief Get Maximum
  * @return Maximum Value
  */
 float maximum() const {
  return max_;
 }

 /**
  * @brief Get Average
  * @return Average Value
  */
 float average() const {
  return sum_/count_;
 }
};

/**
 * @brief High Pass Filter 
 */
class HighPassFilter {
 const float kX;
 const float kA0;
 const float kA1;
 const float kB1;
 float last_filter_value_;
 float last_raw_value_;
public:
 /**
  * @brief Initialize the High Pass Filter
  * @param samples Number of samples until decay to 36.8 %
  * @remark Sample number is an RC time-constant equivalent
  */
 HighPassFilter(float samples) :
  kX(exp(-1/samples)),
  kA0((1+kX)/2),
  kA1(-kA0),
  kB1(kX),
  last_filter_value_(NAN),
  last_raw_value_(NAN){}

 /**
  * @brief Initialize the High Pass Filter
  * @param cutoff Cutoff frequency
  * @pram sampling_frequency Sampling frequency
  */
 HighPassFilter(float cutoff, float sampling_frequency) :
  HighPassFilter(sampling_frequency/(cutoff*2*PI)){}

 /**
  * @brief Applies the high pass filter
  */
 float process(float value) { 
  if(isnan(last_filter_value_) || isnan(last_raw_value_)) {
   last_filter_value_ = 0.0;
  }
  else {
   last_filter_value_ = 
    kA0 * value 
    + kA1 * last_raw_value_ 
    + kB1 * last_filter_value_;
  }
   
  last_raw_value_ = value;
  return last_filter_value_;
 }

 /**
  * @brief Resets the stored values
  */
 void reset() {
  last_raw_value_ = NAN;
  last_filter_value_ = NAN;
 }
};

/**
 * @brief Low Pass Filter 
 */
class LowPassFilter {
 const float kX;
 const float kA0;
 const float kB1;
 float last_value_;
public:
 /**
  * @brief Initialize the Low Pass Filter
  * @param samples Number of samples until decay to 36.8 %
  * @remark Sample number is an RC time-constant equivalent
  */
 LowPassFilter(float samples) :
  kX(exp(-1/samples)),
  kA0(1-kX),
  kB1(kX),
  last_value_(NAN){}

 /**
  * @brief Initialize the Low Pass Filter
  * @param cutoff Cutoff frequency
  * @pram sampling_frequency Sampling frequency
  */
 LowPassFilter(float cutoff, float sampling_frequency) :
  LowPassFilter(sampling_frequency/(cutoff*2*PI)){}

 /**
  * @brief Applies the low pass filter
  */
 float process(float value) {  
  if(isnan(last_value_)) {
   last_value_ = value;
  }
  else {  
   last_value_ = kA0 * value + kB1 * last_value_;
  }
  return last_value_;
 }

 /**
  * @brief Resets the stored values
  */
 void reset() {
  last_value_ = NAN;
 }
};

/**
 * @brief Differentiator
 */
class Differentiator {
 const float kSamplingFrequency;
 float last_value_;
public:
 /**
  * @brief Initializes the differentiator
  */
 Differentiator(float sampling_frequency) :
  kSamplingFrequency(sampling_frequency),
  last_value_(NAN){}

 /**
  * @brief Applies the differentiator
  */
 float process(float value) {  
   float diff = (value-last_value_)*kSamplingFrequency;
   last_value_ = value;
   return diff;
 }

 /**
  * @brief Resets the stored values
  */
 void reset() {
  last_value_ = NAN;
 }
};

/**
 * @brief MovingAverageFilter
 * @tparam buffer_size Number of samples to average over
 */
template<int kBufferSize> class MovingAverageFilter {
 int index_;
 int count_;
 float values_[kBufferSize];
public:
 /**
  * @brief Initalize moving average filter
  */
 MovingAverageFilter() :
  index_(0),
  count_(0){}

 /**
  * @brief Applies the moving average filter
  */
 float process(float value) {  
   // Add value
   values_[index_] = value;

   // Increase index and count
   index_ = (index_ + 1) % kBufferSize;
   if(count_ < kBufferSize) {
    count_++;  
   }

   // Calculate sum
   float sum = 0.0;
   for(int i = 0; i < count_; i++) {
     sum += values_[i];
   }

   // Calculate average
   return sum/count_;
 }

 /**
  * @brief Resets the stored values
  */
 void reset() {
  index_ = 0;
  count_ = 0;
 }

 /**
  * @brief Get number of samples
  * @return Number of stored samples
  */
 int count() const {
  return count_;
 }
};

#endif // FILTERS_H




pulse oled nodemcu
Read next

Making a Pellet Level Monitor with HC-SR04 and ESP8266

To heat my home, I use wood pellets and a big pellet boiler, that I need to top up every few days. This is usually not an issue but sometime...

You might also enojy this

MQ7 Carbon Monoxide Sensor done the right way!

There are a ton of tutorials on how to use the MQ7 Carbon Monoxide sensor with Arduino, Raspberry Pis, and other microcontrollers but they a...