BERGSONNE

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

Studio
void tile_sense_hr_sleep(tile_t *tile)

Registers are retained; tile_sense_hr_wake() resumes without reconfiguration.

tile_sense_hr_wake

Studio
void tile_sense_hr_wake(tile_t *tile)

Leave shutdown and resume the configured measurements.

tile_sense_hr_reset

Studio
void 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

Studio
void tile_sense_hr_start(tile_t *tile)

Flushes the FIFO first so the first frame read is fresh.

tile_sense_hr_stop

Studio
void tile_sense_hr_stop(tile_t *tile)

Stop all measurements (LEDs off, FIFO stops filling).

Runtime

tile_sense_hr_auto_gain

Studio
uint8_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

Studio
uint8_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

Studio
uint8_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

Studio
uint8_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

Studio
uint8_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

Studio
int32_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

Studio
int32_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

Studio
int32_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

Studio
uint16_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

Studio
void 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

Studio
void 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

Studio
void 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

Studio
uint16_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

Studio
uint16_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

Studio
void 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

Studio
void 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

Studio
void 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

Studio
void 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

Studio
uint32_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

Studio
void 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

Studio
void 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

Studio
void 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

Studio
void 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

Studio
void 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

Studio
void 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

Studio
void 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

Studio
uint16_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

Studio
uint8_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

Studio
void tile_sense_hr_flush_fifo(tile_t *tile)

Discard everything in the FIFO and reset its pointers.

tile_sense_hr_set_fifo_watermark

Studio
void 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

Studio
uint16_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

Studio
void 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

Studio
uint8_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

Studio
void 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.

advancedSPI bus modeHardware-gated on Sense-HR-a. The MAX86174A supports 4-wire SPI to 24 MHz (pad 3 to GND selects it), but its SDO/MISO output (pad 2) is push-pull to the 1.8 V VDD (V_OH >= VDD - 0.4 V at 4 mA), below a 3.3 V Core's input-high threshold, and the pad's 2.1 V absolute maximum (VDD + 0.3 V) forbids a pull-up to 3.3 V. SPI needs a level translator on MISO that the tile does not carry. I²C is open-drain and unaffected.
nicheExternal frame sync (TRIG input)Hardware-gated. SYNC_MODE 1/2 take an external frame trigger or frame clock on the LED4_DRV/TRIG pin. On Sense-HR-a that pin drives the second green LED, so the driver pins TRIGLED4_SEL = 1 and runs the internal 32.768 kHz frame oscillator only.
advancedDual-LED exposures (driver B)Driver-deferred. Each measurement slot can pulse two LEDs at once (MEASx_DRVA + MEASx_DRVB) to sum red and IR into one sample. This driver uses driver A only — one LED per slot — because the SpO₂ ratio needs the wavelengths separated, not summed. Driver B is reachable through tile_sense_hr_write_reg() on MEASx_DRVB_PA.
advancedRaw exposure/ambient capture (COLLECT_RAW_DATA)Driver-deferred. Setting COLLECT_RAW_DATA pushes the LED-on and dark samples to the FIFO separately (DARK tag 0xC) so the host can run its own ambient rejection. The driver's FIFO decoder assumes the chip's computed samples; raw mode changes the frame layout and would need a second decoder.
advancedDigital low-pass / IIR filter (DLPF_EN)Driver-deferred. The chip can average 2–32 adjacent samples (DEC_AVE) or run an IIR low-pass (IIR_CFG selects the cut-off; the IIR supports 100 and 200 fps) before the FIFO. Left disabled: the host-side band-pass a heart-rate algorithm already needs does the same job with fewer constraints.
nicheThreshold instance 2 and hysteresisDriver-deferred. tile_sense_hr_set_threshold() programs THRESHOLD1 only, with no time or level hysteresis. THRESHOLD2 (used for a "left proximity mode" interrupt) and TIME_HYST / LEVEL_HYST are reachable through tile_sense_hr_write_reg() on 0x50–0x55.
nicheFIFO marker, I²C broadcast address, SINC3 / 2nd-order decimationDriver-deferred. FIFO_MARK pushes a 0xFFFFFE tag to separate data across a configuration change; I2C_BCAST_ADDR lets several parts share one write address; PPG_FILT2_SEL / MEASx_SINC3_SEL swap the ADC decimation filter. None affect a normal measurement; all are reachable through tile_sense_hr_write_reg().

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_MAJOR1
TILE_SENSE_HR_VERSION_MINOR0
TILE_SENSE_HR_VERSION_PATCH0
MAX86174A_I2C_ADDR_DEFAULT0x6BADDR high (pad 2 open, default)
MAX86174A_I2C_ADDR_ALT0x6AADDR low (pad 2 to GND)
MAX86174A_MEAS_SEL0[6] AMB, [3:2] DRVB, [1:0] DRVA
MAX86174A_MEAS_CFG11[7:6] PDSEL, [4:3] TINT, [2:0] AVER
MAX86174A_MEAS_CFG22[5:4] LED_RGE, [3:2] PPG2_ADC_RGE, [1:0] PPG1_ADC_RGE
MAX86174A_MEAS_CFG33[7:5] PD_SETLNG, [4:3] LED_SETLNG
MAX86174A_MEAS_CFG44[6:4] PPG2_DACOFF, [2:0] PPG1_DACOFF
MAX86174A_MEAS_DRVA_PA5LED driver A current code
MAX86174A_MEAS_DRVB_PA6LED driver B current code
MAX86174A_PART_ID_VALUE0x40MAX86174A (dual channel); 0x41 = MAX86174B
MAX86174A_FIFO_TAG_SHIFT20
MAX86174A_FIFO_DATA_MASK0x000FFFFF
MAX86174A_FIFO_TAG_DARK0xCDark sample (COLLECT_RAW_DATA only)
MAX86174A_FIFO_TAG_ALC_OVF0xDALC overflow replaced this sample
MAX86174A_FIFO_TAG_EXP_OVF0xEExposure overflow replaced this sample
MAX86174A_FIFO_MARKER0xFFFFE
MAX86174A_FIFO_INVALID0xFFFFF
SENSE_HR_SLOTS6Measurement slots per frame (MEAS1..6)
SENSE_HR_CHANNELS2Readout channels (PPG1, PPG2)
SENSE_HR_FIFO_DEPTH256
SENSE_HR_ADC_FULL_SCALE524287
SENSE_HR_FR_CLK_HZ32768
SENSE_HR_FR_DIV_MIN16
SENSE_HR_FR_DIV_MAX32766
SENSE_HR_LED_CHIP_MAX_UA127500
SENSE_HR_LED_MAX_UA100000
SENSE_HR_DACOFF_MAX_UA28