DEV Community

Cover image for Writing a Rust Driver for the Sensirion SEN5x Air Quality Sensor
Hauke J.
Hauke J.

Posted on

Writing a Rust Driver for the Sensirion SEN5x Air Quality Sensor

If you have ever tried to integrate an environmental sensor into a Rust embedded project, you know the drill: vendor SDKs are C-only, community crates may not exist for your specific sensor, and the datasheet is your best friend. I recently went through this with the Sensirion SEN5x module and want to share what I learned building a no_std Rust driver from scratch.

The SEN5x is a multi-sensor module from Sensirion that measures particulate matter (PM1.0, PM2.5, PM4.0, PM10), volatile organic compounds (VOC index), nitrogen oxide (NOx index), temperature, and humidity, all over a single I2C bus. That is a lot of environmental data from one package.

Here is how to build a driver for it in Rust.

What You Need

  • A microcontroller with I2C support (I used an RP2040, but any target with embedded-hal I2C traits works)
  • The SEN5x module (SEN50, SEN54, or SEN55 -- the communication protocol is identical)
  • A Rust toolchain with your target configured (thumbv6m-none-eabi for RP2040, thumbv7em-none-eabihf for STM32, etc.)

Your Cargo.toml dependencies will look something like this:

[dependencies]
embedded-hal = "1.0"
Enter fullscreen mode Exit fullscreen mode

We are intentionally keeping dependencies minimal. The driver itself only needs embedded-hal traits.

Understanding the SEN5x I2C Protocol

The SEN5x uses Sensirion's standard I2C protocol, which has a few quirks compared to a typical I2C device:

  1. Commands are 16-bit. You send two bytes for each command, MSB first.
  2. Every data word is 3 bytes. Two bytes of data followed by a CRC-8 checksum.
  3. Timing matters. Some commands need execution delays before you can read the response.

This means a typical exchange looks like:

Write: [cmd_msb, cmd_lsb]
Wait: execution time
Read:  [data_hi, data_lo, crc, data_hi, data_lo, crc, ...]
Enter fullscreen mode Exit fullscreen mode

The CRC-8 Implementation

Sensirion uses CRC-8 with polynomial 0x31 and initial value 0xFF. This is the first thing to implement because every data word needs CRC validation:

fn crc8(data: &[u8]) -> u8 {
    let mut crc: u8 = 0xFF;
    for &byte in data {
        crc ^= byte;
        for _ in 0..8 {
            if crc & 0x80 != 0 {
                crc = (crc << 1) ^ 0x31;
            } else {
                crc <<= 1;
            }
        }
    }
    crc
}
Enter fullscreen mode Exit fullscreen mode

This runs on any no_std target with no allocations. Fast and predictable.

Structuring the Driver

I like to structure embedded drivers around the embedded-hal traits so they work on any platform. The core struct is generic over the I2C implementation and a delay provider:

use embedded_hal::i2c::I2c;
use embedded_hal::delay::DelayNs;

const SEN5X_ADDR: u8 = 0x69;

pub struct Sen5x<I2C, D> {
    i2c: I2C,
    delay: D,
}

impl<I2C, D> Sen5x<I2C, D>
where
    I2C: I2c,
    D: DelayNs,
{
    pub fn new(i2c: I2C, delay: D) -> Self {
        Self { i2c, delay }
    }
}
Enter fullscreen mode Exit fullscreen mode

The default I2C address for SEN5x is 0x69. It is not configurable -- every SEN5x module uses this address.

Sending Commands

Every command follows the same pattern: write two bytes, optionally wait, optionally read back data. Let us build a helper:

