AikyaNova · Product Deep-Dive

Pulse Oximeter Demo Board
with MAX30102

From the physiology of blood oxygenation to reading real SpO₂ and BPM on your microcontroller — all four example sketches included, ready to copy and run.

The AikyaNova Pulse Oximeter Demo Board — built around Maxim's MAX30102 — measures blood oxygen saturation (SpO₂) and heart rate without any analogue front-end design work. This post walks through the science layer by layer, then hands you four ready-to-run sketches that take you from raw ADC counts all the way to live SpO₂ on an OLED display.

AikyaNova™ Pulse Oximeter Demo Board · MAX30102 + OLED · ₹999 onwards
What you'll need
Hardware
  • AikyaNova Pulse Oximeter Demo Board (MAX30102 + OLED)
  • Arduino Uno / Nano or ESP32 DevKit
  • USB cable + 4 jumper wires
Software
  • Arduino IDE 2.x (free download)
  • Library: SparkFun MAX3010x
  • Libraries: Adafruit GFX + Adafruit SSD1306
Prior knowledge
  • Basic Arduino sketch structure (setup / loop)
  • How to install libraries via Library Manager
  • No biology background required — Module 01 starts from scratch

Module 01

Background Physiology

Blood Circulation & Vessel Types

The human body runs a closed-loop hydraulic system. The heart pumps oxygenated blood out through high-pressure arteries, which branch into microscopic capillaries where gas exchange actually happens, and returns via low-pressure veins. Pulse oximetry works entirely at the capillary layer — typically the fingertip or earlobe.

Blood Circulation
Fig 1. The human circulatory system.

Blood Composition

Blood is roughly 55% plasma (the liquid matrix that carries nutrients, hormones, and waste) and 45% formed elements (red blood cells, white blood cells, and platelets) — of which the critical player for oximetry is the red blood cell (RBC).

Blood composition
Fig 2. Blood composition and function.
Haemoglobin per RBC
~250M

molecules per red blood cell, each able to bind up to 4 O₂ molecules.

Normal SpO₂ range
95–100%

Oxygen saturation in healthy adults. Below 90% is clinically significant hypoxia.

Red Blood Cells

Each RBC is a biconcave disc optimised for gas exchange surface area and flexibility to squeeze through capillaries as narrow as 5 µm. Mature RBCs contain no nucleus, leaving maximum volume for haemoglobin.

RBCs
Fig 3. Red blood cells.

Haemoglobin: The Oxygen Carrier

Each haemoglobin molecule has four subunits (2 alpha + 2 beta chains), each holding a haem group with an iron (Fe²⁺) atom that binds one O₂. The binding is cooperative — when the first O₂ binds, the protein shifts shape, increasing affinity for the next. This produces the classic sigmoid oxygen-dissociation curve.

Haemoglobin structure
Fig 4. Haemoglobin 3D structure.

Haemoglobin's Optical Properties

HbO₂ and Hb absorb light very differently at two key wavelengths — this spectral difference is the entire physical foundation for how we measure oxygen saturation non-invasively using light — the core principle of pulse oximetry.

 Optical absorption spectra
Fig 5. Extinction coefficient spectra: at 660 nm (red) Hb absorbs much more; at 940 nm (IR) HbO₂ absorbs more.
Why blood changes colour

HbO₂ absorbs blue/green and reflects red — bright arterial red. Hb absorbs red more strongly, giving venous blood a darker red-purple appearance. This colour change is the physical basis for optical detection.


Module 02

The PPG Waveform

A Photoplethysmogram (PPG) is the signal you get when you shine light through a fingertip and measure how much the tissue absorbs. The MAX30102 uses reflectance mode — LEDs and photodetector on the same side. Absorption changes with arterial blood volume, and blood volume changes with every heartbeat, producing a periodic waveform.

PPG waveform
Fig 6. PPG waveform structure (left) and tissue layer decomposition (right). AC = pulsatile arterial; DC = venous blood + tissue.

AC and DC Components

The DC component is a slowly-varying baseline from venous blood, tissue, fat, and bone. The AC component is the pulsatile variation driven purely by arterial blood. SpO₂ uses the AC/DC ratio at two wavelengths — DC normalises for tissue differences.

AC DC PPG
Fig 7. AC and DC decomposition. Only the AC component varies beat-to-beat and is used for SpO₂ and BPM.
1

Systolic Upstroke

Ventricular contraction forces blood into arteries → rapid volume expansion → more light absorbed → AC signal rises sharply.

2

Systolic Peak

Maximum arterial expansion, corresponding to systolic pressure (~120 mmHg).

3

Dicrotic Notch

Small dip marks aortic valve closure. Arrives ~300–350 ms after the peak — the main source of doubled-BPM false detections on fair skin. Sketch 3 uses a 400 ms refractory period specifically to block it.

4

Diastolic Peak

A secondary, smaller bump that appears shortly after the dicrotic notch. It is caused by a pressure wave reflected back from the peripheral blood vessels (arterioles and capillary beds) towards the heart. As arterial walls recoil elastically during diastole, this reflected wave briefly increases blood volume at the fingertip again — producing a visible secondary rise in the PPG signal. The diastolic peak is less prominent in older individuals (stiffer arteries reflect less) and becomes more pronounced in younger, healthier vasculature. On the MAX30102, you can see it clearly in Sketch 2 as a small shoulder or hump on the descending slope.

5

Diastolic Decay

After the diastolic peak, the arterial walls continue their elastic recoil and the blood volume at the fingertip gradually returns to baseline. This slow decline continues until the next ventricular contraction triggers a new systolic upstroke.


Module 03

How SpO₂ is Calculated

The key insight is Beer-Lambert law: the amount of light absorbed by a solution is proportional to the concentration of the absorbing substance and the path length through it. In plain terms — the more haemoglobin molecules the light passes through, the dimmer the detector reads. We exploit this at two wavelengths where HbO₂ and Hb absorb very differently (see Fig 5), so the ratio of their absorptions reveals the oxygen saturation.

Oxyhaemoglobin (HbO₂)

~940 nm IR

HbO₂ absorbs IR strongly. More HbO₂ → more IR absorbed → lower IR detector reading.

Deoxyhaemoglobin (Hb)

~660 nm Red

Hb absorbs red strongly. More Hb → more red absorbed → lower red detector reading.

// Normalised pulsatile ratio per wavelength, then Ratio of Ratios:
R = ( ACRED / DCRED ) ÷ ( ACIR / DCIR )

// Empirical calibration curve (as used in Sketch 4):
SpO₂ ≈ 110.0 − 25.0 × R
Dual wavelength SpO2
Fig 8. Dual-wavelength pulse oximetry principle showing red and infrared light absorption used to estimate oxygen saturation (SaO₂).
SaO₂ vs SpO₂

SaO₂ is the gold-standard arterial blood-gas measurement (invasive). SpO₂ is the non-invasive peripheral estimate from a pulse oximeter. In healthy tissue with good perfusion they agree within ±2%. The constants 110.0 − 25.0×R are a first-order approximation — accuracy is ±2–4% for a demo board, not for clinical use.

R Ratio
Fig 9. PPG measurement using dual wavelengths and the ratio-of-ratios (R) method for estimating oxygen saturation (SaO₂).

Module 04

Inside the MAX30102

The MAX30102 integrates a complete pulse oximeter front-end into a 5.6 × 3.3 mm LGA package: two LEDs, a photodetector, optical elements, 18-bit ADC, low-noise analogue front-end, and ambient-light rejection — all over I²C at address 0x57.

MAX30102 overview
Fig 10. Overview of the MAX30102 integrated pulse oximeter and heart-rate sensor, highlighting its internal components.

MAX30102 Key Specifications

LED wavelengths660 nm Red + 880 nm IR
ADC resolution18-bit (262,144 counts full scale)
Sample rate50 – 3200 samples/sec
I²C address0x57 (fixed)
Supply voltage1.8 V core + 3.3 V LEDs
LED current range0 – 50 mA (8-bit control, 0–255)
FIFO depth32 samples × 6 bytes — FIFO (First-In First-Out) is an on-chip buffer; the sensor stores readings here so your microcontroller can fetch them in batches rather than polling constantly
Package14-pin optical LGA (Land Grid Array), 5.6 × 3.3 mm

Key setup() Parameters

Parameter Typical Value What it does
ledBrightness 0–255 (0 = off, 255 ≈ 50mA) LED current 0–50 mA. Auto-scaled in Sketches 3 & 4 for skin tone compensation.
sampleAverage 1, 2, 4, 8, 16, 32 ADC samples averaged per FIFO entry. Higher = smoother output, lower throughput.
ledMode 1, 2, 3 1 = Red only | 2 = Red + IR | 3 = Red + IR + Green(MAX30105 only).
sampleRate 50, 100, 200, 400, 800, 1000, 1600, 3200 Raw ADC samples/sec before averaging.
pulseWidth 69 = 15-bit | 118 = 16-bit | 215 = 17-bit | 411 = 18-bit ADC resolution LED on-time in µs. Longer = more light collected = better SNR (Signal-to-Noise Ratio — how clean the signal is vs background noise).
adcRange 2048, 4096, 8192, 16384 Full-scale ADC range. Smaller = more sensitive; larger = wider range. 4096 = 15.63 pA/LSB, good general purpose.

Module 05

The AikyaNova Demo Board

