## Implementing pulse oximeter using MAX30100

For my smart watch project I decided to experiment with sensors for reading pulse. Looking around I stumbled upon a sensor made by Maxim – MAX30100. For my surprise once I got my development board and delved into the sensor’s datasheet I discovered it’s not as simple as just wiring up the sensor to a microcontroller and reading the data. A lot of work you have to do yourself. In this tutorial, I’ll try to explain what I’ve learned about pulse oximeter and how to make sense of their data.

# Introduction

In this tutorial I’ll briefly explain how a pulse oximeter works and how to make sense of the data coming from MAX30100. This article will be structured in a way where each consecutive step will be explained with why such filtering is applied and how it was calculated. Mainly the implementation consists of two parts: reading the pulse with IR LED only and calculating SaO2 using both RED and IR LEDs.

By the end of the article you should be able to understand the various stages the signal goes through. These methods should be applicable to any sensor even the ones you make yourself or made by other manufacturers.

# What is pulse oximeter?

A pulse oximeter is basically a device which can measure your pulse and oxygen saturation in your blood. Usually this sensor consists of two LEDs emitting light: one in Red spectrum (650nm) and the other one in Infrared (950nm). This sensor is placed on your finger or earlobe, essentially anywhere where the skin is not too thick so both light frequencies can easily penetrate the tissue. Once both of them are shined through your finger for example, the absorption is measure with a photodiode. And depending on the amount of oxygen you have in your blood the ratio between the absorbed red light and IR led will be different. From this ratio it is possible to “easily” calculate your oxygen level in your hemoglobin (see figure 1).

Figure 1 Hemogoblin light absorption graph

Really good explanation on the theory behind the pulse oximeter can be found here: https://www.howequipmentworks.com/pulse_oximeter/

It lacks details to implement the driver for MAX30100. But should give you a really good understanding about how in general these sensors operate.

# What MAX30100 does and doesn’t do?

Initially I thought that this sensor, MAX30100, will do everything for me. My false assumptions were that it will measure the pulse and the oxygen saturation levels automatically and put them in a register I can easily read through the I2C, similar to BMP280. And that can’t be further from truth, if you wanted.

Even though, MAX30100 doesn’t do everything for you, it still does quite a bit to help with measuring the absorption between those two light frequencies. If you wanted to build your own sensor, it would definitely come out as a quite large circuit. Where you have to manually alternate between reading IR and RED led absorption, regulate the brightness manually of the LEDs with PWM, filter 50/60Hz noise out of the signals and more.

All of these things I mentioned in previous paragraph are done automatically by MAX30100. You just configure the sensor and then let it run, and it will store it’s readings in a FIFO buffer. Only thing you have to do is then, go and read the FIFO data and make sense of it. Which, by the way, would be very similar if you created your own sensor. On that basis, this article should also help if you do use different sensor or make your own from scratch.

In short don’t assume MAX30100 will do everything for you, a lot of deciphering will still be up to you.

# Background information on MAX30100

First thing we have to do, is to connect the sensor to our microcontroller and read its data. I won’t go in a lot of details; just some small notes and tips how it is done. Since I feel this is a rather simple process.

First some important background about MAX30100:

1. I2C address of MAX30100: 0x57
2. Data is stored in a FIFO buffer. It can store up to 16 measurements, where each sample is size of 4 bytes. First two bytes are for IR measurement and last two bytes are for RED measurement.
3. FIFO buffer can’t be read consequently with I2C, since the FIFO points to the same address. You have to finish transaction for FIFO output address to contain the next values.
4. MAX30100 has built in 50/60Hz filter
5. If you want to just detect pulse, only IR is required
6. For oxygen saturation you’ll need to enable both IR and RED LEDs
7. By changing sampling rate and pulse width of the LEDs you also change the ADC resolution. It is important to note that sample rate and pulse width are directly linked to each other. See datasheet page 19 table 8 and table 9 or see figure 2. Don’t just configure them randomly.

Figure 2 Sample Rate vs. Pulse width configuration table

To start reading the data from MAX30100 you only have to do two things:

1. Set the mode, I  suggest in the beginning set it only to heart rate mode
2. Set the current for IR led

This will enable us to measure heart rate, once we are done with filtering. You can check how did I do it, by looking at three functions in my library: setMode(),setLEDCurrents() and readFIFO()

Once you have managed to set up the MAX30100 for HR mode and read the raw IR data it should look something like in figure 3, once plotted:

Figure 3 RAW IR data, with visible oscillations

## DC Removal

There are two things you should notice in the graph (figure 3):

1. The graph is oscillating slightly
2. It has a DC offset of 50 000 units

To properly be able to read the heart rate and SaO2 we need to remove the DC signal and leave only the AC part.

It is actually very simple and can be done using these two equations:

$$w(t)=x(t)+ ∝ *w(t-1)$$

$$y(t)=w(t)-w(t-1)$$

y(t):       is the output of the filter
x(t):       current input/value
w(t):      intermediate value, acts like the history of the DC value
α:          is the response constant of the filter
If α = 1 then everything passes through
If α = 0 then nothing passes through
for DC removal you want the α as rather close to 1. I’ll be using α = 0.95

If you want to read more about DC removal, here is a good tutorial and much more detailed description of  how it functions: http://sam-koblenski.blogspot.co.uk/2015/11/everyday-dsp-for-programmers-dc-and.html

Here is the filter implemented in a code:

struct fifo_t {
uint16_t rawIR;
uint16_t rawRed;
};
dcFilter_t MAX30100::dcRemoval(float x, float prev_w, float alpha)
{
dcFilter_t filtered;
filtered.w = x + alpha * prev_w;
filtered.result = filtered.w - prev_w;

return filtered;
}

Once, we pass the signal through the DC removal filter, we should get a signal similar to the one in figure 4:

Figure 4 IR signal passed through DC Removal filter

As you can see in figure 4, we are now left with only the AC part of the signal, and it is oscillating around 0 DC value instead of 50 000.

## Mean Median Filter

Now that we have DC filtered our signal, to further improve the ability to detect pulses we have to take the differential of the signal. Our pulse is where in the data we have suddenly the largest change in value.

