Embedded

Monitoring Air Quality with the SEN5x Rust Driver

Hauke Jung··4 min read
On this page (7 sections)

If you’ve worked with Sensirion’s SEN5x sensor modules, you know they pack an impressive amount of environmental sensing into a tiny package — particulate matter, VOC index, NOx index, humidity, and temperature, all over a single I2C bus. What’s been missing is a solid no_std Rust driver to talk to them. That’s why I built sen5x-rs.

What is the SEN5x?

The SEN5x is Sensirion’s environmental sensor node series. Depending on the variant, you get different measurement capabilities:

VariantPM1.0/2.5/4.0/10.0VOC IndexNOx IndexHumidityTemperature
SEN50✓————
SEN54✓✓—✓✓
SEN55✓✓✓✓✓

The SEN55 is the most capable model, giving you a full picture of indoor air quality from a single sensor. All variants communicate over I2C at address 0x69.

The Driver

The sen5x crate provides a complete, type-safe interface for all three sensor variants. It’s built on embedded-hal 1.0, works in no_std environments, and comes with full async support via embedded-hal-async.

Add it to your project:

[dependencies]
sen5x = "0.2"

# For async support (e.g., with Embassy)
# sen5x = { version = "0.2", features = ["embedded-hal-async"] }

Features

  • Blocking and async APIs — full feature parity between Sen5x and Sen5xAsync
  • no_std by default — runs on bare-metal microcontrollers without heap allocation
  • CRC validation — every I2C read is verified using Sensirion’s CRC8 checksum
  • defmt support — opt-in embedded logging with the defmt feature
  • Command safety — the driver tracks measurement state internally and prevents invalid command sequences

Getting Started

Here’s a minimal example reading air quality data on a Raspberry Pi using linux-embedded-hal:

use linux_embedded_hal::{Delay, I2cdev};
use sen5x::Sen5x;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let i2c = I2cdev::new("/dev/i2c-1")?;
    let mut sensor = Sen5x::new(i2c, Delay);

    // Reset and give the sensor time to boot
    sensor.device_reset()?;
    Delay.delay_ms(200);

    // Print device info
    let name = sensor.product_name()?;
    let serial = sensor.serial_number()?;
    let version = sensor.version()?;

    println!("Product: {}", core::str::from_utf8(&name).unwrap().trim_end_matches('\0'));
    println!("Serial:  {}", core::str::from_utf8(&serial).unwrap().trim_end_matches('\0'));
    println!("Firmware: {}.{}", version.firmware_major, version.firmware_minor);

    // Start measuring
    sensor.start_measurement()?;

    loop {
        Delay.delay_ms(1000);

        if sensor.data_ready()? {
            let data = sensor.measurement()?;
            println!(
                "PM2.5: {:.1} µg/m³ | Temp: {:.1} °C | Humidity: {:.1} % | VOC: {:.0} | NOx: {:.0}",
                data.mass_concentration_pm2p5,
                data.ambient_temperature,
                data.ambient_humidity,
                data.voc_index,
                data.nox_index,
            );
        }
    }
}

The sensor updates every second. After calling start_measurement(), poll data_ready() and read with measurement() when new data is available.

Async on Embedded — ESP32-C3 with Embassy

For microcontroller targets, the async driver is the way to go. Here’s how it looks on an ESP32-C3 using Embassy:

use embassy_time::{Duration, Timer};
use sen5x::Sen5xAsync;

#[embassy_executor::task]
async fn air_quality_task(i2c: I2c<'static, Async>) {
    let mut sensor = Sen5xAsync::new(i2c, embassy_time::Delay);

    sensor.device_reset().await.unwrap();
    Timer::after_millis(200).await;

    sensor.start_measurement().await.unwrap();

    loop {
        Timer::after(Duration::from_secs(1)).await;

        if sensor.data_ready().await.unwrap() {
            let data = sensor.measurement().await.unwrap();
            defmt::info!(
                "PM2.5: {} | Temp: {} | VOC: {}",
                data.mass_concentration_pm2p5,
                data.ambient_temperature,
                data.voc_index,
            );
        }
    }
}

The API is identical to the blocking version — just add .await. No separate learning curve.

Beyond Basic Measurements

The driver exposes the full SEN5x feature set, not just the basics:

Fan management — trigger manual cleaning cycles or configure the auto-cleaning interval:

sensor.start_fan_cleaning()?;
// or
sensor.set_fan_auto_cleaning_interval(604800)?; // weekly

Temperature compensation — correct for self-heating or enclosure effects:

sensor.set_temperature_offset_simple(2.5)?; // offset in °C

VOC/NOx algorithm tuning — adjust the on-chip gas index algorithm parameters for your specific environment.

Algorithm state backup — save and restore the VOC algorithm state across power cycles. This avoids the 12-hour learning phase after every reboot:

let state = sensor.voc_algorithm_state()?;
// persist `state` to flash/EEPROM
// ... after reboot:
sensor.set_voc_algorithm_state(state)?;

Error Handling

Errors are generic over the underlying I2C implementation, keeping the driver platform-agnostic:

pub enum Error<E> {
    I2c(E),       // Underlying bus error
    Crc,          // Data integrity failure
    NotAllowed,   // Command invalid in current state
    Internal,     // Sensor reported a fault
}

Enable the thiserror feature for std::error::Error integration, or defmt for embedded-friendly formatting.

All articles