Our demo board breaks out the MAX30102 in a pre-soldered, ready-to-use form factor alongside a 0.96″ OLED display. The chip's LGA package is notoriously difficult to hand-solder — one cold joint on the optical window voids the measurement entirely. The demo board eliminates this: connect four wires and start reading data in minutes.

AikyaNova Pulse Oximeter Demo Board — showing MAX30102 sensor module, 0.96 OLED display with BPM 68 and SpO2 99%, and wiring labels for ESP32 and Arduino
AikyaNova™ Pulse Oximeter Demo Board  ·  MAX30102 sensor + 0.96″ OLED  ·  Arduino & ESP32 compatible Pre-Soldered · Ready to Use
  • Pre-soldered MAX30102 — no SMD work needed
  • 0.96″ OLED shows live BPM + SpO₂ + ❤ pulse indicator
  • Arduino & ESP32 compatible (auto I²C pin detection)
  • Independent headers for P3 (I²C) and P5 (advanced pins)
  • Advanced breakout: GND, RD, IRD, INT pins on P5
  • Decoupling caps per Maxim reference design
Get the Board
Board only ₹999 · With ESP32 ₹2,199 · Full Kit ₹2,499 · Free shipping on orders above ₹500

Wiring Guide

ESP32
VIN3.3 V
GNDGND
SDAGPIO 21
SCLGPIO 22
Arduino Uno / Nano
VIN5 V
GNDGND
SDAA4
SCLA5
Library dependencies

All four sketches require: SparkFun MAX3010x (Library Manager), Adafruit GFX, and Adafruit SSD1306. Install all three before uploading. The sketches auto-detect ESP32 / ESP8266 / AVR I²C pins via #if defined(ESP32).

Full source code and README on GitHub: AikyaNova Labs Embedded Systems → MAX_30102 →


Sketch 1 of 4

Raw IR & Red Values

Start here. Reads raw 18-bit ADC counts from both LEDs and displays them on the OLED and Serial Monitor. Use it to verify hardware connectivity before any other sketch. When a finger is placed you should see IR values jump from near-zero to 50,000–250,000+.

Max3010x_Raw_Values.ino  ·  Sketch 1 / 4
Arduino · I²C · OLED
 /*
 * ------------------------------------------------------------------------
 * AikyaNova Labs Embedded Systems
 * ------------------------------------------------------------------------
 * Developed by: AikyaNova Labs
 * Firmware: This firmware reads raw Photoplethysmography (PPG) Infrared (IR) and Red
 *           light data using the MAX3010x optical sensor and displays the numerical
 *           values in real-time on a 0.96" OLED display and the Serial Monitor.
 *           This is Sketch 1 of 4 in the AikyaNova Pulse Oximeter series.
 *           Use this sketch first to verify your hardware is connected correctly
 *           before moving on to the plotting and BPM sketches.
 * Copyright (c) 2025 AikyaNova\u2122
 * Licensed under the AikyaNova Non-Commercial License.
 * * Dependencies & Credits:
 * - SparkFun MAX3010x Sensor Library (BSD License)
 * - Adafruit GFX and SSD1306 Libraries by Adafruit Industries (BSD License)
 * ------------------------------------------------------------------------
 */

#include <Wire.h>             // I2C communication library (used by both sensor and OLED)
#include "MAX30105.h"         // SparkFun MAX3010x optical sensor library
#include <Adafruit_GFX.h>     // Core graphics library for the OLED display
#include <Adafruit_SSD1306.h> // SSD1306 OLED display driver

// ========================================================================
// HARDWARE & DISPLAY SETTINGS
// ========================================================================
#define SCREEN_WIDTH   128  // OLED display width, in pixels
#define SCREEN_HEIGHT  64   // OLED display height, in pixels
#define OLED_RESET     -1   // -1 = OLED RST pin not wired to a GPIO; reset handled by hardware power-on
#define SCREEN_ADDRESS 0x3C // Default I2C address for most 0.96" SSD1306 OLEDs; change to 0x3D if not found

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// ========================================================================
// SENSOR OBJECT
// ========================================================================
MAX30105 particleSensor; // MAX30105 is fully compatible with the MAX30102 sensor

// ========================================================================
// SCROLL TEXT HELPER
// ========================================================================
// Scrolls a single line of text right-to-left across the vertical centre of
// the display. Uses textSize 2 (12px wide \u00d7 16px tall per character).
// setTextWrap(false) prevents the text from breaking onto a second line.
//
// Parameters:
//   text        \u2014 the string to scroll
//   numPasses   \u2014 how many times the full string crosses the screen
//   drainSensor \u2014 set true ONLY after particleSensor.begin() has been called.
//                 When true, the IR value is sampled every animation frame.
//                 If a finger is detected (IR >= 15000) the scroll aborts
//                 immediately and returns true, so the caller can react at once.
//
// Returns: true if aborted by finger detection, false if completed normally.
bool scrollText(const char* text, int numPasses, bool drainSensor = false) {
  int charPxWidth = 6 * 2;                     // 12px per character at textSize 2
  int textPxWidth = strlen(text) * charPxWidth;
  int yPos        = (SCREEN_HEIGHT - 16) / 2;  // vertically centred (16px = textSize 2 height)

  for (int pass = 0; pass < numPasses; pass++) {
    // x starts at the right edge and moves left until the text is fully off-screen
    for (int x = SCREEN_WIDTH; x > -textPxWidth; x -= 3) {
      display.clearDisplay();
      display.setTextWrap(false);   // prevent text wrapping to the next line
      display.setTextSize(2);
      display.setTextColor(SSD1306_WHITE);
      display.setCursor(x, yPos);
      display.print(text);
      display.display();
      if (drainSensor && particleSensor.getIR() >= 15000) return true; // finger detected \u2014 abort immediately
      delay(8);
    }
  }
  return false;
}

// ========================================================================
// SETUP
// ========================================================================
void setup() {
  Serial.begin(115200); // Open Serial at 115200 baud \u2014 match this in Tools > Serial Monitor

  delay(1000); // Allow the Serial Monitor time to connect before printing

  // --- CROSS-PLATFORM I2C INITIALIZATION ---
  // The ESP32 and ESP8266 use different GPIO pins for I2C than standard Arduino boards.
  // This block detects which board is being compiled for and sets the correct pins.
#if defined(ESP32)
  Wire.begin(21, 22); // ESP32: SDA = GPIO 21, SCL = GPIO 22
#elif defined(ESP8266)
  Wire.begin(4, 5);   // ESP8266: SDA = D2 (GPIO 4), SCL = D1 (GPIO 5)
#else
  Wire.begin();       // Arduino Uno / Nano / Mega: uses default hardware I2C pins (A4/A5)
#endif

  // --- OLED DISPLAY INITIALIZATION ---
  // SSD1306_SWITCHCAPVCC tells the library to generate the display voltage internally.
  // If begin() returns false, the I2C address is wrong or wiring is incorrect.
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 allocation failed. Check wiring."));
    while (1) delay(10); // Halt \u2014 cannot continue without a display
  }

  // --- WELCOME SCREEN ---
  // Scrolls the product name once across the display before sensor init.
  // drainSensor=false because particleSensor.begin() has not been called yet.
  scrollText("Welcome to AikyaNova Pulse Oximeter Demo Board", 1);

  // --- SENSOR INITIALIZATION ---
  // I2C_SPEED_FAST runs the bus at 400 kHz instead of the default 100 kHz.
  // This is required to read sensor data fast enough without falling behind.
  Serial.println(F("Initializing MAX30102..."));
  if (!particleSensor.begin(Wire, I2C_SPEED_FAST)) {
    Serial.println(F("MAX30102 not found. Check wiring / power / I2C address."));
    display.clearDisplay();
    display.setTextSize(2);
    display.setTextColor(SSD1306_WHITE);
    display.setCursor(0, 10);
    display.println(F("Sensor Error!"));
    display.display();
    while (1) delay(10); // Halt \u2014 cannot continue without a sensor
  }
  Serial.println(F("Sensor found!"));

  // --- SENSOR CONFIGURATION ---
  // sensor.setup(ledBrightness, sampleAverage, ledMode, sampleRate, pulseWidth, adcRange)
  //
  // ledBrightness : LED current 0\u2013255 (0 = off, 255 \u2248 50mA). 60 \u2248 12mA \u2014 good starting point.
  // sampleAverage : How many raw ADC samples are averaged into one FIFO entry.
  //                 Higher = smoother signal, lower temporal resolution.
  //                 Valid: 1, 2, 4, 8, 16, 32
  // ledMode       : 1 = Red only | 2 = Red + IR | 3 = Red + IR + Green (MAX30105 only)
  // sampleRate    : ADC samples per second before averaging.
  //                 Valid: 50, 100, 200, 400, 800, 1000, 1600, 3200
  //                 Effective output rate = sampleRate / sampleAverage
  // pulseWidth    : LED on-time in microseconds. Longer = more light collected = better SNR.
  //                 69 = 15-bit | 118 = 16-bit | 215 = 17-bit | 411 = 18-bit ADC resolution
  // adcRange      : Full-scale ADC range. Smaller = more sensitive; larger = wider range.
  //                 Valid: 2048, 4096, 8192, 16384
  byte ledBrightness = 60;
  byte sampleAverage = 4;
  byte ledMode       = 2;   // Red + IR
  int  sampleRate    = 100;
  int  pulseWidth    = 411; // 18-bit ADC resolution \u2014 best signal quality
  int  adcRange      = 4096;

  particleSensor.setup(ledBrightness, sampleAverage, ledMode, sampleRate, pulseWidth, adcRange);
  Serial.println(F("Setup done. Waiting for finger..."));

  // --- WAIT UNTIL FINGER IS PLACED ---
  // An IR reading above 15,000 reliably indicates a finger is covering the sensor.
  // The scrollText call animates the OLED while waiting and aborts the moment
  // a finger is detected, so there is no delay between placement and first reading.
  while (particleSensor.getIR() < 15000) {
    if (scrollText("Place your finger on sensor", 1, true)) break; // finger detected mid-scroll \u2014 exit immediately
  }

  Serial.println(F("Finger detected. Starting readings."));
}

