In Part 1: Build or Buy, I discussed the various pre-built Air Quality systems you could buy: sensors connected to databases that allow you to graph the data coming out.

I ended up building my own system to monitor air quality.

PMS5003 Sensor

 Blue PMS5003 sensor box with a fan and 6-wire cable coming out of it.
PMS5003 Air Quality Sensor

I bought some PMS5003 sensors from eBay, Banggood, and Aliexpress for $18-$30ea, racing the suppliers to see who would ship fastest.

I bought 3x Pimoroni Breakout Board from Core Electronics for $5ea.

I already had some wires, Raspberry Pis {3, 4, Zero W}, Arduino Unos, and FTDI serial adapters from earlier projects.

My PMS5003 air quality sensor on my desk. It's blue, with 6-wire cable.
My PMS5003 sensor

I was surprised how small the sensor is: I've included a USB-A plug for comparison.

I connected the Breakout board to the cable so I could easily plug into it:

PMS5003 sensor connected to Pomoroni breakout board. Six pins are visible for connecting wires to.
All ready to plug into.

Finding Serial Port from macOS

The PMS5003 sensor talks over a 3.3V serial interface.

I first tried hooking up the sensor to my Mac, rather than using an Arduino. I try to use the a high-level environment first when getting started: it's a lot more fun debugging on a machine with a multitasking, scripting languages, and a debugger than it is futzing with print-statements on an Arduino.

On macOS, the serial ports are represented as filenames. I had no idea what filename my serial port would be. To find out, I used my old favourite command watch to show me a live view of all the files in /dev:

$ cd /dev
$ watch -n0 "bash -c 'echo *'"

Then I plugged/unplugged the serial adapter and watched which filename flashed into existence. For me, this was /dev/tty.usbserial-A501ASBX. There's also /dev/cu.usbserial-A501ASBX, but I don't really understand the difference between the two.

Connecting to the Serial Port

All wired up: PMS5003 connected to Pimoroni Breakout Board, connected to FTDI Serial to USB Adapter

I tried connecting up the breakout board to the FTDI Serial to USB Adapter, and looking at the serial port using screen at 9600 baud:

$ screen /dev/cu.usbserial-A501ASBX 9600

I usually use screen for terminal multiplexing, I didn't realise before that it also talked to serial ports - nice!

But I saw nothing: complete radio silence.

Eventually, after two hours of pulling my hair out, trying to connect with screen, cu, minicom, and eventually getting convinced my serial adapter was bad and trying to connect to it on an Arduino using Adafruit's code... on a whim I tried swapping the TX and RX wires: I swapped the serial adapter's RX to the breakout board's RX (receive), instead of the breakout's TX (transmit), and data started coming through!

Success! Sensor data arriving around every second. Garbled because we're interpreting binary data as ASCII.

That was a bit frustrating. On every other project I've done, you crossover the wires, connecting the receiver's RX to the sender's TX, but I suppose this breakout board is stuck in the middle between the sender and receiver, and 'helpfully' shows the TX port as RX, from the receiver's point of view. Oh well. This reminds me the confusion you get trying to describe software as 'frontend' or 'backend', where it depends on whether you're in front or behind of that software.

Once I saw the binary data coming through on screen, I decoded the binary signal using a combination of a colleague's snippet of Go code, and Adafruit's Arduino code.

I leaned heavily on Adafruit's PM2.5 Air Quality Sensor tutorial for this section.

Saving Measurements to a Database

I'm using Prometheus time-series database for the rest of my home weather monitoring, and I kind-of like it, so I'll keep using Prometheus here. InfluxDB would also be reasonable, as would be using PostgreSQL.

Prometheus has a pull-model, where Prometheus asks for the data, you don't send the data to Prometheus. This doesn't match very well with the model of the PMS5003: continually pushing me a stream of data over the serial port.

To bridge this mismatch, I considered a quick integration with the Prometheus Pushgateway, which lets you push data to a cache that Prometheus later pulls from. But the Pushgateway:

  • doesn't give me uptime monitoring (or errors), and
  • the data can get stale if the pusher stops running, and
  • the extra moving part introduces more latency

So I instead made a persistent "exporter" server in Go, which Prometheus can pull the latest reading from. This is the model I'm following for my other home weather monitoring, so it's familiar to me. This exports the data over an HTTP server on /metrics in Prometheus text format:

# HELP pms_packet_checksum_errors 
# TYPE pms_packet_checksum_errors counter
pms_packet_checksum_errors 178
# HELP pms_particle_counts Number of particles with diameter beyond given number of microns in 0.1L of air
# TYPE pms_particle_counts gauge
pms_particle_counts{microns_lower_bound="10"} 81
pms_particle_counts{microns_lower_bound="100"} 6
pms_particle_counts{microns_lower_bound="25"} 14
pms_particle_counts{microns_lower_bound="3"} 1830
pms_particle_counts{microns_lower_bound="5"} 520
pms_particle_counts{microns_lower_bound="50"} 6
# HELP pms_particulate_matter_environmental micrograms per cubic meter, adjusted for atmospheric environment
# TYPE pms_particulate_matter_environmental gauge
pms_particulate_matter_environmental{microns="1"} 9
pms_particulate_matter_environmental{microns="10"} 18
pms_particulate_matter_environmental{microns="2.5"} 13
# HELP pms_particulate_matter_standard Micrograms per cubic meter, standard particle
# TYPE pms_particulate_matter_standard gauge
pms_particulate_matter_standard{microns="1"} 9
pms_particulate_matter_standard{microns="10"} 18
pms_particulate_matter_standard{microns="2.5"} 13
# HELP pms_received_packets 
# TYPE pms_received_packets counter
pms_received_packets 104604
# HELP pms_skipped_bytes 
# TYPE pms_skipped_bytes counter
pms_skipped_bytes 8469

