Raspberry Pi PM2.5/10 Air Quality Monitor

jeikabu profile image jeikabu Originally published at rendered-obsolete.github.io on ・6 min read

I live and work in Shanghai. We talk about the Air Quality Index (AQI) like people in other places talk about the weather (incidentally, we talk about the weather as well). Die-hard enthusiasts can get an air quality forecast:

I’ve been looking for something to justify the existance of my Raspberry Pi, and came across this project on HackerNoon (and a very recent update) using an SDS011 particle sensor to measure AQI. Fun Fact : the picture at the top of the HackerNoon post is Shanghai, West of the HuangPu River (“Puxi”)- most photos are of the Eastern skyline (“Pudong”).

There’s several existing projects:

We’ll primarily focus on the last two along with serial-rs which both Rust versions used.


It’s easiest to initially get started on PC/Mac using the USB adapter that comes with the sensor.

“sds011-rs” provides a direct link to the Mac driver for the USB adapter (“ch341-uart converter”) and below that is support for Windows, etc. Follow the instructions in the ReadMe.pdf inside the archive.

If you check “System Report” or “System Information” the adapter will come up as USB 2.0-Serial :

Find the name of the device:

$ ls /dev/tty.wch*


The number at the end seems to come from “Location ID” and may vary slightly if you re-connect the USB adapter.


First we need to open the /dev/tty.wchusbserial141230 serial device:

