Monitoring Air Quality with the SEN5x Rust Driver

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:

Variant PM1.0/2.5/4.0/10.0 VOC Index NOx Index Humidity Temperature
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.