However, I’ve decided to implement mean median filter instead of just taking the difference to further clean up the signal. This will give us the value change from the average, as the name implies. Here is my simple implementation of such filter:

struct meanDiffFilter_t
{
float values[MEAN_FILTER_SIZE];
byte index;
float sum;
byte count;
};

float MAX30100::meanDiff(float M, meanDiffFilter_t* filterValues)
{
float avg = 0;

filterValues->sum -= filterValues->values[filterValues->index];
filterValues->values[filterValues->index] = M;
filterValues->sum += filterValues->values[filterValues->index];

filterValues->index++;
filterValues->index = filterValues->index % MEAN_FILTER_SIZE;

if(filterValues->count < MEAN_FILTER_SIZE)
filterValues->count++;

avg = filterValues->sum / filterValues->count;
return avg - M;
}

After we pass the DC filtered signal through the mean difference filter we get a familiar signal which reassembles a cardiogram (see figure 5)

Figure 5 Mean Difference filtered IR signal

The really tall peaks are my heart beats. From this data it should already be quite easy to extrapolate my heartbeat, however if you look closely to the wave form, there are some higher level harmonies in the data. They are especially visible at the bottom part of the signal. We can filter them out easily if we pass the signal through a low pass filter or band pass filter.

## Butterworth filter

To remove the higher level harmonies I shall be using Butterworth filter in low pass filter configuration. Technically it is a band pass filter. And also, any low pass filter would do just fine. It’s just relatively easy to work with Butterworth. There is a good online tool for generating Butterworth filter constants for your desired frequencies: http://www.schwietering.com/jayduino/filtuino/

So to implement this filter we have to establish two variables: sampling rate (FS) and cut-off frequency (FC ).

Technically the fastest sampling rate available for MAX30100 is 1kHz, nonetheless the configuration I’ve chosen is with long pulse width, which allows the sampling rate to be only 100Hz. So from this we can extrapolate that our sampling rate is 100Hz.

Next we need to choose the cut-off frequency. Since we are measuring heart rate, as far as I know, 220 BPM is dangerously high heart rate but still achievable in certain cases. So I’ve chosen that to be our maximum frequency we have to pass through.

Our fastest frequency we would require to let through, can be calculated like so:

$$f = {220 BPM \over 60} = 3.66Hz$$

If we assume we want to measure as low as 50 BPM we can apply the same calculations:

$$f = {50 BPM \over 60} = 0.83Hz$$

It is very important to remember that Butterworth filter work on a normalised frequency

$$R_n = {F_C \over F_S}$$

So if your sampling rate is not spot-on 100Hz, Butterworth filter will start to cut-off different frequencies. In figure 6 you can see how quickly one loop finishes in my current implementation.

Figure 6 Diagram showing how quickly one loop finishes while reading and filtering MAX30100 data. Note that output pin is flipped every time a loop finishes. Actual execution speed is 75Hz

As you can see in the figure 6 our sampling rate is about 75Hz. Let’s assume we actually implemented our Butterworth filter with FS = 100Hz and FC = 4Hz.

If we apply the normalisation, at our real FS = 75Hz, our cut-off frequency would be FC = 3Hz

And because of that we have a problem, our cut-off frequency is lower than our intended 3.66Hz. That means we could only measure up to 180BPM instead of our desired 220BPM. Nonetheless, if the update speed is even lower, we would cut-off even more frequencies we actually want to keep.

To fix this issue, we have two options available, either have a precise sampling rate or increase the cut-off frequency. Effectively increasing available sampling rate error margin and decreasing a bit the quality of the filtered signal.

I adopted the second option and chose a new FC value.

$$F_S = 100Hz$$
$$F_C = 10Hz$$

That would give us ratio of:

$$R_n = {100Hz \over 10Hz} = 0.1$$

Assuming 220BPM or that is 3.66Hz is our target frequency. Butterworth filter would now still let through desired frequencies with as low as a sample rate of:

$${3.66Hz \over 0.1} = 36.6Hz$$

In our real world example of FS = 75Hz it would give us actual FC = 7.5Hz.
I believe it is good enough for our filtering needs, because we don’t need to be ultra-precise about filtering the signal; just enough to clear it up a bit and improve the signal for detecting peaks.

Taking into account our FS = 100Hz and FC = 10Hz, we get the following code for Butterworth filter:

struct butterworthFilter_t
{
float v[2];
float result;
};
void MAX30100::lowPassButterworthFilter( float x, butterworthFilter_t * filterResult )
{
filterResult->v[0] = filterResult->v[1];

//Fs = 100Hz and Fc = 10Hz
filterResult->v[1] = (2.452372752527856026e-1 * x) + (0.50952544949442879485 * filterResult->v[0]);

//Fs = 100Hz and Fc = 4Hz
//filterResult->v[1] = (1.367287359973195227e-1 * x) + (0.72654252800536101020 * filterResult->v[0]); //Very precise butterworth filter

filterResult->result = filterResult->v[0] + filterResult->v[1];
}

Once we pass the cardiogram looking signal, we get a much smoother signal out (see figure 7)

Figure 7 Butterworth filtered signal with Fs = 100Hz and Fc = 10Hz. Real sampling rate Fs = 75Hz, which gives Fc = 7.5Hz

And that is all we have to do for our IR output. At this stage it should be pretty clear where the pulses are, and as a matter of fact it generates a nice cardiogram.

# Beat Detection

Now that we have a relatively clean signal from our MAX30100 we can start calculating the heart rate. I’ve decided to implement a very simple state machine. By no means is my design error free or industry ready. It isn’t, and can easily miss-detect pulses or not detect them at all, but it is good as proof of concept.

Idea for the state machine is very simple. Once a threshold is reached, follow the curve. As soon as one or more times the signal starts to fall, save a timestamp. Once you have two timestamps, the difference between them is our measured delay between two beats. From this we can calculate the BPM.

Arduino has a nice function called millis(), which gives you a timestamp in milliseconds. If we get two timestamps we can calculate the heart rate using this equation:

$$BPM = {60000 \over \text{current beat timestamp} - \text{previous beat timestamp} }$$