pub fn new(path: &Path) -> Result<Self, serial::Error> {
    let serial_port = serial::open(path)?;
    let sensor = Sensor {
        device_id: None,

pub fn configure(&mut self, timeout: Duration) -> serial::Result<()> {
    const PORT_SETTINGS: serial::PortSettings = serial::PortSettings {
        baud_rate: serial::Baud9600,
        char_size: serial::Bits8,
        parity: serial::ParityNone,
        stop_bits: serial::Stop1,
        flow_control: serial::FlowNone,


Before requesting a reading, make sure the sensor isn’t sleeping by setting it to the Measuring “work state”:

let wake_command = SendData::set_work_state(WorkState::Measuring);

Commands are 19 bytes long with the following format:

0 1 2 3 - 14 15 16 17 18
0xAA 0xB4 Command Data (trailing 0’s) 0xFF 0xFF Checksum 0xAB

“Command” field is one of the following:

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Command {
    /// Get/set reporting mode. `Initiative` (0) to automatically generate measurements, or `Passive` (1) to use `Request` commands.
    ReportMode = 2,
    /// Query measurement. When `ReportMode` is `Passive`
    Request = 4,
    /// Get device id
    DeviceId = 5,
    /// Get/set sleeping (0) or awake/measuring (1) state
    WorkState = 6,
    /// Get firmware version
    Firmware = 7,
    /// Get/set duty cycle
    DutyCycle = 8,

Format of “data” depends on the command:

Bits 3 4 - 14 Details
0 all 0 “Get” value (includes Request)
Bits 3 4 - 14 Details
1 value (trailing 0s) “Set” value (excludes Request)

The checksum is calculated by adding bytes 2 to 14 (from the command to before the checksum):

pub fn generate_checksum(data: &[u8]) -> u8 {
    let mut checksum: u8 = 0;
    for data in &data[2..] {
        checksum = checksum.wrapping_add(*data);

// Alternatively, using `u16` to prevent overflow and truncating/taking modulus
pub fn generate_checksum(data: &[u8]) -> u8 {
    let mut checksum: u16 = 0;
    for data in &data[2..] {
        checksum += u16::from(*data);
    checksum as u8

Either using wrapping arithmetic, truncating, or using modulus (%- as seen in several implementations) to deal with overflow.

Note that in one of the implementations there’s checksum - 2. The - 2 is from the command terminating bytes 0xFF which is the two’s complement form of -1 (i.e. checksum + 0xFF + 0xFF is checksum - 1 - 1).

After sending a command, there is a response of 10 bytes:

0 1 2 - 5 6 - 7 8 9 Details
0xAA 0xC0 Data Device Id Checksum 0xAB Receive measurement with Initiative reporting or via Request with Passive reporting
0 1 2 3 4 - 5 6 - 7 8 9 Details
0xAA 0xC5 Command 0 Value Device Id Checksum 0xAB “Get” command
0xAA 0xC5 Command 1 Value Device Id Checksum 0xAB “Set” command

Which is read with code similar to (simplified for clarity):

type Response = [u8; 10];

let mut bytes_received = Response::default();
let mut num_bytes_received;
loop {
    // Read the first byte looking for a "Start" (0xAA)
    num_bytes_received = match self.serial_port.read(&mut bytes_received[..1]) {
        Ok(num_bytes) => num_bytes
        Err(_) => // Error handling
    if num_bytes_received == 0 {
    match Serial::try_from(bytes_received[0]) {
        Ok(Serial::Start) => {} // Ok, got `0xAA`
        other => continue;

    // Read the second byte looking for either `0xC0` or `0xC5`
    num_bytes_received += match self.serial_port.read(&mut bytes_received[1..2]) {
        Ok(read) => read,
        Err(_) => // Error handling
    let serial_read = Serial::try_from(bytes_received[1]);
    if serial_read == Ok(Serial::ResponseByte) || serial_read == Ok(Serial::ReceiveByte) {
        // Read remaining 8 bytes
        let num_bytes = self.serial_port.read(&mut bytes_received[2..])?;
        num_bytes_received += num_bytes;
// Return all 10 bytes

Finally, the pm 2.5 and 10 readings can be calculated from the 10 byte response:

fn response_to_measurement(data: Response) -> SensorMeasurement {
    let pm2_5 = (f32::from(data[2]) + f32::from(data[3]) * 256.0) / 10.0;
    let pm10 = (f32::from(data[4]) + f32::from(data[5]) * 256.0) / 10.0;
    SensorMeasurement { pm2_5, pm10 }


We’ll assume you already have Raspbian installed and running on a Raspberry Pi 3 (although this likely also works for a 2).

Plug the sds011 into a USB port and check the output of dmesg:

[69000.284345] usb 1-1.5: new full-speed USB device number 6 using dwc_otg
[69000.417442] usb 1-1.5: New USB device found, idVendor=1a86, idProduct=7523, bcdDevice= 2.63
[69000.417458] usb 1-1.5: New USB device strings: Mfr=0, Product=2, SerialNumber=0
[69000.417467] usb 1-1.5: Product: USB2.0-Serial
[69000.418425] ch341 1-1.5:1.0: ch341-uart converter detected
[69000.421856] usb 1-1.5: ch341-uart converter now attached to ttyUSB0

A device with the correct Vendor/Product ID is now attached to ttyUSB0, so the sensor is available as /dev/ttyUSB0.

You can copy the code over and build directly on the Pi. But, I’m impatient, so we’re going to cross-compile for armv7 using musl.

In .cargo/config (or ~/.cargo/config):

linker = "arm-linux-musleabihf-gcc"

On PC/Mac:

# 1. Build it
CROSS_COMPILE=arm-linux-musleabihf- cargo build --target armv7-unknown-linux-musleabihf
# 2. Copy it to the Pi (if `raspberrypi.local` doesn't work, use the pi's IP address)
scp target/armv7-unknown-linux-musleabihf/debug/pm25 pi@raspberrypi.local:~/
# 3. Ssh in and run it
ssh pi@raspberrypi.local ./pm25 /dev/ttyUSB0

It didn’t immediately work. Turns out the serial port timeout enabled with serial::unix::TTYPort::set_timeout() isn’t working. Rather than attempting a read and see if there’s a timeout to set the sensor to Measuring, just set it.


The Pi 2/3 have plenty of USB ports, but depending on your usage it might be worth connecting the SDS011 to the GPIO pins rather than having the USB adapter sticking out.

The SDS011 pins are labeled clearly on both the sensor and the USB adapter and align with that found on the various Pis. You can confirm this by looking at a diagram of the Pi GPIO pins, starting with the upper-left corner of the board:

SDS011 NC 5v G Rx Tx
Pi3 5v 5v G Tx (14) Rx (15)

So, the sensor can be connected directly to the Pi, like this:

Make sure the wires connect the Pi and the SDS011 according to the above table (with Tx->Rx).

The official UART document is a bit unclear, but to use the serial GPIO (pins 14 and 15) the UART(s) on the Pi must be configured:

sudo echo "enable_uart=1" >> /boot/config.txt

sudo vim /boot/cmdline.txt
# Remove `console=serial0,115200`

# Restart for changes to take effect
sudo shutdown -r now

Now the sensor can be accessed via /dev/ttyS0.

Once again, things didn’t immediately work. Now serial::unix::TTYPort::read() only returns one byte at a time. Either need to replace it with read_exact() or use a loop:

while num_bytes_received < bytes_received.len() {
    let num_bytes = self.serial_port.read(&mut bytes_received[num_bytes_received..])?;
    num_bytes_received += num_bytes;

In Closing

Was a fun and pretty simple project. Some of my friends had a good laugh when I gave them live, “play-by-play” AQI readings during brunch on Saturday.

Pushed all the code to github.


Editor guide
halfshavedyaks profile image

doing this as suggested:

Remove console=serial0,115200

seems to break my ssh connection to the pi - it becomes unreliable and gives me broken pipe errors when the SDS011 is active.

does the ssh need that setting? can the SDS011 be connected to the pi on the pins and still allow ssh in some way?

jeikabu profile image
jeikabu Author

I don’t recall it breaking ssh.

The official pi docs still have that in “manual” instructions. You can see if the GUI tool does something else:

sudo raspi-config

Select option 5, Interfacing options, then option P6, Serial, and select No. Exit raspi-config.

halfshavedyaks profile image

I am using a pi zero W headless and ssh over wifi on the lan - could the ssh setup therefore be different from yours?

I can't use the gui tool as I set up the pi without a desktop.

it is hard to tell since the broken pipe events seem to be slightly random, and they don't show an error straight away. but I think the problem arises when I try to do something over ssh while the pi is communicating with the sensor.

can you share a link to the manual instructions you mention?

I confess I didn't know what a uart is until yesterday- but in this doc here:


it says "The primary UART is the one assigned to the Linux console, which depends on the Raspberry Pi model as described above. There are also symlinks: /dev/serial0, which always refers to the primary UART"

is the linux console referred to in that quote the same as the terminal in which I am connecting over ssh? If so it suggests a conflict.

Thread Thread
jeikabu profile image
jeikabu Author

It's the same as the link in the post:

raspi-config is probably TUI and can be used in a terminal.

Thread Thread
halfshavedyaks profile image

OK thanks, now I understand what you are suggesting. I will try using raspi-config

can you tell me anything about the seeming conflict with the sensor and the console both apparently using the primary uart? how does that work?

Thread Thread
halfshavedyaks profile image

I tried using raspi-config with the same results - it will read the sensor and it will communicate over ssh in the terminal, but not both at once - if any communication with the pi (including reading web page from lightpd) happens while the sensor is being read then communications break and I get a broken pipe.

Thread Thread
halfshavedyaks profile image

maybe it is unrelated - hard for me to tell. but when I connect to the SD011 over usb I have no problems with the ssh connection, or at least far less often.

Thread Thread
jeikabu profile image
jeikabu Author

I don't recall having issues with SSH, but I don't remember if I was using wifi or ethernet.
Perhaps it's related to using a Pi0, I was using a Pi3.

Thread Thread
randyrue profile image
Randy Rue

I'm also trying to do this with an SDS011 and a pi xero w but can't get the sensor to detect via USB when I plug it in. I thought the pi might not have enough power for the sensor board as when I unplug and replug the sensor USB adapter the pi reboots, but have the same problem when I plug the sensor in via a powered USB hub with a 3A supply.

In either case I don't get any device at /dev/tty/USB0 and there's nothing in dmesg about it.

When I plug the sensor board into a linux laptop I see it in dmesg and at /dev/tty/USB0 so I'm thinking there's nothing wrong with the sensor.

Will hope to hear from you if you have any guidance and will look into connecting the UART directly in the meantime.


Randy in Seattle

Thread Thread
jeikabu profile image
jeikabu Author

I didn't personally try a Pi0, but I'd expect it to work. Did you follow the latest uart instructions?
Try it with another device, although unlikely it's possible your Pi0 is defective.

Thread Thread
randyrue profile image
Randy Rue

I have it working.

I connected directly via the serial GPIO header and disabled console output to S0 and it now works if I set the code to look to /dev/tty/S0

But it still rebooted the pi if I put the SDS011 to sleep and then woke it up, even if I connected the pi to a benchtop 5V power supply.

Finally I separated the power/ground traces of the UART ribbon cable and hacked my micro USB cable so it powers both the pi and also directly powers the SDS011. Now it detects, returns data, and the pi survives a sleep/wake cycle without rebooting.

Next I'm adding a DHT22 temp/humidity sensor and a BMP180 barometric pressure sensor so i can have a more complete "weather" station. I've installed grafana and will create a simple dashboard with the metrics available in a web interface.

Then comes four wheel drive, heated seats and a popcorn machine :)