// ========================================================================
// MAIN LOOP
// ========================================================================
// What to observe:
//   IR  value \u2014 rises significantly when a finger is placed (typically 50,000\u2013250,000).
//               You can also see a small pulsatile variation with each heartbeat.
//   RED value \u2014 similar to IR but lower in amplitude. The ratio RED/IR is what the
//               SpO2 sketch (Sketch 4) uses to estimate blood oxygen saturation.
void loop() {
  long irValue  = particleSensor.getIR();
  long redValue = particleSensor.getRed();

  // --- FINGER REMOVED CHECK ---
  // If IR drops below 15,000 the sensor is reading open air \u2014 display prompt and wait.
  // Scrolling continues until the finger is placed again; returns to loop() immediately after.
  if (irValue < 15000) {
    Serial.println(F("No finger detected."));
    while (true) {
      if (scrollText("Place your finger on sensor", 1, true)) break; // finger detected mid-scroll
      if (particleSensor.getIR() >= 15000) break;                    // finger detected between passes
    }
    return; // return to loop() \u2014 next iteration will immediately read valid IR/RED values
  }

  // --- SERIAL MONITOR OUTPUT ---
  // Open Tools > Serial Monitor @ 115200 baud to view these values.
  // IR and RED are raw 18-bit ADC counts (0\u2013262143 at adcRange=4096).
  Serial.print(F("IR="));
  Serial.print(irValue);
  Serial.print(F("\  RED="));
  Serial.println(redValue);

  // --- OLED DISPLAY ---
  // Shows both raw values in real time. textSize 2 = 12px wide \u00d7 16px tall per character.
  display.clearDisplay();
  display.setTextSize(2);
  display.setTextColor(SSD1306_WHITE);

  // IR value \u2014 top half of screen
  display.setCursor(0, 0);
  display.print(F("IR: "));
  display.print(irValue);

  // RED value \u2014 bottom half of screen
  display.setCursor(0, 36);
  display.print(F("RED:"));
  display.print(redValue);

  display.display();

  delay(20); // ~50 Hz refresh rate \u2014 fast enough to see beat-to-beat variation
}
Serial Monitor · 115200 baud · Expected Output
Initializing MAX30102...
Sensor found!
Setup done. Waiting for finger...
[ Place finger on sensor ]
Finger detected. Starting readings.
IR=24418  RED=39052
IR=68930  RED=93715
IR=145588  RED=113633
IR=175522  RED=119939
IR=181158  RED=120143
IR=182656  RED=120795
IR=183598  RED=122456
IR=184231  RED=124639
IR=186118  RED=125891
IR=188465  RED=123945
No finger detected.
↑ small pulsatile variation visible beat-to-beat
Code 1 0.96 inch OLED Display
Fig 11. Code 1 output on OLED display showing IR and RED LED values.
What to look for

IR above ~50,000 confirms a finger is detected. Small oscillations (hundreds to thousands of counts) visible with each heartbeat confirm the pulsatile signal is working.

If IR stays near zero (no finger placed): that is normal — the sensor is waiting. If IR stays near zero even with a finger: check that SDA and SCL wires aren't swapped. The AikyaNova board includes on-board I²C pull-up resistors, so no external resistors are needed.

If you see "Sensor not found" in Serial Monitor: double-check your power wiring — ESP32 uses 3.3 V, Arduino Uno uses 5 V on the VIN pin.


Sketch 2 of 4

Raw PPG Plot (Serial Plotter)

Removes the DC baseline with an exponential moving average filter and outputs the AC pulsatile component to the Arduino Serial Plotter. Use this to visually confirm waveform shape and signal quality before trusting any numerical output from Sketches 3 or 4. A healthy signal should look like the PPG diagrams in Module 02 above.

How to open the Serial Plotter

In Arduino IDE 2.x: upload the sketch, then go to Tools → Serial Plotter (or press Ctrl+Shift+L on Windows / ⌘+Shift+L on Mac). Make sure the baud rate in the dropdown matches the sketch (115200). Place your finger on the sensor — you should see a smooth wave appear within 2–3 seconds.

Max3010x_Raw_Plot.ino  ·  Sketch 2 / 4
Serial Plotter · DC removal · EMA filter
 /*
 * ------------------------------------------------------------------------
 * AikyaNova Labs Embedded Systems
 * ------------------------------------------------------------------------
 * Developed by: AikyaNova Labs
 * Firmware: Plots the AC (pulsatile) component of the raw PPG IR signal
 *           from the MAX3010x sensor to the Arduino Serial Plotter in
 *           real time. DC baseline removal is applied so the waveform is
 *           centred at zero and each heartbeat appears as an upward peak.
 *           This is Sketch 2 of 4 in the AikyaNova Pulse Oximeter series.
 *           Use this sketch to visually verify signal quality and waveform
 *           shape before moving on to the BPM and SpO2 sketches.
 *           Open Tools > Serial Plotter @ 115200 baud to view the waveform.
 * Copyright (c) 2025 AikyaNova\u2122
 * Licensed under the AikyaNova Non-Commercial License.
 * * Dependencies & Credits:
 * - SparkFun MAX3010x Sensor Library (BSD License)
 * - Adafruit GFX and SSD1306 Libraries by Adafruit Industries (BSD License)
 * ------------------------------------------------------------------------
 */

#include <Wire.h>             // I2C communication library
#include "MAX30105.h"         // SparkFun MAX3010x optical sensor library
#include <Adafruit_GFX.h>     // Core graphics library for OLED display
#include <Adafruit_SSD1306.h> // SSD1306 OLED display driver

// ========================================================================
// OLED DISPLAY SETTINGS
// ========================================================================
#define SCREEN_WIDTH   128 // OLED display width, in pixels
#define SCREEN_HEIGHT  64 // OLED display height, in pixels
#define OLED_RESET     -1    // -1 = OLED RST pin not wired to a GPIO; reset handled by hardware power-on
#define SCREEN_ADDRESS 0x3C  // Default I2C address for most 0.96" SSD1306 OLEDs; change to 0x3D if display not found

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); // Create an instance of the SSD1306 display class

// ========================================================================
// SENSOR & SIGNAL VARIABLES
// ========================================================================
MAX30105 sensor;  // Create an instance of the MAX30105 sensor class

// Variable to store the DC (baseline) component of the IR signal
// Using int32_t to accommodate the large 18-bit values from the sensor
int32_t dcIR = 0;

// ========================================================================
// SCROLL TEXT HELPER
// ========================================================================
// Scrolls a single line of text right-to-left across the vertical centre of
// the display. Uses textSize 2 (12px wide \u00d7 16px tall per character).
// setTextWrap(false) prevents the text from breaking onto a second line.
//
// Parameters:
//   text        \u2014 the string to scroll
//   numPasses   \u2014 how many times the full string crosses the screen
//   drainSensor \u2014 set true ONLY after sensor.begin() has been called.
//                 When true, the IR value is sampled every animation frame.
//                 If a finger is detected (IR >= 15000) the scroll aborts
//                 immediately and returns true, so the caller can react at once.
//
// Returns: true if aborted by finger detection, false if completed normally.
bool scrollText(const char* text, int numPasses, bool drainSensor = false) {
  int charPxWidth = 6 * 2;                     // 12px per character at textSize 2
  int textPxWidth = strlen(text) * charPxWidth;
  int yPos        = (SCREEN_HEIGHT - 16) / 2;  // vertically centred (16px = textSize 2 height)

  for (int pass = 0; pass < numPasses; pass++) {
    // x starts at the right edge and moves left until the text is fully off-screen
    for (int x = SCREEN_WIDTH; x > -textPxWidth; x -= 3) {
      display.clearDisplay();
      display.setTextWrap(false);
      display.setTextSize(2);
      display.setTextColor(SSD1306_WHITE);
      display.setCursor(x, yPos);
      display.print(text);
      display.display();
      if (drainSensor && sensor.getIR() >= 15000) return true; // finger detected \u2014 abort immediately
      delay(8);
    }
  }
  return false;
}