On top of that because we are calculating in such a way BPM, I’ve decided to also implement a moving average filter on the BPM results. Just to give a more accurate measurement of the heart rate.

Figure 8 State machine diagram for detecting peaks

Finally here is the code which detects and measures pulse

bool MAX30100::detectPulse(float sensor_value)
{
static float prev_sensor_value = 0;
static uint8_t values_went_down = 0;
static uint32_t currentBeat = 0;
static uint32_t lastBeat = 0;

if(sensor_value > PULSE_MAX_THRESHOLD)
{
currentPulseDetectorState = PULSE_IDLE;
prev_sensor_value = 0;
lastBeat = 0;
currentBeat = 0;
values_went_down = 0;
lastBeatThreshold = 0;
return false;
}

switch(currentPulseDetectorState)
{
case PULSE_IDLE:
if(sensor_value >= PULSE_MIN_THRESHOLD) {
currentPulseDetectorState = PULSE_TRACE_UP;
values_went_down = 0;
}
break;

case PULSE_TRACE_UP:
if(sensor_value > prev_sensor_value)
{
currentBeat = millis();
lastBeatThreshold = sensor_value;
}
else
{

if(debug == true)
{
Serial.print("Peak reached: ");
Serial.print(sensor_value);
Serial.print(" ");
Serial.println(prev_sensor_value);
}

uint32_t beatDuration = currentBeat - lastBeat;
lastBeat = currentBeat;

float rawBPM = 0;
if(beatDuration > 0)
rawBPM = 60000.0 / (float)beatDuration;
if(debug == true)
Serial.println(rawBPM);

//This method sometimes glitches, it's better to go through whole moving average everytime
//IT's a neat idea to optimize the amount of work for moving avg. but while placing, removing finger it can screw up
//valuesBPMSum -= valuesBPM[bpmIndex];
//valuesBPM[bpmIndex] = rawBPM;
//valuesBPMSum += valuesBPM[bpmIndex];

valuesBPM[bpmIndex] = rawBPM;
valuesBPMSum = 0;
for(int i=0; i<PULSE_BPM_SAMPLE_SIZE; i++)
{
valuesBPMSum += valuesBPM[i];
}

if(debug == true)
{
Serial.print("CurrentMoving Avg: ");
for(int i=0; i<PULSE_BPM_SAMPLE_SIZE; i++)
{
Serial.print(valuesBPM[i]);
Serial.print(" ");
}

Serial.println(" ");
}

bpmIndex++;
bpmIndex = bpmIndex % PULSE_BPM_SAMPLE_SIZE;

if(valuesBPMCount < PULSE_BPM_SAMPLE_SIZE)
valuesBPMCount++;

currentBPM = valuesBPMSum / valuesBPMCount;
if(debug == true)
{
Serial.print("AVg. BPM: ");
Serial.println(currentBPM);
}

currentPulseDetectorState = PULSE_TRACE_DOWN;

return true;
}
break;

case PULSE_TRACE_DOWN:
if(sensor_value < prev_sensor_value)
{
values_went_down++;
}

if(sensor_value < PULSE_MIN_THRESHOLD)
{
currentPulseDetectorState = PULSE_IDLE;
}
break;
}

prev_sensor_value = sensor_value;
return false;
}


At this stage we have applied multiple filters to our signal. Moreover we also have detected the pulse and measured the heart rate. However, as previously mentioned, this state machine can still be greatly improved and should not be used in a real product.

# Measuring SpO2

As mentioned in the introduction, oxygen concentration can be measured by calculating the ratio between absorbed light from IR LED and Red LED. In this section I will explore how it is theoretically done, but due to some limitation the sensor won’t be calibrated properly. Unfortunately for that you need proper empirical data to create a lookup table.

## Balancing IR and Red Current

First of all, we have to switch the MAX30100 mode to SaO2 + HR. That can be done by sending 0x03 to MODE config register. That will enable both LEDs and MAX30100 will start filling the FIFO buffer with readings from both light spectrums.

Also, RED readings should be passed through the same DC removal filter as IR readings. But it is not necessary to pass it through mean average filter and Butterworth filter, since we are not using RED light to detect pulses.

If you just enabled both LEDs with maximum output current of 50ma, you’ll quickly realize that readings from Red LED will be extremely saturated. Also to be able to measure the ratios between our two readings, on base level their DC levels should be nearly identical (see figure 9 and figure 10).

Figure 9 Mismatched DC levels. Difference is approximately about 380 000 (DC units). IR led has been set to 50ma and RED led to 50ma

Figure 10 More closely matched DC levels. Difference now has been reduced to 42 000 (DC units). IR led has been set to 50ma and RED led to 27.1ma

Idea is very simple:

1. Check the difference between RED and IR DC readings
2. If IRED > IIR then decrease IRED current
If IRED < IIR then increase IRED current

It is important to note, that IRED shouldn’t be changed instantly, but once in a while only if the difference is above certain threshold, which can only be determined by experimentation.

Here is the code I’ve implemented to balance IIR and IRED:

void MAX30100::balanceIntesities( float redLedDC, float IRLedDC )
{

if( millis() - lastREDLedCurrentCheck >= RED_LED_CURRENT_ADJUSTMENT_MS)
{
//Serial.println( redLedDC - IRLedDC );
if( IRLedDC - redLedDC > MAGIC_ACCEPTABLE_INTENSITY_DIFF && redLEDCurrent < MAX30100_LED_CURRENT_50MA)
{
redLEDCurrent++;
setLEDCurrents( redLEDCurrent, IrLedCurrent );
if(debug == true)
Serial.println("RED LED Current +");
}
else if(redLedDC - IRLedDC > MAGIC_ACCEPTABLE_INTENSITY_DIFF && redLEDCurrent > 0)
{
redLEDCurrent--;
setLEDCurrents( redLEDCurrent, IrLedCurrent );
if(debug == true)
Serial.println("RED LED Current -");
}

lastREDLedCurrentCheck = millis();
}
}

As I said before, you have to choose a good magic value for acceptable difference between those two readings at base state. If you choose the magic value too little, it will result in a lot of oscillation (see figure 11)

Figure 11 Oscillations invoked due to too little magic value. Magic difference in this example is set to 50 000

