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
Sen5xandSen5xAsync no_stdby 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
defmtfeature - 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.
Links
- Crate: crates.io/crates/sen5x
- Source: github.com/hauju/sen5x-rs
- Docs: docs.rs/sen5x
- Datasheet: Sensirion SEN5x Datasheet