// ========================================================================
// SETUP
// ========================================================================
void setup() {
  Serial.begin(115200);

  // --- CROSS-PLATFORM I2C INITIALIZATION ---
  // Detects the board type and starts the I2C bus accordingly
#if defined(ESP32)
  Wire.begin(21, 22); // ESP32 standard I2C pins (SDA=21, SCL=22)
#elif defined(ESP8266)
  Wire.begin(4, 5);   // ESP8266 standard I2C pins (SDA=D2/4, SCL=D1/5)
#else
  Wire.begin();       // Standard Arduino (Uno, Nano, Mega) uses default hardware pins
#endif

  // --- OLED DISPLAY INITIALIZATION ---
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 allocation failed. Check wiring."));
    while (1) delay(10);
  }

  // --- WELCOME SCREEN ---
  // Scrolls the product name once across the display before sensor init.
  // drainSensor=false because sensor.begin() has not been called yet.
  scrollText("Welcome to AikyaNova Pulse Oximeter Demo Board", 1);

  // --- SENSOR INITIALIZATION ---
  // Attempt to communicate with the sensor at 400kHz (Fast I2C speed).
  if (!sensor.begin(Wire, I2C_SPEED_FAST)) {
    Serial.println(F("Sensor not found. Check wiring!"));
    display.clearDisplay();
    display.setTextWrap(true);
    display.setTextSize(2);
    display.setTextColor(SSD1306_WHITE);
    display.setCursor(0, 10);
    display.println(F("Sensor Error!"));
    display.display();
    while (1) delay(10);
  }

  // --- SENSOR CONFIGURATION ---
  //
  // Argument 1: powerLevel (LED Pulse Amplitude Configuration)
  //   Possible values: 0x00 (0mA) to 0xFF (50mA).
  //   Examples: 0x02 (~0.4mA), 0x1F (~6.4mA), 0x7F (~25.4mA), 0xFF (~50mA).
  //
  // Argument 2: sampleAverage (Number of samples averaged per FIFO reading)
  //   Possible values: 1, 2, 4, 8, 16, 32
  //
  // Argument 3: ledMode (Which LEDs to use)
  //   Possible values:
  //   1 = Red only
  //   2 = Red + IR (SpO2/Heart rate mode)
  //
  // Argument 4: sampleRate (Samples taken per second)
  //   Possible values: 50, 100, 200, 400, 800, 1000, 1600, 3200
  //
  // Argument 5: pulseWidth (LED on-time in microseconds)
  //   Possible values:
  //   69  (15-bit ADC resolution)
  //   118 (16-bit ADC resolution)
  //   215 (17-bit ADC resolution)
  //   411 (18-bit ADC resolution - best SNR)
  //
  // Argument 6: adcRange (Full scale ADC range)
  //   Possible values:
  //   2048  (7.81pA per LSB - Most sensitive)
  //   4096  (15.63pA per LSB)
  //   8192  (31.25pA per LSB)
  //   16384 (62.5pA per LSB - Least sensitive/Largest range)
  // sampleRate=800, sampleAverage=8 \u2192 effective output = 800/8 = 100 samples/sec.
  // At 100 Hz the Serial Plotter's 500-point window covers exactly 5 seconds,
  // showing ~5-6 heartbeat pulses at a normal resting rate of 60-70 BPM.
  sensor.setup(0x00, 8, 2, 800, 411, 4096);

  // Overwrite the initial power level (0) with specific amplitudes.
  // Amplitude values range from 0x00 (0mA) to 0xFF (50mA).
  sensor.setPulseAmplitudeIR(40);  // Set IR LED brightness to ~7.8mA
  sensor.setPulseAmplitudeRed(15); // Set Red LED brightness to ~2.9mA

  // --- WAIT UNTIL FINGER IS PLACED ---
  // IR reading above 15,000 reliably indicates a finger is on the sensor.
  // scrollText aborts mid-animation the moment a finger is detected.
  Serial.println(F("Waiting for finger..."));
  while (sensor.getIR() < 15000) {
    if (scrollText("Place your finger on sensor", 1, true)) break; // finger detected mid-scroll \u2014 exit immediately
  }

  // Allow the sensor to stabilize after finger placement, then seed the DC baseline.
  // Drain any stale FIFO samples accumulated during the scroll animation before seeding.
  delay(200);
  sensor.check();
  while (sensor.available()) sensor.nextSample(); // flush stale FIFO samples
  dcIR = sensor.getIR();

  // Show a brief "Plotting..." confirmation on the OLED before handing off to Serial Plotter
  display.clearDisplay();
  display.setTextWrap(false);
  display.setTextSize(2);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(10, (SCREEN_HEIGHT - 16) / 2);
  display.print("Plotting... check Serial Plotter");
  display.display();
  delay(800);

  Serial.println(F("Finger detected. Plotting started."));
}

// ========================================================================
// MAIN LOOP
// ========================================================================
void loop() {
  // Wait for exactly one new sample from the FIFO before proceeding.
  // This locks the output rate to the sensor's effective sample rate (100 Hz),
  // eliminating FIFO backlog bursts that caused pulses to appear stretched.
  sensor.check();
  if (!sensor.available()) return; // no new sample yet \u2014 try again next call

  int32_t ir = sensor.getIR(); // read current sample
  sensor.nextSample();         // advance FIFO pointer so the next call gets the next sample

  // ---------------------------------------------------------
  // No finger: show scroll prompt on OLED, output 0 to Serial
  // Plotter so the trace stays flat and readable.
  // Abort as soon as the finger is placed again.
  // ---------------------------------------------------------
  if (ir < 15000) {
    Serial.println(0); // flat line on Serial Plotter while waiting
    while (true) {
      if (scrollText("Place your finger on sensor", 1, true)) break; // finger detected mid-scroll
      if (sensor.getIR() >= 15000) break;                            // finger detected between passes
    }
    // Flush stale FIFO samples and re-seed DC baseline cleanly
    delay(200);
    sensor.check();
    while (sensor.available()) sensor.nextSample();
    dcIR = sensor.getIR();

    // Brief confirmation before resuming plot
    display.clearDisplay();
    display.setTextWrap(false);
    display.setTextSize(2);
    display.setTextColor(SSD1306_WHITE);
    display.setCursor(10, (SCREEN_HEIGHT - 16) / 2);
    display.print("Plotting... check Serial Plotter");
    display.display();
    delay(800);
    return;
  }

  // ---------------------------------------------------------
  // Finger present: extract AC component and send to plotter
  // ---------------------------------------------------------

  // Low-Pass Filter (Exponential Moving Average) to track the DC baseline.
  // Bit-shifting right by 4 (>> 4) is a highly efficient way to divide by 16.
  // This smoothly updates the baseline dcIR towards the current ir value.
  // (Using >> 4 instead of >> 6 makes it adapt to baseline changes much faster).
  dcIR = dcIR + ((ir - dcIR) >> 4);

  // Extract the AC component (the actual heartbeat pulse).
  // During a heartbeat, more blood is in the finger, which absorbs MORE light.
  // Therefore, the raw 'ir' value drops. Subtracting 'ir' from 'dcIR' inverts
  // the signal so that heartbeat pulses appear as upward-pointing peaks.
  int32_t ac = dcIR - ir;

  // Print the AC value to the Serial Plotter.
  // Dividing by 8 scales down the amplitude so it fits nicely on the screen
  // without clipping or excessive noise (assumes adcRange=4096).
  Serial.println(ac / 8);
  // No delay needed \u2014 timing is governed by sensor.available() above (100 Hz)
}

Serial Plotter Output:

Code 2 Serial Plotter
Code 2 OLED Display
Fig 12. Pulse Waveform Output The top pane displays the full AC pulsatile waveform running on the Serial Plotter. The inset beside shows "Plotting..." reading on the 0.96-inch OLED display.
DC removal explained — step by step

dcIR = dcIR + ((ir - dcIR) >> 4) is an EMA (Exponential Moving Average) filter. The >> 4 is a right bitshift by 4 places — a fast way to divide by 16 (2⁴). So the formula is: new DC = old DC + (latest sample − old DC) / 16. This slowly tracks the large, steady background (venous blood + tissue) while ignoring the fast heartbeat pulses.

Subtracting raw ir from dcIR inverts the signal so heartbeat peaks point upward on the plotter — because more blood = more light absorbed = lower raw ADC reading, so dcIR − ir is largest at a peak. Dividing by 8 just scales the amplitude to a readable range on screen.


Sketch 3 of 4

Heart Rate (BPM) with Auto-LED Brightness

Adds peak detection and dynamic threshold to measure BPM, plus auto-LED brightness scaling to compensate for melanin levels. Fair skin can saturate the ADC; dark skin may under-illuminate it. The control loop adjusts LED current every 50 ms to keep IR readings in the 60,000–180,000 optimal range.

Max3010x_BPM.ino  ·  Sketch 3 / 4
Peak detection · Auto-LED · Melanin compensation
 /*
 * ------------------------------------------------------------------------
 * AikyaNova Labs Embedded Systems
 * ------------------------------------------------------------------------
 * Developed by: AikyaNova Labs
 * Firmware: Reads raw IR data from the MAX30105/102 sensor, applies a
 *           dynamic threshold peak-detection algorithm to measure heart
 *           rate, and automatically scales LED brightness to compensate
 *           for different skin tones (melanin levels). Displays live BPM
 *           on the OLED and outputs the PPG waveform to the Serial Plotter.
 *           This is Sketch 3 of 4 in the AikyaNova Pulse Oximeter series.
 * Copyright (c) 2025 AikyaNova\u2122
 * Licensed under the AikyaNova Non-Commercial License.
 * * Dependencies & Credits:
 * - SparkFun MAX3010x Sensor Library (BSD License)
 * - PBA & SpO2 Algorithms by Maxim Integrated Products (MIT-style License)
 * - Adafruit GFX and SSD1306 Libraries by Adafruit Industries (BSD License)
 * ------------------------------------------------------------------------
 */