After some little experimentation I came to a good magic value of 65 000. This in my use case completely eliminated oscillations. And only in some rare cases the algorithm adjusted the current while being active for a while. Nonetheless, it adjusts wildly inaccurate intensity to match the IIR immediately (see figure 12)

Figure 12 Good magic value of 65 000. Immediately balances out and stays stable

That is all we have to do before we can get into calculating the SpO2 value.

## A little bit of SpO2 theory

In short SpO2 is defined as the ratio of the oxygenated Hemoglobin level over the total Hemoglobin level.

$$SpO_2 = {HbO_2 \over \text{Total Hb}}$$

Our bodies’ tissue absorbs different amounts of light depending on the blood oxygenation level. However it is important to note, that the characteristic is non-linear.

As mentioned before two different wavelengths are used IR (950nm) and RED (650nm). These two wavelengths are emitted towards your finger, earlobe etc. in alternating fashion. One is turned on, measurement is taken and then it is turned off. This repeats for the other spectrum. Basically, both of them are not measured simultaneously.

The ratio R between these two wavelengths is defined with the following equations:

$$R = { AC_\text{RMS RED} / DC_\text{RED} \over AC_\text{RMS IR} / DC_\text{IR}}$$

Or it can also be expressed like this:

$$R = { log(I_{AC}) * \lambda_1 \over log(I_{AC}) * \lambda_2}$$

IAC is the light intensity where only the AC is present. And λ1 is for 650nm wavelength and λ2 is for 950nm wavelength of light.

Quoting from TI article about pulse oximeter

Once the DC levels match, then the SpO2 is calculated by dividing the logs of the RMS values

As you know, we have already balanced our DC levels, and only thing left to do is to calculate RMS for both IIR and IRED

If you don’t know, calculating basic RMS value is extremely simple; you just have to take the sum of squares of your signal, average them and then take square root of the average. It won’t be true RMS, but more than enough for our application.

I also want to stress out, that RMS values how to be calculated for the whole signal, not only when there is a pulse. And it is advised to reset it once in a while; otherwise it will hold whole historical garbage data. In my final implementation I reset RMS every 4 heart beats.

Now that we have calculated the RMS values for both of our wavelengths, and also calculated ratio R value, only thing left to do is to calculate the actual SpO2 value.

This is where it gets very interesting. To be able to have precise measurements of the oxygen saturation, you’ll need to calibrate the sensor. There is no formula which fits them all.

Nonetheless a standard model of computing SpO2, which is referenced basically in all text books, is as follows:

$$SpO_2 = 110 - 25 * R$$

As I said before, the relationship is non-linear. But standard model is clearly suggesting a linear relationship which is not true. See figure 13 for an excellent comparison between empirical and theoretical R to SpO2

Figure 13 Empirical and Theoretical R to SaO2. Source: http://www.ti.com/lit/an/slaa274b/slaa274b.pdf

Also you should notice that even with empirical calibration, once the oxygen saturation drops below 80% you can safely assume a linear relationship.

Here is where I had a problem. I don’t really have a way of calibrating MAX30100 sensor. Neither have I a calibrated pulse oximeter for reference or other means of determining my real SpO2

Once I implemented all these calculations in practice, I got my RMS ratios between:

0.84 – 0.86

According to standard model it would yield SpO2 between 88.5% and 89%. Or according to the TI empirical curve: ~90%.

It still feels rather low, since I’m expecting at least 94% for a healthy human being. Unless I’m being very unhealthy at the time of measuring my oxygen saturation. Also, I live next to the sea, maybe about 3m above sea level. So my altitude shouldn’t be a factor for low oxygen levels.

My decision, not being the most scientific, was to just assume I have oxygen levels of 94% and I adjusted the standard model accordingly.

$$SpO_2 = 110 - 180 * R$$

I really have to stress out! This is not a scientific or proper way of determining SpO2. You must have a proper calibration in place; this is merely an estimate (and extremely poor at that)!

Nonetheless, here are the final results after implementing everything I have described in this article (see figure 14).

Figure 14 Reading from final implementation of the MAX30100 driver

In figure 14, you can see that my pulse rate is about 68.81 BPM and O2 concentration around 94.06%. I’m absolutely certain about the accuracy of heart rate measurements, since I was able to cross-check it multiple times with Omron blood pressure measuring device, which also measures BPM. At this particular instance, Omron measured my BPM to be 68.

# Conclusion

It was not as simple as I first anticipated to measure heart rate and oxygen saturation in your blood. But with persistence I was able to achieve good enough understanding on the DSP involved and the theory behind measuring SpO2 to implement it from scratch. Not only all of this is applicable to MAX30100 exclusively, but similar techniques and calculations should be done on either your own self-made sensor or a sensor manufactured by a different company than Maxim. MAX30100 gives just the convenience of integrating a rather complicated analog circuit in extremely small package.  However, from quick tests, I must say that measuring heart rate from wrist is extremely difficult with this sensor. Essentially it is impossible with the current algorithm for detecting peaks. Also it is important to remember that in the article when measuring oxygen saturation I have not properly calibrated the sensor, merely adjusted standard model to fit what I felt is right. It is highly advisable that if you do use this sensor for measuring SpO2, you must calibrate it properly.

# Code

Or from my server directly:

### Sample Usage

#include <Arduino.h>
#include <math.h>
#include <Wire.h>

#include "MAX30100.h"

MAX30100* pulseOxymeter;

void setup() {
Wire.begin();
Serial.begin(115200);
Serial.println("Pulse oxymeter test!");

//pulseOxymeter = new MAX30100( DEFAULT_OPERATING_MODE, DEFAULT_SAMPLING_RATE, DEFAULT_LED_PULSE_WIDTH, DEFAULT_IR_LED_CURRENT, true, true );
pulseOxymeter = new MAX30100();
pinMode(2, OUTPUT);

//pulseOxymeter->printRegisters();
}

