The MAX30102 is a small IC that enables non-invasive measurement of a person's pulse and blood-oxygen saturation. It shines two LEDS, on red and one infra-red, into a suitable area of the body and measures the reflected light. The data can be read out via the I2C bus and an external processor can calculate pulse rate and oxygen saturation (called SPO2) using fairly simple algorithms.
Several manufacturers make small modules with the MAX30102 on them. The interface is the I2C bus, plus usually an 'interrupt' signal line. I have one only, and I have found it to be quite temperamental, sometimes refusing to initialise for no apparent reason. Perhaps I have a bad joint somewhere in my I2C bus wiring. Sparkfun has created an Arduino library that reads the MAX30102 and also provides pulse and SPO2 calculations. However, sketches on the Web that use this library are rudimentary and sometimes erroneous. There are several reasons why this is so:
- The MAX30102 data sheet is technical, and the Sparkfun library is essentially undocumented.
- The MAX30102 does not allow direct reading of its sensor, instead it contains a 32-sample FIFO (ring buffer) that it fills circularly. Reading it out is moderately complicated.
- The Sparkfun library does not provide routines to directly read the MAX30102 FIFO, instead it block-reads the FIFO into an internal ring buffer and only provides routines to read that buffer. However, two of those calls simply return the latest red- and IR- LED samples. Amazingly, the default size of the buffer is only 4 samples!
The result of this is that most example sketches on the Web just use the library to return the latest sample values, effectively mimicking a direct read of the sensor. They make no real use of the FIFO. They then use weird and wonderful calculations to get the SPO2 value. (Pulse rate is simpler). I wanted to do better than that so I read the data sheet and studied the Sparkfun source code.
Let's start by designing the solution. The MAX30102 uses an ADC to measure the reflected light from the LEDs. It can read the ADC at enormous speed, far faster than the I2C bus can cope, but it only provides external sample rates of 50, 100, 200, 400, 800, 1000, 1600 and 3200 times per second. We want to handle pulse rates up to. say, 200 per minute, and to be able to do the calculations on the data, we need say, 8 samples per pulse cycle. So that is 1600 samples per minute, which is about 27 per second, less than the minimum provided by the MAX30102. So, our problem is to get it to run slowly enough. The 32 sample FIFO does not really help much, in fact I question whether it is of much use at all.
However, the MAX30102 has another feature that we can exploit: it can average over multiple samples, creating better data and slowing the external data rate. If we sample 200 times per second, and average over 8 samples, the data rate becomes 25 samples per second, close enough to our design goal of 27. 200 per second is one per 5 milliseconds, so we can use any of the available pulse widths of 69, 118, 215 and 411 microseconds. As the FIFO can hold 32 samples, if we read it out once per second we should have no problems with overflow.
Let's just verify that the MAX30102 does indeed behave as we want. The Sparkfun library provides two calls that directly read the MAX30102 FIFO write and read pointer registers. The first of these points to the location where the MAX30102 writes the next sample. This pointer advances for each sample pushed on to the FIFO. The second points to the location from where the processor gets the next sample from the FIFO through the I2C interface. This advances each time a sample is popped from the FIFO. So if we empty the FIFO and do not read from it, the write pointer will increase by one each time a sample is read, and the read pointer will remain at zero. The write pointer will wrap to zero after the value 31. The library provides calls to allow or disallow data wrapping.
(NB It's important to avoid overflows because the Sparkfun library assumes that if the write pointer equals the read pointer, the FIFO is empty. However the same is true in the much-more-likely situation that the FIFO is full. This is a classic problem with a ring buffer, and the best solution is to ensure that it never becomes full. The MAX30102 has a 'FIFO becoming full' interrupt so that an external processor can read the FIFO in that situation. However I am not using the interrupts as they come via a separate line from the I2C lines, and the Sparkfun library has no service routines for them. Instead, I plan to read at a rate that avoids overflow. (The MAX30102 also has a FIFO overflow indicator register, but my experience is that it always indicates overflows, even immediately after it has been cleared, so I think it does not work correctly).
This sketch sets the MAX30102 parameters to the values we want, clears the FIFO, and then loops reading those two registers. It needs a fast processor with sufficient RAM to store the timestamps and data: I used an ESP32C3-ZERO but I believe any ESP32 would do. I found that each loop iteration took about 1 millisecond. The sketch shows the times when the write pointer value changes.
#include <Wire.h>
#include "MAX30105.h"
MAX30105 particleSensor; // initialize MAX30102 with I2C
#define BUFFSIZE 2000
unsigned long millistime[BUFFSIZE];
unsigned int readreg[BUFFSIZE];
unsigned int writereg[BUFFSIZE];
unsigned int overreg[BUFFSIZE];
void setup() {
Serial.begin(115200);
while (!Serial) ;
delay(100);
Wire.begin();
Serial.println("");
Serial.println("MAX30102");
delay(100);
// Initialize sensor
if (particleSensor.begin(Wire) == false) //Use default I2C port
{
Serial.println("MAX30102 not found.");
while (1)
;
}
Serial.println("MAX30102 found. ");
byte ledBrightness = 70; //Options: 0=Off to 255=50mA
byte sampleAverage = 8; //Options: 1, 2, 4, 8, 16, 32
byte ledMode = 2; //Options: 1 = Red only, 2 = Red + IR, 3 = Red + IR + Green
int sampleRate = 200; //Options: 50, 100, 200, 400, 800, 1000, 1600, 3200
int pulseWidth = 411; //Options: 69, 118, 215, 411
int adcRange = 16384; //Options: 2048, 4096, 8192, 16384
particleSensor.setup(ledBrightness, sampleAverage, ledMode, sampleRate, pulseWidth, adcRange); //Configure sensor with these settings
Serial.println(F("Press any key to start conversion"));
while (Serial.available() == 0)
; //wait until user presses a key
Serial.println("starting");
delay(10000);
particleSensor.enableFIFORollover();
particleSensor.clearFIFO();
// read approx every millisecond
unsigned long dt = millis();
for (int i = 0; i < BUFFSIZE; ++i) {
millistime[i] = millis();
readreg[i] = particleSensor.getReadPointer();
writereg[i] = particleSensor.getWritePointer();
}
// print only when the write pointer changes
int w = 0;
for (int i = 0; i < BUFFSIZE; ++i) {
int wr = writereg[i];
if (wr != w) {
w = wr;
Serial.print(millistime[i] - dt);
Serial.print(" ");
Serial.print(readreg[i]);
Serial.print(" ");
Serial.println(wr);
}
}
while (1)
;
}
void loop() {}
The output from it is as follows:
starting
2 0 1
42 0 2
83 0 3
123 0 4
163 0 5
203 0 6
243 0 7
283 0 8
323 0 9
363 0 10
403 0 11
443 0 12
483 0 13
523 0 14
....
After the initial sample is read, the write pointer increments every 40 milliseconds (1/25 of a second) as expected.
Now to the main sketch. We have to understand these Sparkfun library routines:
- check() - reads the FIFO read and write pointers, and, if they are different, reads all the samples in the FIFO into the Sparkfun internal ring buffer. However, the default size of this is 4 samples, and it just wraps if more data are found, so with my design storing 25 in the FIFO, most will be just thrown away! (In fact, my testing shows that it returns just one sample). As the ESP32 is not short of RAM, I increased the buffer to 64 samples (by a simple text edit of the library).
- available() - returns the number of samples in the Sparkfun buffer (NOT the MAX30102 FIFO!).
- getFIFORed() and getFIFOIR() - return the red- and IR- LED values of the current sample in the Sparkfun buffer (NOT the MAX30102 FIFO!).
- nextSample() - advance the current sample to the next one in the Sparkfun buffer (NOT the MAX30102 FIFO!).
So to read and print the Sparkfun buffer we use:
int avail = particleSensor.available();
for (int j = 0; j < avail; ++j) {
Serial.print(particleSensor.getFIFORed());
Serial.print(" ");
Serial.println(particleSensor.getFIFOIR());
particleSensor.nextSample();
}
NB don't put the call to available() in the 'for' statement because the nextSample() changes its return value!
A quick test shows that this is working as designed, so I wrote the following sketch. Having initilised the MAX30102 as specified above, it calls check() once per second, which transfers 25 samples into the Sparkfun buffer (each an average of eight), and then reads the buffer out. (Sometimes it is not exactly 25, which I don't understand). It accumulates the data for 5 seconds, displaying the results. You can change it to run repeatedly and to calculate pulse and SPO2 using your favourite algorithms.
#include <Wire.h>
#include "MAX30105.h"
MAX30105 particleSensor; // initialize MAX30102 with I2C
unsigned long loopTimer;
#define LOOPINTERVAL 1000L // one second
unsigned long timeCount;
#define SAMPLEINTERVAL 40L // 40 ms per sample
#define BUFFSIZE 140 // 5 seconds times 25 samples plus a margin
unsigned long millistime[BUFFSIZE];
unsigned int redValue[BUFFSIZE];
unsigned int IRValue[BUFFSIZE];
int loopCounter = 0;
int samplesTotal = 0;
void setup() {
Serial.begin(115200);
while (!Serial)
;
delay(100);
Wire.begin();
// Initialize sensor
if (particleSensor.begin(Wire) == false)
{
Serial.println("MAX30102 not found.");
while (1)
;
}
Serial.println("MAX30102 found. ");
byte ledBrightness = 70; //Options: 0=Off to 255=50mA
byte sampleAverage = 8; //Options: 1, 2, 4, 8, 16, 32
byte ledMode = 2; //Options: 1 = Red only, 2 = Red + IR, 3 = Red + IR + Green
int sampleRate = 200; //Options: 50, 100, 200, 400, 800, 1000, 1600, 3200
int pulseWidth = 411; //Options: 69, 118, 215, 411
int adcRange = 16384; //Options: 2048, 4096, 8192, 16384
particleSensor.setup(ledBrightness, sampleAverage, ledMode, sampleRate, pulseWidth, adcRange); //Configure sensor with these settings
Serial.println(F("Press any key to start conversion"));
while (Serial.available() == 0)
; //wait until user presses a key
Serial.println("starting");
delay(1000);
particleSensor.enableFIFORollover();
timeCount = 0L;
loopTimer = millis() + LOOPINTERVAL;
particleSensor.clearFIFO();
}
void loop() {
if (millis() >= loopTimer) {
particleSensor.check();
int avail = particleSensor.available();
if (samplesTotal + avail > BUFFSIZE)
while (1)
;
for (int j = 0; j < avail; ++j) {
millistime[samplesTotal + j] = timeCount;
timeCount += SAMPLEINTERVAL;
IRValue[samplesTotal + j] = particleSensor.getFIFOIR();
redValue[samplesTotal + j] = particleSensor.getFIFORed();
particleSensor.nextSample();
}
Serial.print("samples ");
Serial.print(avail);
Serial.print(" total ");
Serial.println(samplesTotal + avail);
Serial.print("time ");
for (int j = 0; j < avail; ++j) {
Serial.print(" ");
Serial.print(millistime[samplesTotal + j]);
Serial.print(" ");
}
Serial.println("");
Serial.print("IR ");
for (int j = 0; j < avail; ++j) {
Serial.print(IRValue[samplesTotal + j]);
Serial.print(" ");
}
Serial.println("");
Serial.print("red ");
for (int j = 0; j < avail; ++j) {
Serial.print(redValue[samplesTotal + j]);
Serial.print(" ");
}
Serial.println("");
Serial.println("");
samplesTotal += avail;
loopTimer += LOOPINTERVAL;
}
}