#include <Wire.h>
#include "MAX30105.h"
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// ========================================================================
// HARDWARE & DISPLAY SETTINGS
// ========================================================================
#define SCREEN_WIDTH   128
#define SCREEN_HEIGHT  64
#define OLED_RESET     -1     // -1 = OLED RST pin not wired to a GPIO; reset handled by hardware power-on
#define SCREEN_ADDRESS 0x3C   // Default I2C address for most 0.96" SSD1306 OLEDs; change to 0x3D if not found

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

MAX30105 sensor;

// ========================================================================
// AUTO-LED BRIGHTNESS CONTROL VARIABLES
// ========================================================================
// currentPower controls the current injected into the IR LED (0-255).
// 40 is a safe starting baseline (~8mA) that prevents blinding the ADC initially.
byte currentPower = 40;
unsigned long lastAdjustTime = 0;  // Timer to prevent adjusting power too rapidly

// ========================================================================
// SIGNAL PROCESSING & BPM VARIABLES
// ========================================================================
int32_t dcIR = 0;                  // Tracks the slow-moving DC baseline (tissue/venous blood)
const int SHIFT_DC = 4;            // Bitshift for DC filter (equivalent to division by 16 for speed)

int32_t prev = 0;                  // Previous AC signal data point (t-1)
int32_t prev2 = 0;                 // Previous AC signal data point (t-2)

uint32_t lastBeatMs = 0;           // Timestamp of the last detected pulse
uint8_t  beatCount  = 0;           // Counts confirmed beats; BPM is not calculated until the 2nd beat.
                                   // This prevents the first interval (lastBeatMs set arbitrarily in
                                   // setup/finger-return, not at a real beat) from seeding a wrong BPM.
float bpm = 0;                     // Calculated Beats Per Minute

float env = 0;                     // Envelope tracker (calculates the amplitude of the pulse wave)
const float envAlpha = 0.95;       // Smoothing factor for the envelope (0.95 = 95% past, 5% new)

// REFRACTORY_MS is the minimum time allowed between valid beats.
// 400ms limits the maximum detectable heart rate to 150 BPM, which is sufficient for
// a resting/light-activity consumer device. Increased from 300ms to 400ms specifically
// to block the dicrotic notch on fair skin tones \u2014 the notch typically arrives
// 300-350ms after the main systolic peak and was being counted as a second beat,
// causing doubled BPM readings (~140 BPM instead of ~70 BPM) on fair skin.
const uint32_t REFRACTORY_MS = 400;

uint32_t heartBeatTimer = 0;       // Timer to control how long the OLED heart icon stays visible
uint32_t lastBeatDetectedMs = 0;   // Timestamp of last confirmed beat, used for stale BPM timeout
const uint32_t BPM_TIMEOUT_MS = 3000; // Reset BPM display if no beat detected for 3 seconds

bool warmupDone = false;           // Flag to skip finger-detection check during LED auto-brightness warmup
uint32_t warmupStart = 0;          // Timestamp when finger was first placed
const uint32_t WARMUP_MS = 500;    // Allow 500ms for auto-brightness to stabilize before applying finger threshold

// Signal quality gate: the envelope (average AC amplitude after /8 scaling) must exceed
// this value before any beat is accepted. Below it the signal is too weak or too noisy
// to trust \u2014 caused by a barely-touching finger, motion artifact, or poor skin contact.
// Tune upward if you see spurious BPM readings; tune downward if detection is too slow to start.
const float MIN_SIGNAL_QUALITY = 8.0f;

// ========================================================================
// HEART SYMBOL HELPER
// ========================================================================
// Draws a filled 13\u00d711 px heart at (x, y) = top-left of its bounding box.
// Built from two filled circles (top lobes) + a filled triangle (bottom point).
//
//   \u25cf \u25cf         \u2190 two circles, r=3, centres at (x+3,y+3) and (x+9,y+3)
//  \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588
//   \u2588\u2588\u2588\u2588\u2588\u2588\u2588
//    \u2588\u2588\u2588\u2588\u2588
//     \u2588\u2588\u2588
//      \u2588        \u2190 triangle apex at (x+6, y+10)
//
void drawHeart(int16_t x, int16_t y, uint16_t color) {
  display.fillCircle(x + 3,  y + 3, 3, color); // left lobe
  display.fillCircle(x + 9,  y + 3, 3, color); // right lobe
  display.fillTriangle(x, y + 4, x + 12, y + 4, x + 6, y + 10, color); // lower point
}

// ========================================================================
// SCROLL TEXT HELPER
// ========================================================================
// Scrolls a single line of text right-to-left across the vertical centre of
// the display. Uses textSize 2 (12px wide \u00d7 16px tall per character).
// setTextWrap(false) prevents the text from breaking onto a second line.
//
// Parameters:
//   text        \u2014 the string to scroll
//   numPasses   \u2014 how many times the full string crosses the screen
//   drainSensor \u2014 set true ONLY after sensor.begin() has been called.
//                 When true, the IR value is sampled every animation frame.
//                 If a finger is detected (IR >= 20000) the scroll aborts
//                 immediately and returns true, so the caller can react at once.
//
// Returns: true if aborted by finger detection, false if completed normally.
bool scrollText(const char* text, int numPasses, bool drainSensor = false) {
  int charPxWidth = 6 * 2;
  int textPxWidth = strlen(text) * charPxWidth;
  int yPos        = (SCREEN_HEIGHT - 16) / 2;  // vertically centred (16px = textSize 2 height)

  for (int pass = 0; pass < numPasses; pass++) {
    for (int x = SCREEN_WIDTH; x > -textPxWidth; x -= 3) {
      display.clearDisplay();
      display.setTextWrap(false);
      display.setTextSize(2);
      display.setTextColor(SSD1306_WHITE);
      display.setCursor(x, yPos);
      display.print(text);
      display.display();
      if (drainSensor && sensor.getIR() >= 20000) return true; // finger detected \u2014 abort immediately
      delay(8);
    }
  }
  return false;
}

// ========================================================================
// SETUP
// ========================================================================
void setup() {
  Serial.begin(115200);

  // --- CROSS-PLATFORM I2C INITIALIZATION ---
  // ESP32 and ESP8266 often use different default I2C pins than standard Arduinos
#if defined(ESP32)
  Wire.begin(21, 22); // SDA, SCL for ESP32
#elif defined(ESP8266)
  Wire.begin(4, 5);   // SDA, SCL for ESP8266
#else
  Wire.begin();       // Default for AVR (Uno/Nano)
#endif

  // --- OLED INITIALIZATION ---
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 allocation failed. Check wiring!"));
    while (1) delay(10);
  }

  // --- WELCOME SCREEN ---
  // Scrolls the product name once across the display before sensor init.
  // drainSensor=false because sensor.begin() has not been called yet.
  scrollText("Welcome to AikyaNova Pulse Oximeter Demo Board", 1);

  // --- SENSOR INITIALIZATION ---
  // I2C_SPEED_FAST (400kHz) is required to pull data fast enough for smooth waveforms
  if (!sensor.begin(Wire, I2C_SPEED_FAST)) {
    display.clearDisplay();
    display.setTextWrap(true);
    display.setTextSize(2);
    display.setTextColor(SSD1306_WHITE);
    display.setCursor(0, 10);
    display.println(F("Sensor Error!"));
    display.display();
    Serial.println(F("Sensor not found. Check wiring!"));
    while (1) delay(10);
  }

  // --- SENSOR CONFIGURATION ---
  // setup(powerLevel, sampleAverage, ledMode, sampleRate, pulseWidth, adcRange)
  // We use a 411us pulse width here. This provides the highest possible ADC resolution (18-bit),
  // which is absolutely critical for resolving tiny AC pulse waves through dark skin tones.
  sensor.setup(0x00, 8, 2, 400, 411, 4096);

  // Set initial LED amplitudes
  sensor.setPulseAmplitudeIR(currentPower);
  sensor.setPulseAmplitudeRed(15); // Red is kept low as this specific algorithm primarily relies on IR

  // --- WAIT UNTIL FINGER IS PLACED ---
  Serial.println(F("Waiting for finger..."));
  while (sensor.getIR() < 20000) {
    if (scrollText("Place your finger on sensor", 1, true)) break; // finger detected mid-scroll \u2014 exit immediately
  }

  // Seed variables after finger is confirmed
  delay(200);
  dcIR       = sensor.getIR();
  lastBeatMs = millis();
  lastBeatDetectedMs = millis();
  beatCount  = 0; // reset so first interval is skipped
  Serial.println(F("Finger detected. Starting BPM readings."));
}