void loop() {
//return;
//You have to call update with frequency at least 37Hz. But the closer you call it to 100Hz the better, the filter will work.
pulseoxymeter_t result = pulseOxymeter->update();

if( result.pulseDetected == true )
{
Serial.println("BEAT");

Serial.print( "BPM: " );
Serial.print( result.heartBPM );
Serial.print( " | " );

Serial.print( "SaO2: " );
Serial.print( result.SaO2 );
Serial.println( "%" );

Serial.print("{P2|BPM|255,40,0|");
Serial.print(result.heartBPM);
Serial.print("|SaO2|0,0,255|");
Serial.print(result.SaO2);
Serial.println("}");
}

//These are special packets for FlexiPlot plotting tool
Serial.print("{P0|IR|0,0,255|");
Serial.print(result.dcFilteredIR);
Serial.print("|RED|255,0,0|");
Serial.print(result.dcFilteredRed);
Serial.println("}");

Serial.print("{P1|RED|255,0,255|");
Serial.print(result.irCardiogram);
Serial.print("|BEAT|0,0,255|");
Serial.print(result.lastBeatThreshold);
Serial.println("}");

delay(10);

//Basic way of determening execution of the loop via oscoliscope
}

### MAX30100.h

/*

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

*/

#ifndef MAX30100_H
#define MAX30100_H

#include <Arduino.h>
#include <Wire.h>
#include <math.h>
/*----------------------------------------------*/
/* Config defines, you can tailor to your needs */
/*----------------------------------------------*/

/* MAX30100 parameters */
#define DEFAULT_OPERATING_MODE            MAX30100_MODE_SPO2_HR
/*!!!IMPORTANT
* You can't just throw these two values at random. Check Check table 8 in datasheet on page 19.
* 100hz + 1600us is max for that resolution
*/
#define DEFAULT_SAMPLING_RATE             MAX30100_SAMPLING_RATE_100HZ

#define DEFAULT_IR_LED_CURRENT            MAX30100_LED_CURRENT_50MA
#define STARTING_RED_LED_CURRENT          MAX30100_LED_CURRENT_27_1MA

/* Adjust RED LED current balancing*/
#define MAGIC_ACCEPTABLE_INTENSITY_DIFF         65000

/* SaO2 parameters */
#define RESET_SPO2_EVERY_N_PULSES     4

/* Filter parameters */
#define ALPHA 0.95  //dc filter alpha value
#define MEAN_FILTER_SIZE        15

/* Pulse detection parameters */
#define PULSE_MIN_THRESHOLD         100 //300 is good for finger, but for wrist you need like 20, and there is shitloads of noise
#define PULSE_MAX_THRESHOLD         2000
#define PULSE_GO_DOWN_THRESHOLD     1

#define PULSE_BPM_SAMPLE_SIZE       10 //Moving average size

/* Enums, data structures and typdefs. DO NOT EDIT */
struct pulseoxymeter_t {
bool pulseDetected;
float heartBPM;

float irCardiogram;

float irDcValue;
float redDcValue;

float SaO2;

uint32_t lastBeatThreshold;

float dcFilteredIR;
float dcFilteredRed;
};

typedef enum PulseStateMachine {
PULSE_IDLE,
PULSE_TRACE_UP,
PULSE_TRACE_DOWN
} PulseStateMachine;

struct fifo_t {
uint16_t rawIR;
uint16_t rawRed;
};

struct dcFilter_t {
float w;
float result;
};

struct butterworthFilter_t
{
float v[2];
float result;
};

struct meanDiffFilter_t
{
float values[MEAN_FILTER_SIZE];
byte index;
float sum;
byte count;
};

/* MAX30100 register and bit defines, DO NOT EDIT */
#define MAX30100_DEVICE                   0x57

//Part ID Registers
#define MAX30100_REV_ID                   0xFE
#define MAX30100_PART_ID                  0xFF

//status registers
#define MAX30100_INT_STATUS               0x00
#define MAX30100_INT_ENABLE               0x01

//Fifo registers
#define MAX30100_FIFO_WRITE               0x02
#define MAX30100_FIFO_OVERFLOW_COUNTER    0x03
#define MAX30100_FIFO_DATA                0x05

//Config registers
#define MAX30100_MODE_CONF                0x06
#define MAX30100_SPO2_CONF                0x07
#define MAX30100_LED_CONF                 0x09

//Temperature registers
#define MAX30100_TEMP_INT                 0x16
#define MAX30100_TEMP_FRACTION            0x17

//Bit defines MODE Regsiter
#define MAX30100_MODE_SHDN                (1<<7)
#define MAX30100_MODE_RESET               (1<<6)
#define MAX30100_MODE_TEMP_EN             (1<<3)

typedef enum Mode {
MAX30100_MODE_HR_ONLY                 = 0x02,
MAX30100_MODE_SPO2_HR                 = 0x03
} Mode;

//Bit defines SpO2 register
#define MAX30100_SPO2_HI_RES_EN           (1 << 6)
typedef enum SamplingRate {
MAX30100_SAMPLING_RATE_50HZ           = 0x00,
MAX30100_SAMPLING_RATE_100HZ          = 0x01,
MAX30100_SAMPLING_RATE_167HZ          = 0x02,
MAX30100_SAMPLING_RATE_200HZ          = 0x03,
MAX30100_SAMPLING_RATE_400HZ          = 0x04,
MAX30100_SAMPLING_RATE_600HZ          = 0x05,
MAX30100_SAMPLING_RATE_800HZ          = 0x06,
MAX30100_SAMPLING_RATE_1000HZ         = 0x07
} SamplingRate;

typedef enum LEDPulseWidth {
} LEDPulseWidth;

typedef enum LEDCurrent {
MAX30100_LED_CURRENT_0MA              = 0x00,
MAX30100_LED_CURRENT_4_4MA            = 0x01,
MAX30100_LED_CURRENT_7_6MA            = 0x02,
MAX30100_LED_CURRENT_11MA             = 0x03,
MAX30100_LED_CURRENT_14_2MA           = 0x04,
MAX30100_LED_CURRENT_17_4MA           = 0x05,
MAX30100_LED_CURRENT_20_8MA           = 0x06,
MAX30100_LED_CURRENT_24MA             = 0x07,
MAX30100_LED_CURRENT_27_1MA           = 0x08,
MAX30100_LED_CURRENT_30_6MA           = 0x09,
MAX30100_LED_CURRENT_33_8MA           = 0x0A,
MAX30100_LED_CURRENT_37MA             = 0x0B,
MAX30100_LED_CURRENT_40_2MA           = 0x0C,
MAX30100_LED_CURRENT_43_6MA           = 0x0D,
MAX30100_LED_CURRENT_46_8MA           = 0x0E,
MAX30100_LED_CURRENT_50MA             = 0x0F
} LEDCurrent;