impl<I2C, D> Sen5x<I2C, D>
where
    I2C: I2c,
    D: DelayNs,
{
    fn write_command(&mut self, command: u16) -> Result<(), I2C::Error> {
        let bytes = command.to_be_bytes();
        self.i2c.write(SEN5X_ADDR, &bytes)
    }

    fn read_words_no_alloc(
        &mut self,
        command: u16,
        delay_us: u32,
        buf: &mut [u16],
    ) -> Result<(), Error<I2C::Error>> {
        self.write_command(command)
            .map_err(Error::I2c)?;

        self.delay.delay_us(delay_us);

        let num_words = buf.len();
        let mut raw = [0u8; 24];
        let read_len = num_words * 3;
        self.i2c
            .read(SEN5X_ADDR, &mut raw[..read_len])
            .map_err(Error::I2c)?;

        for (i, chunk) in raw[..read_len].chunks(3).enumerate() {
            let crc = crc8(&chunk[..2]);
            if crc != chunk[2] {
                return Err(Error::Crc);
            }
            buf[i] = u16::from_be_bytes([chunk[0], chunk[1]]);
        }

        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

This no_std-friendly version uses a fixed-size stack buffer. No heap allocations, no alloc crate needed.

Error Handling

A proper driver needs a clear error type:

#[derive(Debug)]
pub enum Error<E> {
    /// I2C bus error
    I2c(E),
    /// CRC checksum mismatch
    Crc,
    /// Command not allowed in current state
    NotAllowed,
    /// Sensor reported internal error
    Internal,
}
Enter fullscreen mode Exit fullscreen mode

The error is generic over the I2C error type so you get the platform's original error information propagated through. NotAllowed catches invalid command sequences (for example, reading measurements before starting them), and Internal surfaces faults reported by the sensor itself. Enable the thiserror feature for std::error::Error integration, or defmt for embedded-friendly formatting.

Reading Measurements

The SEN5x measurement flow is: start measurement, wait for data-ready, read the values. Here are the key commands:

Command Code Description
Start Measurement 0x0021 Begin continuous measurement
Stop Measurement 0x0104 Stop measurement mode
Data Ready 0x0202 Check if new data is available
Read Measured Values 0x03C4 Read all sensor values

Putting it together:

pub struct SensorData {
    pub mass_concentration_pm1p0: f32,
    pub mass_concentration_pm2p5: f32,
    pub mass_concentration_pm4p0: f32,
    pub mass_concentration_pm10p0: f32,
    pub ambient_humidity: f32,
    pub ambient_temperature: f32,
    pub voc_index: f32,
    pub nox_index: f32,
}

impl<I2C, D> Sen5x<I2C, D>
where
    I2C: I2c,
    D: DelayNs,
{
    pub fn start_measurement(&mut self) -> Result<(), Error<I2C::Error>> {
        self.write_command(0x0021).map_err(Error::I2c)?;
        self.delay.delay_ms(50);
        Ok(())
    }

    pub fn data_ready(&mut self) -> Result<bool, Error<I2C::Error>> {
        let mut buf = [0u16; 1];
        self.read_words_no_alloc(0x0202, 20_000, &mut buf)?;
        Ok(buf[0] & 0x01 != 0)
    }

    pub fn measurement(&mut self) -> Result<SensorData, Error<I2C::Error>> {
        let mut words = [0u16; 8];
        self.read_words_no_alloc(0x03C4, 20_000, &mut words)?;

        Ok(SensorData {
            mass_concentration_pm1p0: words[0] as f32 / 10.0,
            mass_concentration_pm2p5: words[1] as f32 / 10.0,
            mass_concentration_pm4p0: words[2] as f32 / 10.0,
            mass_concentration_pm10p0: words[3] as f32 / 10.0,
            ambient_humidity: words[4] as i16 as f32 / 100.0,
            ambient_temperature: words[5] as i16 as f32 / 200.0,
            voc_index: words[6] as f32 / 10.0,
            nox_index: words[7] as f32 / 10.0,
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

A few things to note about the value scaling:

  • mass_concentration_pm* values are unsigned 16-bit divided by 10 (unit: ug/m3)
  • ambient_temperature is signed 16-bit divided by 200 (unit: degrees Celsius)
  • ambient_humidity is signed 16-bit divided by 100 (unit: %RH)
  • voc_index and nox_index are unsigned 16-bit divided by 10 (dimensionless index)

The signed/unsigned distinction is important. Casting a u16 to i16 before converting to f32 handles the two's complement correctly for temperature and humidity values.

Using the Driver

Here is what the application code looks like on an RP2040:

#![no_std]
#![no_main]

use rp2040_hal as hal;
use hal::i2c::I2C;
use hal::Timer;
use defmt_rtt as _;
use panic_probe as _;

#[hal::entry]
fn main() -> ! {
    let mut pac = hal::pac::Peripherals::take().unwrap();
    let sio = hal::Sio::new(pac.SIO);
    let pins = hal::gpio::Pins::new(
        pac.IO_BANK0,
        pac.PADS_BANK0,
        sio.gpio_bank0,
        &mut pac.RESETS,
    );

    let i2c = I2C::i2c0(
        pac.I2C0,
        pins.gpio4.into_function(),  // SDA
        pins.gpio5.into_function(),  // SCL
        400.kHz(),
        &mut pac.RESETS,
        125.MHz(),
    );

    let timer = Timer::new(pac.TIMER, &mut pac.RESETS);
    let mut sensor = Sen5x::new(i2c, timer);

    sensor.start_measurement().unwrap();

    loop {
        if sensor.data_ready().unwrap() {
            let m = sensor.measurement().unwrap();
            defmt::info!(
                "PM2.5: {} ug/m3, Temp: {} C, Humidity: {} %RH, VOC: {}",
                m.mass_concentration_pm2p5, m.ambient_temperature, m.ambient_humidity, m.voc_index
            );
        }
        // SEN5x updates roughly every 1 second
        timer.delay_ms(1000);
    }
}
Enter fullscreen mode Exit fullscreen mode

Clean, readable, and fully type-safe. No unsafe blocks, no C FFI, no raw pointer arithmetic.

Testing with Mock I2C

The embedded-hal-mock crate lets you write unit tests for your driver on your development machine. You define expected I2C transactions and the mock verifies them:

#[cfg(test)]
mod tests {
    use super::*;
    use embedded_hal_mock::eh1::i2c::{Mock as I2cMock, Transaction as I2cTransaction};
    use embedded_hal_mock::eh1::delay::NoopDelay;

    #[test]
    fn test_crc8() {
        assert_eq!(crc8(&[0xBE, 0xEF]), 0x92);
        assert_eq!(crc8(&[0x00, 0x00]), 0x81);
    }

    #[test]
    fn test_data_ready() {
        let expectations = vec![
            I2cTransaction::write(SEN5X_ADDR, vec![0x02, 0x02]),
            I2cTransaction::read(SEN5X_ADDR, vec![0x00, 0x01, crc8(&[0x00, 0x01])]),
        ];
        let i2c = I2cMock::new(&expectations);
        let delay = NoopDelay::new();
        let mut sensor = Sen5x::new(i2c, delay);

        assert!(sensor.data_ready().unwrap());
    }
}
Enter fullscreen mode Exit fullscreen mode

This catches protocol bugs before you flash hardware. Highly recommended.

Lessons Learned

1. Always validate CRC. I2C buses in real hardware are noisy. I initially skipped CRC checks during development and got intermittent garbage readings that were hard to debug. CRC validation catches these immediately.

2. Respect the timing. The SEN5x datasheet specifies minimum delays between sending a command and reading the response. Skipping these delays causes NACK responses. The delays are not optional.

3. The embedded-hal 1.0 migration was worth it. If you are still on embedded-hal 0.2, the I2c trait in 1.0 is cleaner and combines read/write/write-read into a single trait. The migration is straightforward and the result is more ergonomic.

4. Test with mock I2C. You can write and validate your entire driver logic without touching hardware. This speeds up development significantly.

What About Existing Crates?

You might find existing SEN5x crates on crates.io. Before reaching for one, consider whether it targets embedded-hal 1.0 (released stable in early 2024). Many embedded sensor crates still target 0.2, and mixing HAL versions in one project creates compatibility headaches. Building your own driver is also an excellent way to understand the sensor's protocol deeply, which helps when debugging real hardware issues.

Wrapping Up

The Sensirion SEN5x is a capable module that packs a lot of environmental sensing into a single I2C device. Rust's embedded-hal ecosystem makes it straightforward to write a portable driver that works across microcontroller families.

The combination of strong typing, zero-cost abstractions, and no_std support makes Rust genuinely productive for embedded sensor work. No runtime overhead, no hidden allocations, and the compiler catches protocol mistakes at build time instead of at 3 AM when your sensor readings go wrong.

If you are working on IoT or environmental monitoring projects in Rust, I would love to hear about your setup. Drop a comment or find me on GitHub.


Follow me for more embedded Rust content and practical driver guides. You can also check out oxidt.com for more articles on Rust development.

Top comments (0)