// ========================================================================
// MAIN LOOP
// ========================================================================
void loop() {
  // Fetch the latest raw Infrared reading
  int32_t ir = sensor.getIR();

  // ---------------------------------------------------------
  // PHASE 1: "Place Finger" Status Warning
  // ---------------------------------------------------------
  // 20,000 is our "open air" threshold. If reading is below this, no finger is present.
  // We skip this check during the warmup window to give auto-brightness time to stabilize,
  // which prevents false "no finger" detections on dark skin tones at startup.
  if (ir < 20000 && warmupDone) {
    // Reset math variables so BPM doesn't spike artificially when finger is replaced
    bpm = 0;
    dcIR = ir;
    warmupDone = false; // Require a fresh warmup next time finger is placed
    env = 0;            // Reset envelope so threshold doesn't trigger on stale amplitude

    // Reset LED power back to a safe baseline when finger is removed
    if (currentPower != 40) {
      currentPower = 40;
      sensor.setPulseAmplitudeIR(currentPower);
    }

    // Scroll finger prompt \u2014 abort as soon as finger is placed again
    while (true) {
      if (scrollText("Place your finger on sensor", 1, true)) break; // finger detected mid-scroll
      if (sensor.getIR() >= 20000) break;                            // finger detected between passes
    }

    // Re-seed baseline cleanly after finger is replaced
    delay(200);
    dcIR       = sensor.getIR();
    lastBeatMs = millis();
    lastBeatDetectedMs = millis();
    beatCount  = 0; // reset so first interval after re-placement is skipped
    return;
  }

  // Start warmup timer once a finger is detected
  if (!warmupDone) {
    if (warmupStart == 0) warmupStart = millis();
    if (millis() - warmupStart >= WARMUP_MS) {
      warmupDone = true;
      warmupStart = 0;
    }
  }

  // ---------------------------------------------------------
  // PHASE 2: Auto-LED Brightness Feedback Loop
  // ---------------------------------------------------------
  // This compensates for varying melanin levels and tissue thickness.
  // We only check every 50ms so the ADC has time to register the previous power change.
  if (millis() - lastAdjustTime > 50) {
    if (ir < 60000 && currentPower < 250) {
      // Signal is too weak (dark skin / thick finger). Increment LED power.
      currentPower += 2;
      sensor.setPulseAmplitudeIR(currentPower);
    }
    else if (ir > 220000 && currentPower > 5) {
      // Signal is heavily saturated (very fair skin). Decrease aggressively to
      // prevent a large AC amplitude that makes the dicrotic notch cross the threshold.
      currentPower = (currentPower > 10) ? currentPower - 5 : 5;
      sensor.setPulseAmplitudeIR(currentPower);
    }
    else if (ir > 180000 && currentPower > 5) {
      // Signal is mildly strong. Gentle decrease to stay in optimal range.
      currentPower -= 2;
      sensor.setPulseAmplitudeIR(currentPower);
    }
    lastAdjustTime = millis();
  }

  // ---------------------------------------------------------
  // PHASE 3: Signal Processing & DC Removal
  // ---------------------------------------------------------
  // Update the slow-moving DC average using a simple low-pass IIR filter
  dcIR = dcIR + ((ir - dcIR) >> SHIFT_DC);

  // Isolate the AC component (the actual pulse wave) by subtracting the DC baseline
  int32_t ac = dcIR - ir;
  int32_t x = ac / 8; // Scale down the signal to fit comfortably on serial plotters (assumes adcRange=4096)

  // ---------------------------------------------------------
  // PHASE 4: Dynamic Thresholding (Envelope Tracking)
  // ---------------------------------------------------------
  // Get absolute value of the signal
  float absx = (x >= 0) ? x : -x;

  // Update the envelope (tracks the average peak amplitude of the wave)
  env = envAlpha * env + (1.0 - envAlpha) * absx;

  // Our trigger threshold is dynamically set to 50% of the current wave amplitude.
  // This allows the algorithm to adapt perfectly to both weak and strong pulses.
  float thresh = 0.5f * env;

  // ---------------------------------------------------------
  // PHASE 5: Peak Detection & BPM Calculation
  // ---------------------------------------------------------
  // A peak is detected if the wave has changed direction (prev > prev2 and prev > current)
  // AND the peak height is above the dynamic noise threshold
  // AND the signal envelope is above the minimum quality gate.
  // The quality gate rejects beats during motion artifact, barely-touching finger,
  // or when the auto-brightness hasn't yet settled on the correct LED power.
  bool isPeak = (prev > prev2) && (prev > x) && (prev > thresh) && (env >= MIN_SIGNAL_QUALITY);
  bool beatFired = false;

  if (isPeak) {
    uint32_t now = millis();
    uint32_t dt  = now - lastBeatMs; // Time elapsed since last beat in milliseconds

    // Validate the beat against the human refractory period (preventing double-counting)
    if (dt > REFRACTORY_MS) {
      beatCount++;
      lastBeatMs         = now;
      lastBeatDetectedMs = now;
      beatFired          = true; // triggers OLED heart and Serial spike regardless of BPM calc

      if (beatCount == 1) {
        // First beat after placement: lastBeatMs was set arbitrarily (not at a real beat),
        // so dt is meaningless. Record the timestamp but skip BPM calculation entirely.
        // BPM will be computed cleanly from the second beat onward.
      } else {
        float instBpm = 60000.0f / dt; // Convert ms between real beats to BPM

        if (bpm == 0) {
          bpm = instBpm; // Seed with first valid interval (beat 2)
        } else if (instBpm > bpm * 1.4f) {
          // Plausibility check: sudden >40% jump is likely a dicrotic notch or artifact
          // that slipped past the refractory gate \u2014 apply near-zero weight.
          bpm = 0.98f * bpm + 0.02f * instBpm;
        } else {
          // Normal smoothing: 30% new beat weight.
          // Responds to real HR changes within ~3 beats (~2.5s at 70 BPM).
          bpm = 0.70f * bpm + 0.30f * instBpm;
        }
      }
    }
  }

  // Stale BPM timeout: if no beat detected for BPM_TIMEOUT_MS, clear the reading
  if (bpm > 0 && (millis() - lastBeatDetectedMs > BPM_TIMEOUT_MS)) {
    bpm = 0;
  }

  // Shift data points back in time for the next loop iteration
  prev2 = prev;
  prev = x;

  // ---------------------------------------------------------
  // PHASE 6: Serial Plotter Output
  // ---------------------------------------------------------
  // Open Tools > Serial Plotter @ 115200 baud to view these waveforms.
  Serial.print("Signal:");
  Serial.print(x);
  Serial.print(", Threshold:");
  Serial.print(thresh);
  Serial.print(", BeatMarker:");
  // Draw a sharp visual spike up to the envelope height when a beat is confirmed
  Serial.println(beatFired ? env : 0);

  // ---------------------------------------------------------
  // PHASE 7: OLED Display Update
  // ---------------------------------------------------------
  display.clearDisplay();
  display.setTextWrap(false);

  // Draw "BPM:" label on the top line (4 chars \u00d7 12px = 48px \u2014 fits without overflow)
  display.setTextSize(2);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 5);
  display.print("BPM:");

  // Draw the calculated BPM value on the bottom line (large, centre-aligned)
  display.setTextSize(3);
  if (bpm > 20 && bpm < 250) {
    // Centre-align: each char is 18px wide at textSize 3
    int bpmVal = (int)bpm;
    int digits = (bpmVal >= 100) ? 3 : 2;
    int xPos = (SCREEN_WIDTH - digits * 18) / 2;
    display.setCursor(xPos, 32);
    display.print(bpmVal);
  } else if (env < MIN_SIGNAL_QUALITY) {
    // Signal quality too low \u2014 finger barely touching or motion artifact
    display.setTextSize(1);
    display.setCursor(22, 38);
    display.print("LOW SIGNAL");
    display.setCursor(10, 50);
    display.print("Adjust finger");
  } else {
    display.setCursor(46, 32);
    display.print("--"); // Signal OK but still calculating first beat
  }

  // Handle the blinking heart indicator
  if (beatFired) {
    heartBeatTimer = millis() + 150; // Keep heart visible for 150ms after a beat
  }

  // Draw the heart symbol if the beat timer is still active
  // Positioned top-right: bounding box x=113, y=1 \u2192 occupies px 113-126 \u00d7 1-12
  if (millis() < heartBeatTimer) {
    drawHeart(113, 1, SSD1306_WHITE);
  }

  display.display(); // Push the rendered buffer to the physical screen
}

Serial Plotter Output:

Code 2 Serial Plotter
Code 2 OLED Display
Fig 13. Pulse Waveform Output The top pane displays the full AC pulsatile waveform along with threshold, beat-marker and BPM, running on the Serial Plotter. The inset beside shows the live BPM (Beats Per Minute) readings mirrored on the 0.96-inch OLED display.
Refractory period — why 400 ms?

The dicrotic notch arrives ~300–350 ms after the systolic peak and can cross the detection threshold on fair skin. Setting REFRACTORY_MS = 400 (max 150 BPM) blocks it. If you see doubled BPM (e.g. ~140 instead of ~70 BPM), try increasing to 450.


Sketch 4 of 4

SpO₂ + BPM — Full Measurement

The complete sketch. Runs all the BPM logic from Sketch 3 and adds SpO₂ via the Ratio of Ratios method: peak-to-peak amplitudes of Red and IR AC signals are tracked per beat cycle, normalised by their DC baselines, and fed into SpO₂ = 110.0 − 25.0 × R. Both LEDs are independently auto-scaled for skin tone compensation.