class MAX30100
{
public:
MAX30100( Mode mode = DEFAULT_OPERATING_MODE,
SamplingRate samplingRate = DEFAULT_SAMPLING_RATE,
LEDPulseWidth pulseWidth = DEFAULT_LED_PULSE_WIDTH,
LEDCurrent IrLedCurrent = DEFAULT_IR_LED_CURRENT,
bool highResMode = true,
bool debug = false
);

pulseoxymeter_t update();

void setMode(Mode mode);
void setHighresModeEnabled(bool enabled);
void setSamplingRate(SamplingRate rate);
void setLEDPulseWidth(LEDPulseWidth pw);
void setLEDCurrents( byte redLedCurrent, byte IRLedCurrent );
void printRegisters();

dcFilter_t dcRemoval(float x, float prev_w, float alpha);
void lowPassButterworthFilter( float x, butterworthFilter_t * filterResult );
float meanDiff(float M, meanDiffFilter_t* filterValues);

private:
bool detectPulse(float sensor_value);
void balanceIntesities( float redLedDC, float IRLedDC );

private:
bool debug;

uint8_t redLEDCurrent;
float lastREDLedCurrentCheck;

uint8_t currentPulseDetectorState;
float currentBPM;
float valuesBPM[PULSE_BPM_SAMPLE_SIZE];
float valuesBPMSum;
uint8_t valuesBPMCount;
uint8_t bpmIndex;
uint32_t lastBeatThreshold;

fifo_t prevFifo;

dcFilter_t dcFilterIR;
dcFilter_t dcFilterRed;
butterworthFilter_t lpbFilterIR;
meanDiffFilter_t meanDiffIR;

float irACValueSqSum;
float redACValueSqSum;
uint16_t samplesRecorded;
uint16_t pulsesDetected;
float currentSaO2Value;

LEDCurrent IrLedCurrent;
};

#endif

### MAX30100.cpp

/*

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

*/

#include "MAX30100.h"

MAX30100::MAX30100(
Mode mode,
SamplingRate samplingRate,
LEDPulseWidth pulseWidth,
LEDCurrent IrLedCurrent,
bool highResMode,
bool debug)
{
this->debug = debug;
currentPulseDetectorState = PULSE_IDLE;

setMode(  mode );

//Check table 8 in datasheet on page 19. You can't just throw in sample rate and pulse width randomly. 100hz + 1600us is max for that resolution
setSamplingRate( samplingRate );
setLEDPulseWidth( pulseWidth );

redLEDCurrent = (uint8_t)STARTING_RED_LED_CURRENT;
lastREDLedCurrentCheck = 0;

this->IrLedCurrent = IrLedCurrent;
setLEDCurrents(redLEDCurrent, IrLedCurrent );
setHighresModeEnabled(highResMode);

dcFilterIR.w = 0;
dcFilterIR.result = 0;

dcFilterRed.w = 0;
dcFilterRed.result = 0;

lpbFilterIR.v[0] = 0;
lpbFilterIR.v[1] = 0;
lpbFilterIR.result = 0;

meanDiffIR.index = 0;
meanDiffIR.sum = 0;
meanDiffIR.count = 0;

valuesBPM[0] = 0;
valuesBPMSum = 0;
valuesBPMCount = 0;
bpmIndex = 0;

irACValueSqSum = 0;
redACValueSqSum = 0;
samplesRecorded = 0;
pulsesDetected = 0;
currentSaO2Value = 0;

lastBeatThreshold = 0;

}

pulseoxymeter_t MAX30100::update()
{
pulseoxymeter_t result = {
/*bool pulseDetected*/ false,
/*float heartBPM*/ 0.0,
/*float irCardiogram*/ 0.0,
/*float irDcValue*/ 0.0,
/*float redDcValue*/ 0.0,
/*float SaO2*/ currentSaO2Value,
/*uint32_t lastBeatThreshold*/ 0,
/*float dcFilteredIR*/ 0.0,
/*float dcFilteredRed*/ 0.0
};

dcFilterIR = dcRemoval( (float)rawData.rawIR, dcFilterIR.w, ALPHA );
dcFilterRed = dcRemoval( (float)rawData.rawRed, dcFilterRed.w, ALPHA );

float meanDiffResIR = meanDiff( dcFilterIR.result, &meanDiffIR);
lowPassButterworthFilter( meanDiffResIR/*-dcFilterIR.result*/, &lpbFilterIR );

irACValueSqSum += dcFilterIR.result * dcFilterIR.result;
redACValueSqSum += dcFilterRed.result * dcFilterRed.result;
samplesRecorded++;

if( detectPulse( lpbFilterIR.result ) && samplesRecorded > 0 )
{
result.pulseDetected=true;
pulsesDetected++;

float ratioRMS = log( sqrt(redACValueSqSum/samplesRecorded) ) / log( sqrt(irACValueSqSum/samplesRecorded) );

if( debug == true )
{
Serial.print("RMS Ratio: ");
Serial.println(ratioRMS);
}

//This is my adjusted standard model, so it shows 0.89 as 94% saturation. It is probably far from correct, requires proper empircal calibration
currentSaO2Value = 110.0 - 18.0 * ratioRMS;
result.SaO2 = currentSaO2Value;

if( pulsesDetected % RESET_SPO2_EVERY_N_PULSES == 0)
{
irACValueSqSum = 0;
redACValueSqSum = 0;
samplesRecorded = 0;
}
}

balanceIntesities( dcFilterRed.w, dcFilterIR.w );

result.heartBPM = currentBPM;
result.irCardiogram = lpbFilterIR.result;
result.irDcValue = dcFilterIR.w;
result.redDcValue = dcFilterRed.w;
result.lastBeatThreshold = lastBeatThreshold;
result.dcFilteredIR = dcFilterIR.result;
result.dcFilteredRed = dcFilterRed.result;

return result;
}