I ported the decoder to export to Prometheus Gauges, tweaked the error handling, mostly to exit-on-failure so a watchdog could restart the binary. Mostly this was thinking through the error handling to exit rather than loop trying to read forever, and handling the serial port file disappearing.

I put the code for the PMS5003 Prometheus Exporter up on GitHub, and I added a Dockerfile because I'm running the exporter under Docker on my Raspberry Pi.

I added a config for Prometheus to scrape/pull from my new server, and label the server as being in the lounge:

scrape_configs:
  - job_name: 'breathe'
    static_configs:
      - targets: ['breathe:9662']
        labels:
          location: 'Lounge'

You end up with the data persisted neatly into Prometheus' time-series database:

Graph in Prometheus of particulate matter over time. Lines for 1um, 2.5u, 10um Air Quality.
Prometheus can show you the data it's scraped

Dashboard: Grafana

I've got Grafana hooked up to my Prometheus database. Grafana makes relatively decent-looking graphs and has a passable mobile interface.

Shows two graphs of raw particle count (micrograms) and air quality index, with drilldown by location and micron size.
Grafana dashboards, with drilldown by location+size, time window selection, and raw+index numbers.

Running on Raspberry Pi 4

Raspberry Pi 4 has a serial port "UART" header on Pin 10. pinout.xyz was a great resource for this. I connected like so:

Pimoroni Breakout Board (Sensor) Raspberry Pi
5V 5V (pin 2)
GND GND (pin 6)
RX RX (pin 10)

I had to enable the serial port with the instructions on the Pimoroni breakout board Python library README:

# Disable serial terminal over /dev/ttyAMA0
$ sudo raspi-config nonint do_serial 1

# Enable serial port
$ sudo raspi-config nonint set_config_var enable_uart 1 /boot/config.txt

But I didn't need to use pi3-miniuart-bt in my /boot/config.txt (as I'm using a Raspberry Pi 4, not 3).

I popped this Raspberry Pi on top of the shelf in the lounge, hopefully somewhere above where most of the floor dust in the room is.

PMS5003 sensor connected to Raspberry Pi, on top of my shelf.

Running on Raspberry Pi Zero W

I had another Raspberry Pi Zero W lying around the house. I don't have a soldering iron, and my Raspberry Pi Zero W doesn't have the headers attached. Instead, I jerry-rigged an Arduino Uno to be a pass-through Serial-to-USB adapter, which is pretty clunky, but does the job. The main benefit is you get a 5V pin on the Arduino for the PMS5003's fan.

I learned if you connect the Arduino Uno's Reset pin to Ground, the Arduino acts as a raw pass-through Serial-to-USB adapter. I then connected to the Raspberry Pi Zero W's USB port to the Arduino.

Overall, this looks like:

Raspberry Pi USB Socket → Arduino UNO's USB-A port → Pimoroni Breakout Board → PMS5003 Sensor.

Pimoroni Breakout Board (Sensor) Arduino Uno
5V 5V
GND GND
RX RX (pin 0)

I should probably eventually take this into a Maker-space and solder up the pins directly to remove the extra moving part, but this is working "good enough".

Neatly, I found out that Linux has a stable way to refer to serial ports by device-ID, rather than just /dev/serial0 or /dev/serial1, which can point to different devices depending which device is connected first. My Arduino Uno is stably available at the address /dev/serial/by-id/usb-Arduino__www.arduino.cc__0043_55739323137351C042A1-if00.

I popped this one on top of a shelf in the upstairs study, as a counterpoint to the data coming from the downstairs lounge. There's a lot of wires that I don't want to come out as I pull the device up/down, so I put it in a cardboard box to try to keep it together.

PMS5003 sensor connected to Raspberry Pi Zero W, on top of my shelf, in a box.

Future Work

This has been fun, and I'm pretty happy with being able to see the data in real-time. But there's more we could do:

  • Alerting? Perhaps when we stop getting data, or when air quality gets really bad?
  • Make the dashboard available from the public net - but hardening, permissions and certificates will be annoying to get right. I've seen other home weather stations publish to Twitter instead, which lets you share the data easily.
  • Use 2x sensors per Raspberry Pi, and average the data, in case one sensor starts acting up. This is what the PurpleAir sensor does.
  • Solder on the headers for the Raspberry Pi Zero W so I can cut out the Arduino from the middle.
  • Stop running the database & graphing myself - maybe move to some cloud monitoring framework? It's a bit of a pain to be a database administrator, particularly storing data on the 32-bit Raspberry Pi, where Prometheus runs out of address space pretty quickly.