Max3010x_SpO2.ino  ·  Sketch 4 / 4
SpO₂ · BPM · Dual auto-LED · OLED · Serial Plotter
/*
 * ------------------------------------------------------------------------
 * AikyaNova Labs Embedded Systems
 * ------------------------------------------------------------------------
 * Developed by: AikyaNova Labs
 * Firmware: Reads raw Infrared (IR) and Red optical data from the MAX30105/102
 *           sensor and computes two vital signs simultaneously:
 *           - Heart Rate (BPM) using a dynamic threshold peak-detection algorithm.
 *           - Blood Oxygen Saturation (SpO2) using the Red/IR Ratio-of-Ratios method.
 *           Features independent auto-scaling of both LEDs to compensate for
 *           varying melanin levels and tissue thickness across skin tones.
 *           Displays both values live on the OLED and outputs the PPG waveform,
 *           threshold, BPM and SpO2 traces to the Arduino Serial Plotter.
 *           This is Sketch 4 of 4 in the AikyaNova Pulse Oximeter series.
 * Copyright (c) 2025 AikyaNova\u2122
 * Licensed under the AikyaNova Non-Commercial License.
 * * Dependencies & Credits:
 * - SparkFun MAX3010x Sensor Library (BSD License)
 * - PBA & SpO2 Algorithms by Maxim Integrated Products (MIT-style License)
 * - Adafruit GFX and SSD1306 Libraries by Adafruit Industries (BSD License)
 * ------------------------------------------------------------------------
 */

#include <Wire.h>
#include "MAX30105.h"
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// ========================================================================
// HARDWARE & DISPLAY SETTINGS
// ========================================================================
#define SCREEN_WIDTH   128
#define SCREEN_HEIGHT  64
#define OLED_RESET     -1     // -1 = OLED RST pin not wired to a GPIO; reset handled by hardware power-on
#define SCREEN_ADDRESS 0x3C   // Default I2C address for most 0.96" SSD1306 OLEDs; change to 0x3D if not found

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

MAX30105 sensor;

// ========================================================================
// DUAL AUTO-LED BRIGHTNESS (MELANIN COMPENSATION)
// ========================================================================
// Melanin absorbs visible Red light significantly more than Infrared (IR) light.
// To ensure a high SNR for accurate SpO2 on all skin tones, both LEDs are
// independently adjusted dynamically.
byte irPower  = 40;           // Starting power for IR LED (~8mA)
byte redPower = 40;           // Starting power for Red LED (~8mA)
unsigned long lastAdjustTime = 0;

// ========================================================================
// DC REMOVAL & BASELINE TRACKING
// ========================================================================
int32_t dcIR  = 0;
int32_t dcRED = 0;
const int SHIFT_DC = 4;       // EMA filter: >> 4 \u2261 divide by 16

// ========================================================================
// PEAK DETECTION & BPM VARIABLES
// ========================================================================
int32_t prev = 0, prev2 = 0;
uint32_t lastBeatMs        = 0;
uint8_t  beatCount         = 0;   // BPM not calculated until 2nd beat to avoid
                                   // a bad first interval from the arbitrary lastBeatMs seed.
float bpm = 0;

float env = 0;
const float envAlpha = 0.95f;

// 400ms refractory period (max detectable: 150 BPM).
// Increased from 300ms to block the dicrotic notch on fair skin tones,
// which arrives ~300-350ms after the systolic peak and was causing doubled BPM.
const uint32_t REFRACTORY_MS = 400;

uint32_t lastBeatDetectedMs   = 0;
const uint32_t BPM_TIMEOUT_MS = 3000; // Clear BPM if no beat for 3 seconds

// Signal quality gate: envelope must exceed this before beats are accepted.
// Rejects noise, motion artifact, and barely-touching finger.
const float MIN_SIGNAL_QUALITY = 8.0f;

// ========================================================================
// WARMUP VARIABLES
// ========================================================================
// Skips the finger-absent check for WARMUP_MS after placement, giving the
// auto-brightness time to stabilize and preventing false removals on dark skin.
bool     warmupDone  = false;
uint32_t warmupStart = 0;
const uint32_t WARMUP_MS = 500;

// ========================================================================
// SpO2 ESTIMATION VARIABLES
// ========================================================================
float spo2   = 0;
float Rratio = 0;

int32_t irMax  = INT32_MIN, irMin  = INT32_MAX;
int32_t redMax = INT32_MIN, redMin = INT32_MAX;

uint32_t lastSpo2DetectedMs    = 0;
const uint32_t SPO2_TIMEOUT_MS = 10000; // Clear SpO2 if no valid beat for 10 seconds

uint32_t heartBeatTimer = 0;

// ========================================================================
// UTILITY HELPERS
// ========================================================================
// clampf: clamps a float value between lo and hi (inclusive).
// Used to constrain SpO2 estimates to the physiologically valid range (70\u2013100%).
float clampf(float v, float lo, float hi) {
  if (v < lo) return lo;
  if (v > hi) return hi;
  return v;
}

// ========================================================================
// HEART SYMBOL HELPER
// ========================================================================
// Draws a filled 13\u00d711 px heart at (x, y) = top-left of its bounding box.
// Built from two filled circles (top lobes) + a filled triangle (bottom point).
//
//   \u25cf \u25cf         \u2190 two circles, r=3, centres at (x+3,y+3) and (x+9,y+3)
//  \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588
//   \u2588\u2588\u2588\u2588\u2588\u2588\u2588
//    \u2588\u2588\u2588\u2588\u2588       \u2190 triangle connects the two lobes into a downward point
//     \u2588\u2588\u2588
//      \u2588        \u2190 triangle apex at (x+6, y+10)
//
void drawHeart(int16_t x, int16_t y, uint16_t color) {
  display.fillCircle(x + 3,  y + 3, 3, color); // left lobe
  display.fillCircle(x + 9,  y + 3, 3, color); // right lobe
  display.fillTriangle(x, y + 4, x + 12, y + 4, x + 6, y + 10, color);
}

// ========================================================================
// SCROLL TEXT HELPER
// ========================================================================
// Scrolls a single line of text right-to-left across the vertical centre of
// the display. Uses textSize 2 (12px wide \u00d7 16px tall per character).
// setTextWrap(false) prevents the text from breaking onto a second line.
//
// Parameters:
//   text        \u2014 the string to scroll
//   numPasses   \u2014 how many times the full string crosses the screen
//   drainSensor \u2014 set true ONLY after sensor.begin() has been called.
//                 When true, the IR value is sampled every animation frame.
//                 If a finger is detected (IR >= 20000) the scroll aborts
//                 immediately and returns true, so the caller can react at once.
//
// Returns: true if aborted by finger detection, false if completed normally.
bool scrollText(const char* text, int numPasses, bool drainSensor = false) {
  int charPxWidth = 6 * 2;
  int textPxWidth = strlen(text) * charPxWidth;
  int yPos        = (SCREEN_HEIGHT - 16) / 2;

  for (int pass = 0; pass < numPasses; pass++) {
    for (int x = SCREEN_WIDTH; x > -textPxWidth; x -= 3) {
      display.clearDisplay();
      display.setTextWrap(false);
      display.setTextSize(2);
      display.setTextColor(SSD1306_WHITE);
      display.setCursor(x, yPos);
      display.print(text);
      display.display();
      if (drainSensor && sensor.getIR() >= 20000) return true;
      delay(8);
    }
  }
  return false;
}

// ========================================================================
// STATE RESET HELPER
// ========================================================================
// Resets all physiological and LED state to safe defaults.
// Called both when a finger is removed and when it is re-placed, ensuring
// that stale BPM/SpO2 values, a wrong DC baseline, and residual LED power
// from a previous placement never carry over into the next measurement.
void resetState() {
  bpm   = 0;  spo2  = 0;
  dcIR  = sensor.getIR();
  dcRED = sensor.getRed();
  env   = 0;
  irMax = INT32_MIN; irMin = INT32_MAX;
  redMax = INT32_MIN; redMin = INT32_MAX;
  lastBeatMs        = millis();
  lastBeatDetectedMs = millis();
  lastSpo2DetectedMs = millis();
  beatCount  = 0;
  warmupDone = false;
  warmupStart = 0;
  irPower = 40; redPower = 40;
  sensor.setPulseAmplitudeIR(irPower);
  sensor.setPulseAmplitudeRed(redPower);
}

// ========================================================================
// SETUP
// ========================================================================
void setup() {
  Serial.begin(115200);

  // --- CROSS-PLATFORM I2C INITIALIZATION ---
#if defined(ESP32)
  Wire.begin(21, 22);
#elif defined(ESP8266)
  Wire.begin(4, 5);
#else
  Wire.begin();
#endif

  // --- OLED INITIALIZATION ---
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 allocation failed. Check wiring."));
    while (1) delay(10);
  }

  // --- WELCOME SCREEN ---
  // Scrolls the product name once across the display before sensor init.
  // drainSensor=false because sensor.begin() has not been called yet.
  scrollText("Welcome to AikyaNova Pulse Oximeter Demo Board", 1);

  // --- SENSOR INITIALIZATION ---
  if (!sensor.begin(Wire, I2C_SPEED_FAST)) {
    display.clearDisplay();
    display.setTextWrap(true);
    display.setTextSize(2);
    display.setTextColor(SSD1306_WHITE);
    display.setCursor(0, 10);
    display.println(F("Sensor Error!"));
    display.display();
    Serial.println(F("Sensor not found. Check wiring!"));
    while (1) delay(10);
  }

  // --- SENSOR CONFIGURATION ---
  // sampleRate=400, avg=8 \u2192 50 Hz effective output rate.
  // pulseWidth=411us \u2192 18-bit ADC resolution \u2014 critical for dark skin tones.
  sensor.setup(0, 8, 2, 400, 411, 4096);
  sensor.setPulseAmplitudeIR(irPower);
  sensor.setPulseAmplitudeRed(redPower);

  // --- WAIT UNTIL FINGER IS PLACED ---
  Serial.println(F("Waiting for finger..."));
  while (sensor.getIR() < 20000) {
    if (scrollText("Place your finger on sensor", 1, true)) break;
  }

  // Seed baseline after finger is confirmed
  delay(200);
  resetState();
  Serial.println(F("Finger detected. Starting readings."));
}