bool MAX30100::detectPulse(float sensor_value)
{
static float prev_sensor_value = 0;
static uint8_t values_went_down = 0;
static uint32_t currentBeat = 0;
static uint32_t lastBeat = 0;

if(sensor_value > PULSE_MAX_THRESHOLD)
{
currentPulseDetectorState = PULSE_IDLE;
prev_sensor_value = 0;
lastBeat = 0;
currentBeat = 0;
values_went_down = 0;
lastBeatThreshold = 0;
return false;
}

switch(currentPulseDetectorState)
{
case PULSE_IDLE:
if(sensor_value >= PULSE_MIN_THRESHOLD) {
currentPulseDetectorState = PULSE_TRACE_UP;
values_went_down = 0;
}
break;

case PULSE_TRACE_UP:
if(sensor_value > prev_sensor_value)
{
currentBeat = millis();
lastBeatThreshold = sensor_value;
}
else
{

if(debug == true)
{
Serial.print("Peak reached: ");
Serial.print(sensor_value);
Serial.print(" ");
Serial.println(prev_sensor_value);
}

uint32_t beatDuration = currentBeat - lastBeat;
lastBeat = currentBeat;

float rawBPM = 0;
if(beatDuration > 0)
rawBPM = 60000.0 / (float)beatDuration;
if(debug == true)
Serial.println(rawBPM);

//This method sometimes glitches, it's better to go through whole moving average everytime
//IT's a neat idea to optimize the amount of work for moving avg. but while placing, removing finger it can screw up
//valuesBPMSum -= valuesBPM[bpmIndex];
//valuesBPM[bpmIndex] = rawBPM;
//valuesBPMSum += valuesBPM[bpmIndex];

valuesBPM[bpmIndex] = rawBPM;
valuesBPMSum = 0;
for(int i=0; i<PULSE_BPM_SAMPLE_SIZE; i++)
{
valuesBPMSum += valuesBPM[i];
}

if(debug == true)
{
Serial.print("CurrentMoving Avg: ");
for(int i=0; i<PULSE_BPM_SAMPLE_SIZE; i++)
{
Serial.print(valuesBPM[i]);
Serial.print(" ");
}

Serial.println(" ");
}

bpmIndex++;
bpmIndex = bpmIndex % PULSE_BPM_SAMPLE_SIZE;

if(valuesBPMCount < PULSE_BPM_SAMPLE_SIZE)
valuesBPMCount++;

currentBPM = valuesBPMSum / valuesBPMCount;
if(debug == true)
{
Serial.print("AVg. BPM: ");
Serial.println(currentBPM);
}

currentPulseDetectorState = PULSE_TRACE_DOWN;

return true;
}
break;

case PULSE_TRACE_DOWN:
if(sensor_value < prev_sensor_value)
{
values_went_down++;
}

if(sensor_value < PULSE_MIN_THRESHOLD)
{
currentPulseDetectorState = PULSE_IDLE;
}
break;
}

prev_sensor_value = sensor_value;
return false;
}

void MAX30100::balanceIntesities( float redLedDC, float IRLedDC )
{

if( millis() - lastREDLedCurrentCheck >= RED_LED_CURRENT_ADJUSTMENT_MS)
{
//Serial.println( redLedDC - IRLedDC );
if( IRLedDC - redLedDC > MAGIC_ACCEPTABLE_INTENSITY_DIFF && redLEDCurrent < MAX30100_LED_CURRENT_50MA)
{
redLEDCurrent++;
setLEDCurrents( redLEDCurrent, IrLedCurrent );
if(debug == true)
Serial.println("RED LED Current +");
}
else if(redLedDC - IRLedDC > MAGIC_ACCEPTABLE_INTENSITY_DIFF && redLEDCurrent > 0)
{
redLEDCurrent--;
setLEDCurrents( redLEDCurrent, IrLedCurrent );
if(debug == true)
Serial.println("RED LED Current -");
}

lastREDLedCurrentCheck = millis();
}
}

// Writes val to address register on device
{
Wire.beginTransmission(MAX30100_DEVICE); // start transmission to device
Wire.write(val); // send value to write
Wire.endTransmission(); // end transmission
}

{
Wire.beginTransmission(MAX30100_DEVICE);
Wire.endTransmission(false);
Wire.requestFrom(MAX30100_DEVICE, 1);

}

// Reads num bytes starting from address register on device in to _buff array
{
Wire.beginTransmission(MAX30100_DEVICE); // start transmission to device
Wire.endTransmission(false); // end transmission

Wire.requestFrom(MAX30100_DEVICE, num); // request 6 bytes from device Registers: DATAX0, DATAX1, DATAY0, DATAY1, DATAZ0, DATAZ1

int i = 0;
while(Wire.available()) // device may send less than requested (abnormal)
{
}

Wire.endTransmission(); // end transmission
}

void MAX30100::setMode(Mode mode)
{
byte currentModeReg = readRegister( MAX30100_MODE_CONF );
writeRegister( MAX30100_MODE_CONF, (currentModeReg & 0xF8) | mode );
}

void MAX30100::setHighresModeEnabled(bool enabled)
{
if (enabled) {
writeRegister(MAX30100_SPO2_CONF, previous | MAX30100_SPO2_HI_RES_EN);
} else {
writeRegister(MAX30100_SPO2_CONF, previous & ~MAX30100_SPO2_HI_RES_EN);
}
}

void MAX30100::setSamplingRate(SamplingRate rate)
{
byte currentSpO2Reg = readRegister( MAX30100_SPO2_CONF );
writeRegister( MAX30100_SPO2_CONF, ( currentSpO2Reg & 0xE3 ) | (rate<<2) );
}

void MAX30100::setLEDPulseWidth(LEDPulseWidth pw)
{
byte currentSpO2Reg = readRegister( MAX30100_SPO2_CONF );
writeRegister( MAX30100_SPO2_CONF, ( currentSpO2Reg & 0xFC ) | pw );
}

void MAX30100::setLEDCurrents( byte redLedCurrent, byte IRLedCurrent )
{
writeRegister( MAX30100_LED_CONF, (redLedCurrent << 4) | IRLedCurrent );
}

