Chapter 79: Health sensors¶
What: PPG (photoplethysmography) sensors, Maxim MAX30100 (the original) and MAX30102 (the improved successor). Both: red + IR LED + photodiode + FIFO + I²C. They give you raw light-intensity samples from a finger (or earlobe, forehead). Your code extracts heart rate and SpO₂ (blood-oxygen saturation) from those samples. Protocol, FIFO mechanics, from-scratch IIO driver, and a sketch of the HR/SpO₂ extraction algorithm. IIO: Industrial I/O, Linux’s subsystem for sensors, ADCs, DACs, and buffered sampled data.
Why: “wellness” features (fitness bands, smart watches, baby monitors, medical IoT) all use PPG. The chip is cheap, the wiring trivial, the principle is simple, but the work is in the signal processing on the host side. This chapter covers the chip end-to-end and points at what user-space must do.
Focus: The chip delivers light-intensity samples. Your code turns those samples into heart rate and SpO₂, the chip cannot do this for you. Heart rate comes from counting peaks in the IR signal. SpO₂ comes from the ratio of red-AC/red-DC to IR-AC/IR-DC mapped through an empirical curve. The driver delivers raw 18-bit samples at 100 Hz. User-space filters, finds peaks, computes. Without good signal processing, the readings are unreliable. The chip cannot compensate for bad code on the host.
79.1 Chip comparison¶
Maxim MAX30100 |
Maxim MAX30102 |
Maxim MAX30105 |
|
|---|---|---|---|
Channels |
red + IR |
red + IR |
red + IR + green (particle sense) |
Resolution |
14-bit |
18-bit |
18-bit |
Sample rates |
50/100/167/200/400/600/800/1000 Hz |
50–3200 Hz |
50–3200 Hz |
FIFO |
16 samples |
32 samples |
32 samples |
Sensitivity |
per-LED current 0–50 mA |
0–51 mA |
0–51 mA |
Operating voltage |
1.7, 2.0 V (sensor) + 3.3 V (I/O) |
1.7, 2.0 V + 3.3 V |
1.7, 2.0 V + 3.3 V |
I²C address |
0x57 |
0x57 |
0x57 |
Idle current |
0.6 µA shutdown |
0.7 µA |
0.7 µA |
Status |
EOL, replaced by MAX30102 |
active |
active |
Volume price |
$4 (older modules) |
$6–8 |
$10–12 |
Pick guide:
MAX30102 for new designs. Better signal, larger FIFO.
MAX30100 for legacy / module-already-on-hand.
MAX30105 when you need a third LED (green for “is there a finger present?” via particle sensing).
79.2 The physics, photoplethysmography¶
Shine light into tissue. Some of it is absorbed by blood. The rest reflects or scatters back to a photodiode. As your heart beats, the volume of blood in capillaries fluctuates → light absorption fluctuates → photodiode current fluctuates. That AC component is the PPG signal.
The pulsatile signal is small, about 1 % of the DC level. Most of the light hitting the photodiode is the constant amount that passes through tissue without modulation. The pulsatile AC component is the signal we actually want.
For SpO₂, you measure with two wavelengths:
Red (660 nm): oxygenated hemoglobin absorbs less red light.
IR (940 nm): deoxygenated hemoglobin absorbs less IR light.
Compute:
R = (AC_red / DC_red) / (AC_IR / DC_IR)
SpO₂ ≈ 110 - 25·R (empirical for fingertip PPG; calibration-dependent)
The “110 − 25·R” is fitted to clinical pulse-oximeter data. Different chips, different fits, Maxim publishes a recommended table for each.
For heart rate, you find peaks in the IR-AC signal. Peak-to-peak time × 60 = BPM. Modern algorithms use FFT-based methods or autocorrelation for robustness against motion artifacts.
79.3 Protocol, MAX30102¶
Register map (the highlights):
Reg |
Name |
Purpose |
|---|---|---|
0x00 |
Interrupt Status 1 |
Bit 7 = FIFO almost full, bit 6 = new sample, bit 0 = power-ready |
0x02 |
Interrupt Enable 1 |
Mask of interrupts |
0x04 |
FIFO Write Pointer |
Index where chip writes next sample |
0x05 |
FIFO Overflow Counter |
How many samples lost |
0x06 |
FIFO Read Pointer |
Reader-controlled pointer; chip ignores beyond this |
0x07 |
FIFO Data Register |
Read here to get samples |
0x08 |
FIFO Configuration |
Sample averaging, rollover, almost-full threshold |
0x09 |
Mode Configuration |
Reset, shutdown, mode (HR=0x02, SpO₂=0x03, multi-LED=0x07) |
0x0A |
SpO₂ Configuration |
ADC range, sample rate, pulse width |
0x0C |
LED1 (red) Pulse Amplitude |
0..255, controls LED current |
0x0D |
LED2 (IR) Pulse Amplitude |
0..255 |
0xFF |
Part ID |
0x15 for MAX30102 |
Bring-up sequence¶
Write 0x09 = 0x40 → reset.
Wait ~10 ms.
Write 0x02 = 0xC0 → enable FIFO-almost-full + new-data IRQs.
Write 0x08 = 0x4F → average 4 samples per FIFO entry, FIFO rollover OFF, almost-full at 17 samples (=32-15).
Write 0x09 = 0x03 → SpO₂ mode (both LEDs).
Write 0x0A = 0x27 → ADC range 4096 nA, sample rate 100 Hz, pulse width 411 µs (18-bit).
Write 0x0C = 0x24 → LED1 (red) current ~7 mA.
Write 0x0D = 0x24 → LED2 (IR) current ~7 mA.
Wait for IRQ or poll. Each FIFO entry is 6 bytes: 3 bytes red + 3 bytes IR.
MCU bridge: Think of an IRQ like an EXTI/NVIC interrupt path, except Linux splits the hard interrupt from deferred work and must share lines across drivers. IRQ: interrupt request, the signal path that tells the CPU or interrupt controller that hardware needs service.
Reading the FIFO¶
Host: write 0x07 to set register pointer, then read N×6 bytes back.
N = (write_ptr - read_ptr) mod 32
Each 3-byte sample is 18 bits packed in 24 bits, the top 6 bits are zero. To extract:
u32 red_sample = ((buf[0] << 16) | (buf[1] << 8) | buf[2]) & 0x3FFFF;
u32 ir_sample = ((buf[3] << 16) | (buf[4] << 8) | buf[5]) & 0x3FFFF;
The chip increments its write pointer. You increment your read pointer (write to 0x06). FIFO empty when write_ptr == read_ptr.
79.4 How a mainline driver would work¶
As of v6.6, mainline does include IIO drivers, drivers/iio/health/max30100.c and drivers/iio/health/max30102.c, that handle the basics (FIFO drain via IRQ, raw red/IR/green samples to a triggered buffer). Most ecosystems still ship Arduino-style ports with the HR/SpO₂ math built in. The mainline drivers deliberately leave the DSP for user space. A complete in-kernel driver would:
Read part-id at probe (verify 0x15 for MAX30102).
Configure via DT properties (sample rate, LED current).
Register an IIO device with two
IIO_INTENSITYchannels (red, IR).Set up triggered buffered capture using either the chip’s data-ready IRQ or an hrtimer.
Push samples into the IIO buffer on FIFO-almost-full IRQ.
User-space then sees /dev/iio:device0 streaming 6-byte samples at 100 Hz, processes them.
The MAX30102’s data-ready IRQ pin connects to a GPIO. The driver uses request_threaded_irq (Ch 43) → drains the FIFO → pushes to IIO buffer (Ch 49/70). It’s an IIO driver with a FIFO. Structurally identical to the IMU drivers in Ch 70–71, just with intensity channels instead of accel/gyro.
MCU bridge: Think of Linux GPIO like the same pin set/reset block you used on STM32, but accessed through a kernel subsystem that owns numbering, direction, interrupts, and user-space exposure. GPIO: General-Purpose Input/Output, a pin controlled as a digital input, output, or interrupt source.
79.5 Writing a MAX30102 driver from scratch¶
We’ll implement the full driver with triggered buffered IIO capture driven by the chip’s data-ready IRQ. ~300 lines.
mymax30102.c:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/delay.h>
#include <linux/iio/iio.h>
#include <linux/iio/buffer.h>
#include <linux/iio/triggered_buffer.h>
#include <linux/iio/trigger_consumer.h>
#include <linux/interrupt.h>
#define REG_INT_STATUS_1 0x00
#define REG_INT_ENABLE_1 0x02
#define REG_FIFO_WR_PTR 0x04
#define REG_FIFO_OVF 0x05
#define REG_FIFO_RD_PTR 0x06
#define REG_FIFO_DATA 0x07
#define REG_FIFO_CONFIG 0x08
#define REG_MODE_CONFIG 0x09
#define REG_SPO2_CONFIG 0x0A
#define REG_LED1_PA 0x0C
#define REG_LED2_PA 0x0D
#define REG_PART_ID 0xFF
#define PART_ID_VAL 0x15
struct mymax30102 {
struct i2c_client *client;
struct mutex lock;
};
static int mh_init(struct mymax30102 *m)
{
int err, part_id;
part_id = i2c_smbus_read_byte_data(m->client, REG_PART_ID);
if (part_id < 0) return part_id;
if (part_id != PART_ID_VAL) return -ENODEV;
/* Reset */
err = i2c_smbus_write_byte_data(m->client, REG_MODE_CONFIG, 0x40);
if (err) return err;
msleep(10);
/* Enable data-ready IRQ + FIFO-almost-full */
err = i2c_smbus_write_byte_data(m->client, REG_INT_ENABLE_1, 0xC0);
if (err) return err;
/* FIFO config: avg=4 samples per entry, rollover OFF, almost-full at 15 */
err = i2c_smbus_write_byte_data(m->client, REG_FIFO_CONFIG, 0x4F);
if (err) return err;
/* Mode: SpO2 (both LEDs) */
err = i2c_smbus_write_byte_data(m->client, REG_MODE_CONFIG, 0x03);
if (err) return err;
/* SpO2 config: ADC 4096 nA, sample rate 100 Hz, pulse width 411 µs (18-bit) */
err = i2c_smbus_write_byte_data(m->client, REG_SPO2_CONFIG, 0x27);
if (err) return err;
/* LED currents (~7 mA each — about right for a fingertip; reduce for earlobe) */
err = i2c_smbus_write_byte_data(m->client, REG_LED1_PA, 0x24);
if (err) return err;
err = i2c_smbus_write_byte_data(m->client, REG_LED2_PA, 0x24);
if (err) return err;
/* Reset FIFO pointers */
i2c_smbus_write_byte_data(m->client, REG_FIFO_WR_PTR, 0);
i2c_smbus_write_byte_data(m->client, REG_FIFO_OVF, 0);
i2c_smbus_write_byte_data(m->client, REG_FIFO_RD_PTR, 0);
return 0;
}
static int mh_read_fifo_count(struct mymax30102 *m)
{
int wr = i2c_smbus_read_byte_data(m->client, REG_FIFO_WR_PTR);
int rd = i2c_smbus_read_byte_data(m->client, REG_FIFO_RD_PTR);
if (wr < 0 || rd < 0) return -EIO;
int count = wr - rd;
if (count < 0) count += 32;
return count;
}
static int mh_read_one_sample(struct mymax30102 *m, u32 *red, u32 *ir)
{
u8 buf[6];
int err = i2c_smbus_read_i2c_block_data(m->client, REG_FIFO_DATA, 6, buf);
if (err < 0) return err;
if (err != 6) return -EIO;
*red = ((buf[0] << 16) | (buf[1] << 8) | buf[2]) & 0x3FFFF;
*ir = ((buf[3] << 16) | (buf[4] << 8) | buf[5]) & 0x3FFFF;
return 0;
}
/* === IIO INFO_PROCESSED (one-shot read; drains entire FIFO, returns latest) === */
static int mh_read_raw(struct iio_dev *idev,
struct iio_chan_spec const *chan,
int *val, int *val2, long mask)
{
struct mymax30102 *m = iio_priv(idev);
u32 red, ir;
int err, n;
if (mask != IIO_CHAN_INFO_RAW) return -EINVAL;
mutex_lock(&m->lock);
n = mh_read_fifo_count(m);
if (n <= 0) {
mutex_unlock(&m->lock);
return -EAGAIN;
}
/* Drain all but the latest */
while (n-- > 1) mh_read_one_sample(m, &red, &ir);
err = mh_read_one_sample(m, &red, &ir);
mutex_unlock(&m->lock);
if (err) return err;
switch (chan->channel2) {
case IIO_MOD_LIGHT_RED: *val = red; return IIO_VAL_INT;
case IIO_MOD_LIGHT_IR: *val = ir; return IIO_VAL_INT;
}
return -EINVAL;
}
#define MAX_CHAN(_mod, _idx) { \
.type = IIO_INTENSITY, .modified = 1, .channel2 = (_mod), \
.info_mask_separate = BIT(IIO_CHAN_INFO_RAW), \
.scan_index = (_idx), \
.scan_type = { .sign='u', .realbits=18, .storagebits=32, \
.endianness=IIO_BE }, \
}
static const struct iio_chan_spec mh_channels[] = {
MAX_CHAN(IIO_MOD_LIGHT_RED, 0),
MAX_CHAN(IIO_MOD_LIGHT_IR, 1),
IIO_CHAN_SOFT_TIMESTAMP(2),
};
static const struct iio_info mh_iio_info = {
.read_raw = mh_read_raw,
};
/* === IRQ handler: drain FIFO and push samples to IIO buffer === */
static irqreturn_t mh_irq_thread(int irq, void *p)
{
struct iio_dev *idev = p;
struct mymax30102 *m = iio_priv(idev);
int int_status, count;
u32 red, ir;
u32 sample[2 + 2]; /* 2 u32 channels + 8 bytes timestamp */
mutex_lock(&m->lock);
int_status = i2c_smbus_read_byte_data(m->client, REG_INT_STATUS_1);
if (int_status < 0) goto out;
/* Either FIFO_A_FULL (bit 7) or PPG_RDY (bit 6) brought us here */
count = mh_read_fifo_count(m);
while (count-- > 0) {
if (mh_read_one_sample(m, &red, &ir)) break;
sample[0] = red;
sample[1] = ir;
iio_push_to_buffers_with_timestamp(idev, sample,
iio_get_time_ns(idev));
}
out:
mutex_unlock(&m->lock);
return IRQ_HANDLED;
}
/* === Probe / Remove === */
static int mh_probe(struct i2c_client *client)
{
struct iio_dev *idev;
struct mymax30102 *m;
int err;
idev = devm_iio_device_alloc(&client->dev, sizeof(*m));
if (!idev) return -ENOMEM;
m = iio_priv(idev);
m->client = client;
mutex_init(&m->lock);
err = mh_init(m);
if (err) return dev_err_probe(&client->dev, err, "init failed\n");
idev->name = "mymax30102";
idev->info = &mh_iio_info;
idev->modes = INDIO_DIRECT_MODE | INDIO_BUFFER_TRIGGERED;
idev->channels = mh_channels;
idev->num_channels = ARRAY_SIZE(mh_channels);
err = devm_iio_triggered_buffer_setup(&client->dev, idev,
NULL, NULL, NULL);
if (err) return err;
if (client->irq > 0) {
err = devm_request_threaded_irq(&client->dev, client->irq, NULL,
mh_irq_thread,
IRQF_TRIGGER_FALLING | IRQF_ONESHOT,
"mymax30102", idev);
if (err) return err;
}
return devm_iio_device_register(&client->dev, idev);
}
static const struct of_device_id mh_of_match[] = {
{ .compatible = "linuxlearn,mymax30102" },
{ }
};
MODULE_DEVICE_TABLE(of, mh_of_match);
static const struct i2c_device_id mh_id[] = { { "mymax30102", 0 }, { } };
MODULE_DEVICE_TABLE(i2c, mh_id);
static struct i2c_driver mh_driver = {
.driver = {
.name = "mymax30102",
.of_match_table = mh_of_match,
},
.probe = mh_probe,
.id_table = mh_id,
};
module_i2c_driver(mh_driver);
MODULE_LICENSE("GPL");
DT:
&i2c1 {
max30102@57 {
compatible = "linuxlearn,mymax30102";
reg = <0x57>;
interrupt-parent = <&gpio4>;
interrupts = <13 IRQ_TYPE_EDGE_FALLING>; /* data-ready IRQ */
};
};
Test (one-shot via sysfs):
sysfs: a kernel-generated filesystem under /sys that exposes devices, drivers, and attributes.
[root@pa-mini:~]# insmod mymax30102.ko
[root@pa-mini:~]# # Put your finger on the sensor:
[root@pa-mini:~]# cat /sys/bus/iio/devices/iio:device0/in_intensity_red_raw
102842
[root@pa-mini:~]# cat /sys/bus/iio/devices/iio:device0/in_intensity_ir_raw
108391
[root@pa-mini:~]# # Remove finger:
[root@pa-mini:~]# cat /sys/bus/iio/devices/iio:device0/in_intensity_red_raw
3214 ← ambient light only
Streaming via buffer:
# Enable both intensity channels + timestamp
for ch in red ir; do
echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_intensity_${ch}_en
done
echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_timestamp_en
echo 256 > /sys/bus/iio/devices/iio:device0/buffer/length
echo 1 > /sys/bus/iio/devices/iio:device0/buffer/enable
# Each sample: 4 (red u32) + 4 (ir u32) + 8 (timestamp) = 16 bytes
dd if=/dev/iio:device0 of=ppg.bin bs=16 count=3000
3000 samples in 30 seconds (100 Hz). Parse offline.
79.6 Extracting heart rate + SpO₂ in user-space¶
The driver gives raw samples. The interesting work is now:
import numpy as np, struct, sys
from scipy.signal import butter, filtfilt, find_peaks
# Load samples
data = np.fromfile("ppg.bin", dtype=np.uint32).reshape(-1, 4)
red = data[:, 0].astype(float)
ir = data[:, 1].astype(float)
# (columns 2,3 are the 64-bit timestamp split as two u32; ignore for now)
# 1. Band-pass filter the IR signal: keep 0.5–4 Hz (30–240 BPM)
fs = 100.0 # sample rate
b, a = butter(4, [0.5/(fs/2), 4.0/(fs/2)], btype='band')
ir_ac = filtfilt(b, a, ir)
red_ac = filtfilt(b, a, red)
# 2. Find peaks for heart rate
peaks, _ = find_peaks(ir_ac, distance=fs * 0.4) # min 400 ms between peaks
if len(peaks) > 1:
intervals = np.diff(peaks) / fs # seconds between beats
bpm = 60.0 / intervals.mean()
print(f"Heart rate: {bpm:.1f} BPM")
# 3. SpO2 from R-ratio
red_dc = red.mean()
ir_dc = ir.mean()
red_ac_rms = np.sqrt(np.mean(red_ac**2))
ir_ac_rms = np.sqrt(np.mean(ir_ac**2))
R = (red_ac_rms / red_dc) / (ir_ac_rms / ir_dc)
spo2 = 110 - 25 * R
print(f"SpO2: {spo2:.1f}% (R-ratio {R:.3f})")
That’s the textbook approach. Production systems add:
Motion artifact rejection (accelerometer cross-correlation. Gate readings during walking).
Auto-gain (adjust LED currents to keep DC within the ADC’s sweet spot).
Finger-detection (if ir_dc < threshold, no finger present. Report “no signal”).
FFT/autocorrelation instead of peak-finding for noisy signals.
Maxim’s reference code includes the algorithm. Apple Watch’s algorithm is patented and proprietary. For a hobby/demo: peak-finding + a 10-second running average works.
79.7 Lab¶
Wire MAX30102 to your I²C bus + a GPIO for the IRQ.
Build and load
mymax30102.ko. Verify probe in dmesg.Bare-finger test. Cover the sensor with your fingertip. Verify red_raw and ir_raw jump from ~3000 (ambient) to 100k+.
Stream + process. Capture 30 s of data. Use a Python script (see §79.6) to extract HR. Compare to a real pulse oximeter or your own pulse rate (count for 15 s × 4).
SpO₂ check. Compute R-ratio + SpO₂. At rest, healthy adults are 95–99 %. Compare to medical pulse-oximeter on the other finger if available.
Motion artifacts. Capture while moving the sensor. Verify HR-extraction algorithm goes haywire. This is why fitness watches use accelerometer-gated HR.
LED current sweep. Vary
LED1_PAandLED2_PAfrom 0x10 to 0x60. Observe DC level scaling linearly. Higher currents = stronger signal but higher noise from photodiode saturation.Compare against a MAX30100 (if available). Same code. Different chip-id. ~14-bit vs 18-bit visible in signal SNR.
79.8 Pitfalls¶
Driver choice: Use the in-tree, maintained driver first. Use out-of-tree, spidev, or custom-driver paths only after you accept the kernel-version maintenance cost and document who owns updates.
Sensor in direct sun. Photodiode saturated by ambient IR. DC level pinned at max. No pulsatile signal. Use indoor or covered.
Loose finger placement. Tiny finger movements look like enormous “pulses.” Mechanical fixturing matters, clip designs work, finger-on-flat-board doesn’t.
Cold fingers. Reduced peripheral perfusion → weak AC signal. SpO₂ readings unreliable. Warm hands. Or use earlobe.
No DC tracking. Naive AC extraction (just band-pass) fails when DC drifts with finger pressure. Real algorithms track DC adaptively.
R-ratio calibration off. The 110-25·R formula is for one specific chip’s geometry. Use Maxim’s chip-specific table for accuracy. Don’t claim “medical-grade SpO₂” without proper calibration against a reference oximeter.
FIFO overflow. If user-space drains slower than 100 Hz × 6 bytes = 600 B/s, FIFO overflows. Check REG_FIFO_OVF (0x05) and warn.
IRQ pin polarity. Open-drain output, active-low. Pull-up to 3.3V required.
18-bit data in 24-bit container. Top 6 bits are zero, they’re padding. Mask with 0x3FFFF or the math goes wrong.
No mainline IIO driver. Multiple out-of-tree implementations exist. Quality varies. Writing your own from §79.5 is reasonable.
79.9 Going deeper¶
MAX30102 datasheet (Maxim 19-7740 Rev 1): full register map.
Maxim app note AN6409: “PPG signal processing on MAX30102.”
Maxim’s reference C library (Arduino-flavored but algorithmically clear), at
github.com/MaximIntegratedRefDesTeam/.drivers/iio/light/: IIO INTENSITY-channel drivers for comparison.scipy.signaldocumentation, Butterworth, peak-finding.PhysioNet: open-access ECG/PPG signal databases for testing algorithms.
WHO SpO₂ measurement guidelines: for understanding what “medical-grade” means.
End of Group F, Specialty sensors (Ch 77–79). Three sensors that each represent a different driver pattern: 1-Wire’s bit-bang master + slave-driver split (Ch 77), I²S audio capture without a chip driver (Ch 78), and an IIO chip with FIFO + user-space DSP (Ch 79).
Next chapter: Chapter 80: External ADCs (ADS1115 / ADS1256 / MCP3008 / AD7606). When the SoC’s internal ADC isn’t accurate enough, fast enough, or has the wrong number of channels.