// ========================================================================
// MAIN LOOP
// ========================================================================
void loop() {
  int32_t irRaw  = sensor.getIR();
  int32_t redRaw = sensor.getRed();

  // ---------------------------------------------------------
  // PHASE 1: Finger Absent Check
  // ---------------------------------------------------------
  if (irRaw < 20000 && warmupDone) {
    resetState();

    while (true) {
      if (scrollText("Place your finger on sensor", 1, true)) break;
      if (sensor.getIR() >= 20000) break;
    }

    delay(200);
    resetState();
    return;
  }

  // Warmup: skip finger-absent check for WARMUP_MS after placement
  if (!warmupDone) {
    if (warmupStart == 0) warmupStart = millis();
    if (millis() - warmupStart >= WARMUP_MS) {
      warmupDone  = true;
      warmupStart = 0;
    }
  }

  // ---------------------------------------------------------
  // PHASE 2: Independent Dual Auto-LED Brightness
  // ---------------------------------------------------------
  if (millis() - lastAdjustTime > 50) {
    bool adjusted = false;

    // IR LED control
    if      (irRaw < 60000  && irPower < 250) { irPower  += 2; adjusted = true; }
    else if (irRaw > 220000 && irPower > 5)   { irPower   = (irPower > 10) ? irPower - 5 : 5; adjusted = true; } // aggressive for very saturated fair skin
    else if (irRaw > 180000 && irPower > 5)   { irPower  -= 2; adjusted = true; }

    // Red LED control (independent \u2014 melanin absorbs Red more than IR)
    if      (redRaw < 60000  && redPower < 250) { redPower  += 2; adjusted = true; }
    else if (redRaw > 220000 && redPower > 5)   { redPower   = (redPower > 10) ? redPower - 5 : 5; adjusted = true; }
    else if (redRaw > 180000 && redPower > 5)   { redPower  -= 2; adjusted = true; }

    if (adjusted) {
      sensor.setPulseAmplitudeIR(irPower);
      sensor.setPulseAmplitudeRed(redPower);
    }
    lastAdjustTime = millis();
  }

  // ---------------------------------------------------------
  // PHASE 3: DC Removal & AC Extraction
  // ---------------------------------------------------------
  dcIR  = dcIR  + ((irRaw  - dcIR)  >> SHIFT_DC);
  dcRED = dcRED + ((redRaw - dcRED) >> SHIFT_DC);

  int32_t irAC  = dcIR  - irRaw;
  int32_t redAC = dcRED - redRaw;

  // Track peak-to-peak amplitude for SpO2 calculation
  if (irAC > irMax)   irMax  = irAC;
  if (irAC < irMin)   irMin  = irAC;
  if (redAC > redMax) redMax = redAC;
  if (redAC < redMin) redMin = redAC;

  int32_t x = irAC / 8; // Scale IR signal for Serial Plotter (assumes adcRange=4096)

  // ---------------------------------------------------------
  // PHASE 4: Dynamic Thresholding (Envelope Tracking)
  // ---------------------------------------------------------
  float absx = (x >= 0) ? x : -x;
  env = envAlpha * env + (1.0f - envAlpha) * absx;
  float thresh = 0.5f * env;

  // ---------------------------------------------------------
  // PHASE 5: Peak Detection, BPM & SpO2
  // ---------------------------------------------------------
  // Quality gate: reject beats when signal is too weak or noisy
  bool isPeak = (prev > prev2) && (prev > x) && (prev > thresh) && (env >= MIN_SIGNAL_QUALITY);
  bool beatFired = false;

  if (isPeak) {
    uint32_t now = millis();
    uint32_t dt  = now - lastBeatMs;

    if (dt > REFRACTORY_MS) {
      beatCount++;
      lastBeatMs         = now;
      lastBeatDetectedMs = now;
      beatFired          = true;

      if (beatCount == 1) {
        // First beat after placement: lastBeatMs was set at an arbitrary time,
        // so dt is invalid. Record timestamp only \u2014 skip BPM calculation.
      } else {
        // --- BPM ---
        float instBpm = 60000.0f / dt;
        if (bpm == 0) {
          bpm = instBpm;
        } else if (instBpm > bpm * 1.4f) {
          // Plausibility check: >40% sudden jump \u2192 likely dicrotic notch or artifact
          bpm = 0.98f * bpm + 0.02f * instBpm;
        } else {
          // Normal smoothing: 30% new beat weight (~3 beats to reflect real HR change)
          bpm = 0.70f * bpm + 0.30f * instBpm;
        }

        // --- SpO2 ---
        float acIRAmp  = (float)(irMax  - irMin);
        float acREDAmp = (float)(redMax - redMin);
        float dcIrF    = (float)dcIR;
        float dcRedF   = (float)dcRED;

        if (acIRAmp > 1 && acREDAmp > 1 && dcIrF > 1 && dcRedF > 1) {
          float nir  = acIRAmp / dcIrF;
          float nred = acREDAmp / dcRedF;
          if (nir > 0.000001f) {
            Rratio = nred / nir;
            float spo2Inst = 110.0f - 25.0f * Rratio;
            spo2Inst = clampf(spo2Inst, 70.0f, 100.0f);
            if (spo2 == 0) spo2 = spo2Inst;
            spo2 = 0.80f * spo2 + 0.20f * spo2Inst; // SpO2 smoothing (20% new beat weight)
            lastSpo2DetectedMs = now;
          }
        }
      }

      // Reset peak-to-peak trackers for the next beat cycle
      irMax = INT32_MIN; irMin = INT32_MAX;
      redMax = INT32_MIN; redMin = INT32_MAX;
    }
  }

  // Stale timeouts: clear readings if no beat detected for the timeout period
  if (bpm  > 0 && (millis() - lastBeatDetectedMs  > BPM_TIMEOUT_MS))  bpm  = 0;
  if (spo2 > 0 && (millis() - lastSpo2DetectedMs  > SPO2_TIMEOUT_MS)) spo2 = 0;

  prev2 = prev;
  prev  = x;

  // ---------------------------------------------------------
  // PHASE 6: Serial Plotter Output
  // ---------------------------------------------------------
  Serial.print("IR_Signal:");   Serial.print(x);
  Serial.print(", Threshold:"); Serial.print(thresh);
  Serial.print(", Beat:");      Serial.print(beatFired ? env : 0);
  Serial.print(", BPM:");       Serial.print(bpm / 2);
  Serial.print(", SpO2:");      Serial.println(spo2 / 2);

  // ---------------------------------------------------------
  // PHASE 7: OLED Display Update
  // ---------------------------------------------------------
  display.clearDisplay();
  display.setTextWrap(false);
  display.setTextColor(SSD1306_WHITE);

  // --- Headers ---
  display.setTextSize(2);
  display.setCursor(0,  5); display.print("BPM");
  display.setCursor(72, 5); display.print("SpO2");

  // --- BPM Value ---
  display.setTextSize(3);
  display.setCursor(0, 30);
  if (bpm > 20 && bpm < 250) {
    display.print((int)bpm);
  } else if (env < MIN_SIGNAL_QUALITY) {
    display.setTextSize(1);
    display.setCursor(0, 30);
    display.print("LOW");
    display.setCursor(0, 42);
    display.print("SIGNAL");
  } else {
    display.print("--");
  }

  // --- SpO2 Value ---
  display.setTextSize(3);
  display.setCursor(72, 30);
  if (spo2 > 70) {
    display.print((int)spo2);
    display.setTextSize(1);
    display.setCursor(117, 30);
    display.print("%");
  } else {
    display.print("--");
  }

  // --- Heart symbol (bottom centre, blinks on each confirmed beat) ---
  if (beatFired) heartBeatTimer = millis() + 150;
  if (millis() < heartBeatTimer) {
    drawHeart(47, 51, SSD1306_WHITE); // 13\u00d711 px heart centred at approximately (53, 57)
  }

  display.display();
}

Serial Plotter Output:

Code 2 Serial Plotter
Code 2 OLED Display
Fig 14. Pulse Waveform Output The top pane displays the full AC pulsatile waveform along with IR-Signal value, threshold, beat-marker, BPM and SpO2, running on the Serial Plotter. The inset beside shows the live heart rate and SpO2 readings mirrored on the 0.96-inch OLED display.
SpO₂ accuracy tips

Keep still — motion artefact is the biggest error source. Cold fingers reduce perfusion; warm your hand first. Allow 5–10 seconds after placing the finger for the algorithm to converge. SpO₂ is smoothed at 80% old / 20% new weight, so sudden changes take ~5 beats to fully reflect.

⚠️ Research & Educational Use Only. The AikyaNova Pulse Oximeter Demo Board is designed for learning, prototyping, and research. It is not a certified medical device and must not be used for clinical diagnosis, patient monitoring, or any application where an inaccurate reading could affect health decisions. For medical SpO₂ monitoring, use a CE/FDA-cleared device.

Ready to get hands-on?

Order the AikyaNova Pulse Oximeter Demo Board directly from our website — pre-soldered and ready to use in minutes.