{
byte currentModeReg = readRegister( MAX30100_MODE_CONF );
writeRegister( MAX30100_MODE_CONF, currentModeReg | MAX30100_MODE_TEMP_EN );

delay(100); //This can be changed to a while loop, there is an interrupt flag for when temperature has been read.

int8_t temp = (int8_t)readRegister( MAX30100_TEMP_INT );
float tempFraction = (float)readRegister( MAX30100_TEMP_FRACTION ) * 0.0625;

return (float)temp + tempFraction;
}

{
fifo_t result;

byte buffer[4];
result.rawIR = (buffer[0] << 8) | buffer[1];
result.rawRed = (buffer[2] << 8) | buffer[3];

return result;
}

dcFilter_t MAX30100::dcRemoval(float x, float prev_w, float alpha)
{
dcFilter_t filtered;
filtered.w = x + alpha * prev_w;
filtered.result = filtered.w - prev_w;

return filtered;
}

void MAX30100::lowPassButterworthFilter( float x, butterworthFilter_t * filterResult )
{
filterResult->v[0] = filterResult->v[1];

//Fs = 100Hz and Fc = 10Hz
filterResult->v[1] = (2.452372752527856026e-1 * x) + (0.50952544949442879485 * filterResult->v[0]);

//Fs = 100Hz and Fc = 4Hz
//filterResult->v[1] = (1.367287359973195227e-1 * x) + (0.72654252800536101020 * filterResult->v[0]); //Very precise butterworth filter

filterResult->result = filterResult->v[0] + filterResult->v[1];
}

float MAX30100::meanDiff(float M, meanDiffFilter_t* filterValues)
{
float avg = 0;

filterValues->sum -= filterValues->values[filterValues->index];
filterValues->values[filterValues->index] = M;
filterValues->sum += filterValues->values[filterValues->index];

filterValues->index++;
filterValues->index = filterValues->index % MEAN_FILTER_SIZE;

if(filterValues->count < MEAN_FILTER_SIZE)
filterValues->count++;

avg = filterValues->sum / filterValues->count;
return avg - M;
}

void MAX30100::printRegisters()
{
}


# References

ElectronicsTutorials. “Butterworth Filter Design.” ElectronicsTutorials. n.d. http://www.electronics-tutorials.ws/filter/filter_8.html (accessed February 17, 2017).

Erken, Eray. How to Measure Heart Rate with MAX30100 Pulse Oximeter. 30 April 2016. https://www.youtube.com/watch?v=36cELpNGJZYHow to Measure Heart Rate with MAX30100 Pulse Oximeter (accessed December 1, 2016).

Intersecans, OXullo. Arduino-MAX30100. n.d. https://github.com/oxullo/Arduino-MAX30100 (accessed January 20, 2017).

IOP Institute of Physics. Explaining rms voltage and current. n.d. http://practicalphysics.org/explaining-rms-voltage-and-current.html (accessed February 27, 2017).

Iowegian International. “IIR Filter Basics.” dspGuru. n.d. http://dspguru.com/dsp/faqs/iir/basics (accessed December 3, 2016).

Kennedy, Stephen M. “AN INTRODUCTION TO PULSE OXIMETERS: EQUATIONS AND THEORY.” University of Wisconsin-Madison. 20 April 2015. http://www.imt.liu.se/FoUtb/kurser/oxikurs/restricted/pulseoximetersequationsandtheory-stephenkennedy.pdf (accessed January 15, 2017).

Koblenski, Sam. Everyday DSP for Programmers: DC and Impulsive Noise Removal. 23 November 2015. http://sam-koblenski.blogspot.co.uk/2015/11/everyday-dsp-for-programmers-dc-and.html (accessed December 4, 2016).

Lopez, Santiago. “Pulse Oximeter Fundamentals and Design.” NXP. November 2012. http://www.nxp.com/assets/documents/data/en/application-notes/AN4327.pdf?&tid=AMdlDR (accessed January 17, 2017).

Maxim. “Pulse Oximeter and Heart-Rate Sensor IC for Wearable Health.” MaximIntegrated. September 2014. https://

• #### Implementing pulse oximeter using MAX30100

Mar 8, 2017 | by Raivis Strogonovs
• #### nRF51 Makefile with Qt Creator

Jun 4, 2016 | by Raivis Strogonovs
• #### USART, FreeRTOS and C++ on nRF51

Dec 14, 2015 | by Raivis Strogonovs
• #### Starting with nRF51 BLE and Qt Creator

Dec 12, 2015 | by Raivis Strogonovs
• #### Touch gesture recognition using body capacitance

Nov 29, 2014 | by Raivis Strogonovs
• #### Introduction to data encryption

Oct 4, 2014 | by Raivis Strogonovs
• #### MEMS (Part 2) – Guide to using gyroscope L3G4200D

Jun 17, 2014 | by Raivis Strogonovs
• #### seo in nRF51 Makefile with Qt Creator

thanksssssssssssssssssssss gfhfgjg

gfhfgjg

#### Rick Simpsonoil Meds in Strain Gauge based weight sensor (load cell)

Rick Simpson Oil (RSO) is a full spectrum extract of cannabis oil. It is a highly concentrated cannabis oil that contains all compounds from the cannabis plant in the extraction. It is also commonly known as Rick Simpson Oil, named after Rick Simpson who developed the product that helped...

#### Rick Simpsonoil Meds in Strain Gauge based weight sensor (load cell)

Rick Simpson Oil (RSO) is a full spectrum extract of cannabis oil. It is a highly concentrated cannabis oil that contains all compounds from the cannabis plant in the extraction. It is also commonly known as Rick Simpson Oil, named after Rick Simpson who developed the product that helped...

#### Johannes Adendorff in Simple TLS/SSL SMTP client for Qt5

DId you get BCC or CC that working?

#### Johannes Adendorff in Simple TLS/SSL SMTP client for Qt5

DId you get BCC or CC that working?

#### Sagie Meshulam in MEMS (Part 1) - Guide to using accelerometer ADXL345

Do you know if I can use flexiplot and Visual Code using C++? if so, how