Sense.HR
MAX86174A optical heart-rate and SpO₂ front end (PPG).
Platform-agnostic driver for the Analog Devices MAX86174A dual-channel photoplethysmography (PPG) analog front end, paired on Sense.HR with two Vishay VEMD1060X01 silicon PIN photodiodes and four emitters (green ×2, red 645 nm, infrared 940 nm) for reflective optical sensing.
Examples
Quick start (single-wavelength heart rate, polled)
#include "core.h"
#include "core_tiles.h"
#include "tile_sense_hr.h"
tile_t hr;
tile_sense_hr_init(core_tiles_pal(&core_i2c1), 0, &hr, NULL);
tile_sense_hr_auto_gain(&hr, 0, 0); // find LED current + offset for slot 0 (0 = 10 mA cap)
tile_sense_hr_start(&hr);
while (1) {
if (tile_sense_hr_frame_ready(&hr)) {
int32_t ppg = tile_sense_hr_read_ppg(&hr, 0); // green, PPG1
// pulsatile AC rides on a large DC — band-pass on the host
}
}Multi-wavelength (green + red + IR interleaved in one frame)
sense_hr_cfg_t cfg = { .fps = 128 };
tile_sense_hr_init(core_tiles_pal(&core_i2c1), 0, &hr, &cfg);
tile_sense_hr_set_slot(&hr, 0, SENSE_HR_LED_GREEN, 5000);
tile_sense_hr_set_slot(&hr, 1, SENSE_HR_LED_RED, 5000);
tile_sense_hr_set_slot(&hr, 2, SENSE_HR_LED_IR, 5000);
for (uint8_t s = 0; s < 3; s++) tile_sense_hr_auto_gain(&hr, s, 0);
tile_sense_hr_start(&hr);
int32_t frame[SENSE_HR_FRAME_LEN];
if (tile_sense_hr_read_frame(&hr, frame)) {
int32_t green = frame[0], red = frame[2], ir = frame[4]; // PPG1
}API reference
Initialization
tile_sense_hr_find
uint8_t tile_sense_hr_find(tiles_pal_t *hal, uint8_t instance)Check if a Sense.HR is present on the bus.
- hal
- Platform HAL handle.
- instance
- 0 = default address (0x6B), 1 = alternate (0x6A).
Returns 1 if the device ACKs and PART_ID reads 0x40, 0 otherwise.
tile_sense_hr_init
void tile_sense_hr_init(tiles_pal_t *hal, uint8_t instance, tile_t *tile, const sense_hr_cfg_t *cfg)Probes the device, verifies PART_ID, soft-resets, pins the LED4/TRIG pin to LED duty, configures the frame rate and slot 0, leaves INTB open-drain, and sets the tile state to READY. Measurements are NOT running until tile_sense_hr_start().
- hal
- Platform HAL handle.
- instance
- 0 = default (0x6B), 1 = alternate (0x6A).
- tile
- Tile handle to initialise.
- cfg
- Configuration (NULL for defaults).
Lifecycle
tile_sense_hr_sleep
Studiovoid tile_sense_hr_sleep(tile_t *tile)Registers are retained; tile_sense_hr_wake() resumes without reconfiguration.
tile_sense_hr_wake
Studiovoid tile_sense_hr_wake(tile_t *tile)Leave shutdown and resume the configured measurements.
tile_sense_hr_reset
Studiovoid tile_sense_hr_reset(tile_t *tile)All registers return to defaults and the tile state drops to NONE. Call tile_sense_hr_init() again afterwards.
tile_sense_hr_start
Studiovoid tile_sense_hr_start(tile_t *tile)Flushes the FIFO first so the first frame read is fresh.
tile_sense_hr_stop
Studiovoid tile_sense_hr_stop(tile_t *tile)Stop all measurements (LEDs off, FIFO stops filling).
Runtime
tile_sense_hr_auto_gain
Studiouint8_t tile_sense_hr_auto_gain(tile_t *tile, uint8_t slot, uint32_t max_ua)Blocking, roughly two to three seconds. Runs the slot alone, then: 1. raises LED current toward `max_ua` while the offset DAC can still keep the DC pedestal (crosstalk + tissue return) inside the ADC, backing off current when it cannot; 2. measures the total photocurrent and picks the largest offset that leaves ~0.5 µA of headroom, then the tightest ADC range holding the residual under ~70 % of full scale; 3. verifies, and unwinds one step at a time if that overflowed. Other slots' enables are restored on return; measurements are left stopped. Call tile_sense_hr_start() afterwards. Re-run whenever the optical situation changes (finger placed or removed).
- slot
- Measurement slot 0–5 (must already have an emitter).
- max_ua
- Upper bound for LED current in µA (0 = 10000, a safe default for the 0402 emitters). The search runs in the 32 mA range, so anything above 31875 is capped there.
Returns 1 if a valid operating point was found, 0 if the slot never produced samples.
tile_sense_hr_get_status
Studiouint8_t tile_sense_hr_get_status(tile_t *tile)Read STATUS1 (clears its latched bits and releases INTB).
Returns Raw STATUS1 byte (use MAX86174A_STATUS1_* masks).
tile_sense_hr_get_status2
Studiouint8_t tile_sense_hr_get_status2(tile_t *tile)LEDn_COMPB set means VLED is too low for the programmed current on that pin — raise VLED or lower the current.
Returns Raw STATUS2 byte (use MAX86174A_STATUS2_* masks).
tile_sense_hr_frame_ready
Studiouint8_t tile_sense_hr_frame_ready(tile_t *tile)Non-clearing: compares the FIFO count against the frame length, so it is safe to poll without disturbing STATUS1 or INTB.
Returns 1 if a frame can be read, 0 otherwise.
tile_sense_hr_read_frame
Studiouint8_t tile_sense_hr_read_frame(tile_t *tile, int32_t *frame)Reads every waiting sample, sign-extends the 20-bit two's-complement data, and places it by tag: `frame[2·slot + channel]` (channel 0 = PPG1, 1 = PPG2). Entries for disabled slots, powered-down channels, and samples replaced by an overflow tag read SENSE_HR_NO_SAMPLE. Overflow-tagged samples are counted (see get_overflow_count()). Each entry holds the newest sample for its tag, so if the FIFO ends part-way through a frame the result can mix two adjacent frames. The decoded frame is also cached for tile_sense_hr_read_ppg().
- frame
- Caller buffer of SENSE_HR_FRAME_LEN (12) entries.
Returns 1 if a complete frame was decoded, 0 if the FIFO held less than one frame (buffer then holds the previous frame).
tile_sense_hr_read_ppg
Studioint32_t tile_sense_hr_read_ppg(tile_t *tile, uint8_t slot)Drains the FIFO if a full frame is waiting (same as read_frame()), then returns the cached channel-1 value for the slot. Negative values are real: the offset DAC can pull the residual below zero.
- slot
- Measurement slot 0–5.
Returns Signed 20-bit sample, or SENSE_HR_NO_SAMPLE if none yet.
tile_sense_hr_read_ppg2
Studioint32_t tile_sense_hr_read_ppg2(tile_t *tile, uint8_t slot)Only meaningful when channel 2 is powered and a photodiode is routed to it (SENSE_HR_PD_1_2 or SENSE_HR_PD_2_1).
- slot
- Measurement slot 0–5.
Returns Signed 20-bit sample, or SENSE_HR_NO_SAMPLE if none yet.
tile_sense_hr_read_photocurrent_na
Studioint32_t tile_sense_hr_read_photocurrent_na(tile_t *tile, uint8_t slot)Converts the cached sample with the slot's ADC range (full scale / 524287 counts) and adds the offset-DAC current back, so the result is the photodiode current itself and is comparable across slots that ended up with different gain settings. Integer maths, no float.
- slot
- Measurement slot 0–5.
Returns Photocurrent in nA, or SENSE_HR_NO_SAMPLE if no sample yet.
tile_sense_hr_get_overflow_count
Studiouint16_t tile_sense_hr_get_overflow_count(tile_t *tile)An overflowing channel produces no data — the chip replaces the sample with an ALC- or exposure-overflow tag. A non-zero count means lower the LED current, raise the offset DAC, or widen the ADC range.
Returns Number of overflow-tagged samples in the last read.
Events
tile_sense_hr_process
Studiovoid tile_sense_hr_process(tile_t *tile)In interrupt mode (int_pin set): returns immediately unless INTB has fired since the last call, then reads STATUS1 and fires the callback. In polled mode: reads STATUS1 every call. Reading STATUS1 clears its latched bits and releases INTB.
tile_sense_hr_on_event
Studiovoid tile_sense_hr_on_event(tile_t *tile, sense_hr_event_cb_t cb, void *ctx)Register or change the event callback.
- cb
- Callback to fire on STATUS1 activity. NULL to clear.
- ctx
- Opaque user pointer passed to the callback.
tile_sense_hr_set_interrupts
Studiovoid tile_sense_hr_set_interrupts(tile_t *tile, uint8_t mask)Pass a mask of MAX86174A_STATUS1_* bits, e.g. FRAME_RDY | A_FULL. PWR_RDY is non-maskable. INTB stays open-drain, active-low.
- mask
- STATUS1 bits that should assert INTB.
Config
tile_sense_hr_set_frame_rate
Studiouint16_t tile_sense_hr_set_frame_rate(tile_t *tile, uint16_t fps)Programs FR_CLK_DIV = 32768 / fps, clamped to the chip's 16–32766 divider range (1–2048 fps). If the enabled slots' exposures do not fit in one frame period the chip flags INVALID_CFG in STATUS2.
- fps
- Requested frames per second.
Returns Actual frame rate after divider rounding.
tile_sense_hr_get_frame_rate
Studiouint16_t tile_sense_hr_get_frame_rate(tile_t *tile)Read the configured frame rate.
Returns Frames per second after divider rounding.
tile_sense_hr_set_slot
Studiovoid tile_sense_hr_set_slot(tile_t *tile, uint8_t slot, sense_hr_led_t led, uint32_t led_ua)Writes the slot's emitter select and LED current, inherits the tile-wide integration / averaging / ADC range / photodiode routing, and enables the slot in SYS_CFG2. Takes effect on the next start() (or immediately if already running). Slot 0 is the one init() set up.
- slot
- Measurement slot 0–5 (MEAS1..MEAS6).
- led
- Emitter to pulse (sense_hr_led_t).
- led_ua
- LED current in µA, 0–100000 (0 disables the LED; above 100 mA is capped).
tile_sense_hr_set_ambient_slot
Studiovoid tile_sense_hr_set_ambient_slot(tile_t *tile, uint8_t slot)The chip requires an ambient slot to be the LAST enabled slot in the frame. Its sample carries the same tag position as an LED slot.
- slot
- Measurement slot 0–5.
tile_sense_hr_enable_slot
Studiovoid tile_sense_hr_enable_slot(tile_t *tile, uint8_t slot, uint8_t enable)Enable or disable a slot without changing its settings.
- slot
- Measurement slot 0–5.
- enable
- 1 = included in each frame, 0 = skipped.
tile_sense_hr_set_led_current
Studiovoid tile_sense_hr_set_led_current(tile_t *tile, uint8_t slot, uint32_t led_ua)Picks the smallest LED range (32 / 64 / 96 / 128 mA full scale) that covers the request, then the nearest 8-bit code: 125 / 250 / 375 / 500 µA per step. 0 disables the LED for that slot.
- slot
- Measurement slot 0–5.
- led_ua
- LED current in µA, 0–100000 (above 100 mA is capped).
tile_sense_hr_get_led_current
Studiouint32_t tile_sense_hr_get_led_current(tile_t *tile, uint8_t slot)Read back a slot's LED drive current after quantisation.
- slot
- Measurement slot 0–5.
Returns LED current in µA as programmed (0 = LED off).
tile_sense_hr_set_integration
Studiovoid tile_sense_hr_set_integration(tile_t *tile, uint8_t slot, sense_hr_tint_t tint)Longer integration collects more photocurrent per pulse (more signal, but easier to overflow on crosstalk or ambient light).
- slot
- Measurement slot 0–5.
- tint
- Integration time (sense_hr_tint_t).
tile_sense_hr_set_averaging
Studiovoid tile_sense_hr_set_averaging(tile_t *tile, uint8_t slot, sense_hr_aver_t aver)Averaging improves SNR and ambient rejection at the cost of frame time: each pulse costs one integration period plus settling.
- slot
- Measurement slot 0–5.
- aver
- Pulses per sample (sense_hr_aver_t).
tile_sense_hr_set_adc_range
Studiovoid tile_sense_hr_set_adc_range(tile_t *tile, uint8_t slot, sense_hr_adc_range_t range)Set a slot's ADC full-scale photocurrent range (both channels).
- slot
- Measurement slot 0–5.
- range
- Full-scale range (sense_hr_adc_range_t).
tile_sense_hr_set_dac_offset
Studiovoid tile_sense_hr_set_dac_offset(tile_t *tile, uint8_t slot, uint8_t offset_ua)The DAC sources part of the photodiode DC away from the converter so the ADC digitises only what is left. The reported sample is (photocurrent − offset); tile_sense_hr_read_photocurrent_na() adds the offset back. Quantised to 0, 4, 8 … 28 µA.
- slot
- Measurement slot 0–5.
- offset_ua
- Cancellation current in µA, 0–28.
tile_sense_hr_set_photodiodes
Studiovoid tile_sense_hr_set_photodiodes(tile_t *tile, uint8_t slot, sense_hr_pdsel_t pdsel)Summing both onto PPG1 (default) gives the best single-wavelength SNR. Splitting them (SENSE_HR_PD_1_2) reads each diode on its own channel; PPG2 must then be powered (cfg.ppg2_enable) to see PD2.
- slot
- Measurement slot 0–5.
- pdsel
- Routing (sense_hr_pdsel_t).
tile_sense_hr_set_ppg2_enabled
Studiovoid tile_sense_hr_set_ppg2_enabled(tile_t *tile, uint8_t enable)With both photodiodes summed onto PPG1, channel 2 still emits a near-zero sample per slot per frame; powering it down halves FIFO traffic. Enable it for per-diode readout.
- enable
- 1 = channel 2 powered, 0 = powered down.
tile_sense_hr_set_proximity_mode
Studiovoid tile_sense_hr_set_proximity_mode(tile_t *tile, uint8_t enable, uint8_t lower)Reserves slot 5 (MEAS6) as a low-power skin detector: while its PPG1 reading stays below `lower` × 2048 counts the chip runs slot 5 alone at 8 fps; once skin is detected it returns to the programmed frame rate with every enabled slot. Configure slot 5's emitter and a small LED current first (tile_sense_hr_set_slot()). THRESH1_HILO fires on entering proximity mode.
- enable
- 1 = automatic proximity mode, 0 = normal.
- lower
- Skin-detect threshold ÷ 2048 ADC counts.
FIFO
tile_sense_hr_get_fifo_count
Studiouint16_t tile_sense_hr_get_fifo_count(tile_t *tile)Number of unread samples in the FIFO (0–256).
Returns Samples waiting.
tile_sense_hr_get_fifo_overflow
Studiouint8_t tile_sense_hr_get_fifo_overflow(tile_t *tile)Samples lost to FIFO overflow since the last FIFO read (0–127).
Returns OVF_COUNTER value; saturates at 127.
tile_sense_hr_flush_fifo
Studiovoid tile_sense_hr_flush_fifo(tile_t *tile)Discard everything in the FIFO and reset its pointers.
tile_sense_hr_set_fifo_watermark
Studiovoid tile_sense_hr_set_fifo_watermark(tile_t *tile, uint16_t samples)Set the almost-full watermark used by the A_FULL flag / event.
- samples
- Number of queued samples that asserts A_FULL, 1–256.
tile_sense_hr_read_fifo_raw
Studiouint16_t tile_sense_hr_read_fifo_raw(tile_t *tile, uint32_t *buf, uint16_t count)For hosts that want to decode the stream themselves. Data is NOT sign-extended here; bit 19 is the sign.
- buf
- Caller buffer for raw words.
- count
- Capacity of `buf`; at most this many samples are read.
Returns Number of words actually read.
Advanced
tile_sense_hr_set_threshold
Studiovoid tile_sense_hr_set_threshold(tile_t *tile, uint8_t slot, uint8_t lower, uint8_t upper)THRESH1_HILO is set (and INTB asserted if enabled) when the sample exceeds `upper` or drops below `lower`. Both limits are in units of 2048 ADC counts (0–255). Upper = 255 disables the upper check; lower = 0 disables the lower check. Negative samples clip to 0 first. (The datasheet's register table says upper 0x00 disables it, its functional description 0xFF; the driver follows the description.)
- slot
- Measurement slot 0–5 to watch.
- lower
- Lower limit ÷ 2048.
- upper
- Upper limit ÷ 2048.
tile_sense_hr_read_reg
Studiouint8_t tile_sense_hr_read_reg(tile_t *tile, uint8_t reg)Read any register directly.
- reg
- Register address 0x00–0xFF.
Returns Register value.
tile_sense_hr_write_reg
Studiovoid tile_sense_hr_write_reg(tile_t *tile, uint8_t reg, uint8_t val)Bypasses the driver's cached configuration; prefer the typed setters.
- reg
- Register address 0x00–0xFF.
- val
- Value to write.
Driver gaps · 7
Chip capabilities this driver doesn’t expose yet.
Events
- frame_ready
- fifo_almost_full
- exposure_overflow
- proximity
Enums
sense_hr_led_t
- SENSE_HR_LED_IR
- LED1_DRV — 940 nm infrared
- SENSE_HR_LED_GREEN
- LED2_DRV — 525 nm green (primary)
- SENSE_HR_LED_RED
- LED3_DRV — 645 nm red (IN-S42ATR, λP)
- SENSE_HR_LED_GREEN2
- LED4_DRV — 525 nm green (second)
sense_hr_tint_t
ADC integration time per pulse (MEASx_TINT[1:0], 3rd-order decimation).
- SENSE_HR_TINT_14US
- 14.6 µs
- SENSE_HR_TINT_29US
- 29.2 µs
- SENSE_HR_TINT_58US
- 58.6 µs
- SENSE_HR_TINT_117US
- 117.1 µs (default, most signal per exposure)
sense_hr_aver_t
LED pulses averaged per sample (MEASx_AVER[2:0]).
- SENSE_HR_AVER_1
- 1 pulse
- SENSE_HR_AVER_2
- 2 pulses
- SENSE_HR_AVER_4
- 4 pulses (default)
- SENSE_HR_AVER_8
- 8 pulses
- SENSE_HR_AVER_16
- 16 pulses
- SENSE_HR_AVER_32
- 32 pulses
- SENSE_HR_AVER_64
- 64 pulses
- SENSE_HR_AVER_128
- 128 pulses
sense_hr_adc_range_t
PPG ADC full-scale photocurrent (MEASx_PPGy_ADC_RGE[1:0]).
- SENSE_HR_ADC_4UA
- 4 µA full scale, 7.6 pA/LSB
- SENSE_HR_ADC_8UA
- 8 µA full scale, 15.3 pA/LSB
- SENSE_HR_ADC_16UA
- 16 µA full scale, 30.5 pA/LSB
- SENSE_HR_ADC_32UA
- 32 µA full scale, 61 pA/LSB (default)
sense_hr_pdsel_t
Photodiode routing to the two readout channels (MEASx_PDSEL[1:0]).
- SENSE_HR_PD_BOTH_PPG1
- PD1 + PD2 summed onto PPG1 (default, best SNR)
- SENSE_HR_PD_2_1
- PD2 → PPG1, PD1 → PPG2
- SENSE_HR_PD_1_2
- PD1 → PPG1, PD2 → PPG2 (per-diode readout)
- SENSE_HR_PD_BOTH_PPG2
- PD1 + PD2 summed onto PPG2
Constants
| TILE_SENSE_HR_VERSION_MAJOR | 1 | |
| TILE_SENSE_HR_VERSION_MINOR | 0 | |
| TILE_SENSE_HR_VERSION_PATCH | 0 | |
| MAX86174A_I2C_ADDR_DEFAULT | 0x6B | ADDR high (pad 2 open, default) |
| MAX86174A_I2C_ADDR_ALT | 0x6A | ADDR low (pad 2 to GND) |
| MAX86174A_MEAS_SEL | 0 | [6] AMB, [3:2] DRVB, [1:0] DRVA |
| MAX86174A_MEAS_CFG1 | 1 | [7:6] PDSEL, [4:3] TINT, [2:0] AVER |
| MAX86174A_MEAS_CFG2 | 2 | [5:4] LED_RGE, [3:2] PPG2_ADC_RGE, [1:0] PPG1_ADC_RGE |
| MAX86174A_MEAS_CFG3 | 3 | [7:5] PD_SETLNG, [4:3] LED_SETLNG |
| MAX86174A_MEAS_CFG4 | 4 | [6:4] PPG2_DACOFF, [2:0] PPG1_DACOFF |
| MAX86174A_MEAS_DRVA_PA | 5 | LED driver A current code |
| MAX86174A_MEAS_DRVB_PA | 6 | LED driver B current code |
| MAX86174A_PART_ID_VALUE | 0x40 | MAX86174A (dual channel); 0x41 = MAX86174B |
| MAX86174A_FIFO_TAG_SHIFT | 20 | |
| MAX86174A_FIFO_DATA_MASK | 0x000FFFFF | |
| MAX86174A_FIFO_TAG_DARK | 0xC | Dark sample (COLLECT_RAW_DATA only) |
| MAX86174A_FIFO_TAG_ALC_OVF | 0xD | ALC overflow replaced this sample |
| MAX86174A_FIFO_TAG_EXP_OVF | 0xE | Exposure overflow replaced this sample |
| MAX86174A_FIFO_MARKER | 0xFFFFE | |
| MAX86174A_FIFO_INVALID | 0xFFFFF | |
| SENSE_HR_SLOTS | 6 | Measurement slots per frame (MEAS1..6) |
| SENSE_HR_CHANNELS | 2 | Readout channels (PPG1, PPG2) |
| SENSE_HR_FIFO_DEPTH | 256 | |
| SENSE_HR_ADC_FULL_SCALE | 524287 | |
| SENSE_HR_FR_CLK_HZ | 32768 | |
| SENSE_HR_FR_DIV_MIN | 16 | |
| SENSE_HR_FR_DIV_MAX | 32766 | |
| SENSE_HR_LED_CHIP_MAX_UA | 127500 | |
| SENSE_HR_LED_MAX_UA | 100000 | |
| SENSE_HR_DACOFF_MAX_UA | 28 |

