Hold your Breath for this Air Quality Sensor

Recently I discovered the luftdaten.info project that provides a simple blueprint for measuring particulate matter, and in this post I am sharing how I built and set up my own device and integrated it into Home Assistant.

The project was originally founded in Stuttgart/Germany and has since found many volunteers who created their own particulate matter sensor and share their data with the project. luftdaten.info is aggregating the received data and provides the data for example through an API or on a map.

Home Assistant actually has an API integration with luftdaten.info available but I decided to integrate my own device directly instead of making the detour through the cloud.

Building the Device

I used a Wemos D1 Mini Pro, a Nova SDS011 particulate matter sensor and a BME280 sensor for measuring temperature, humidity and air pressure. The assembly instructions on the website are pretty straightforward and I even followed their suggestion and used some PVC pipes/elbows for the casing. The details of how to connect the BME280 sensor are described separately in the FAQ section (German only).

Wiring

The following wiring detail basically just reproduces what can be found on the luftdaten.info website, but represents my configuration with the optional BME280 sensor.

BME280 SensorESP
GNDGND
VIN3V3
SDAD3
SCLD4

Make sure you connect the BME280 sensor to 3.3V, and the SDS011 sensor to 5V.

SDS011 SensorESP
GNDGND
5V5V
RXDD2
TXDD1
Luftdaten device – wiring the sensors

Particulate matter sensor

The SDS011 sensor measures two sizes of particles – 10µm or less as PM10, and 2.5µm or less as PM2.5. It comes with a little fan built in to suck in the air and then uses laser detection to measure the amount of particles. According to its data sheet the sensor has a lifespan of about 8000 hours, i.e. if running this continuously you would only get less than one year out of it. Luckily the luftdaten firmware lets you specify the measuring interval and hibernates the sensor in between to increase its life.

Assembly

As usual I used a prototype PCB to expose pin headers for connecting the sensors. I drilled a hole into the PCB to then screw it directly to the SDS011 board to keep the two together in the casing.

Luftdaten device before assembly

As recommended I connected a clear PVC tube to the SDS011 sensor’s air inlet. The end of that tube will be close to the bottom opening of the casing and so I tied the BME280 sensor to that tube.

Luftdaten device parts connected top view
Luftdaten device parts connected bottom view

I bought 2 PVC 80mm socket bend downpipes and sawed off the socket of one of them so that they neatly fit. The 2 PVC 80mm gutter pop outlets will be modified to become lids.

Luftdaten device case parts

The electronics just fit into the downpipe’s opening.

Luftdaten device in case top view
Luftdaten device in case front view

The tube is just long enough to reach the opening at the bottom.

Luftdaten device in case tube and environment sensor

The sawn-off piece has a cable gland installed at the bottom where I will thread the USB cable through.

Luftdaten device case part with cable gland

To make an insect-proof lid I power-glued PVC mesh to the bottom of each outlet piece that I originally bought as PC cooling fan protection.

Luftdaten device with mesh-protected lid

All four pipe pieces are held together by small wood screws. And because the sensor will eventually need to survive rainy days I used a bit of thread seal tape for the connection of the two bends at the top of the casing.

Luftdaten device fully assembled rear view

Et voilà – the fully assembled device with long USB cable for power, ready to be mounted outside.

Luftdaten device fully assembled front view

Installing software

Uploading the firmware requires a bit more effort, but again the instructions on their website will get you through that part just fine. After this initial upload I configured the device to update its firmware automatically over-the-air, and that has worked fine so far.

At the very first start of the device it creates its own Wifi access point that you can connect to and from there on the configuration of the new device is done through the built-in web UI.

The following screenshot shows the web UI’s home page from where you can access the sensor values as well as the device’s configuration.

Luftdaten web UI home screen

Device configuration

The data is sent online to the luftdaten.info API for sharing. Please note that you will need to register your device on the luftdaten.info website so that they can actually make use of your contribution.

Luftdaten web UI configuration (1/3)

In terms of configuring the device you really only need to tick the sensors you have connected. I decided to let the device auto-update its firmware.

The default measuring interval is 300 seconds. Because the SDS011 sensors has a limited lifespan, I would recommend not to measure too often – measuring every 5 minutes should be more than sufficient and increases the life of the device to several years.

Luftdaten web UI configuration (2/3)

And I also store each measurement in a local InfluxDB instance which requires the configuration of the IP address of the server running InfluxDB as well as username and password. I left path and port at their default values. More on the InfluxDB integration with Home Assistant follows below.

Luftdaten web UI configuration (3/3)

Viewing sensor data on the device

After a fresh reboot of the device you will have to wait for the duration of the measuring interval until the first measurement is taking place. The following screen shows that the device is working as expected.

Luftdaten web UI sensor data

Integration into Home Assistant

Initially I used Home Assistant’s rest sensor to grab sensor data as JSON from the device to then extract the relevant data and display in Home Assistant. However, after a while I noticed that the device was frequently unresponsive for a couple of seconds – I assume while it was spinning up the SDS011 sensor and taking a measure – which in turn then frequently left the sensors showing as “unavailable” in the Home Assistant UI.

Unfortunately the firmware does not have MQTT built in, so I decided to go with the next best option – InfluxDB. I don’t want to go into details on how to set up InfluxDB in this article, but I haven’t really put in a lot of effort myself and just went with the available InfluxDB and Chronograf packages for my Ubuntu server which also hosts Home Assistant.

Retrieving sensor data from InfluxDB

The following Home Assistant configuration snippets are stored in a package file, and I have only split them up here to insert some explanations.

Assuming that all data is nicely flowing from the device into InfluxDB, a influxdb sensor is required. Each query turns into a separate sensor and to find the correct parameters I recommend confirming the details using InfluxDB’s companion Chronograf, but because the luftdaten firmware automatically decided how to name things internally I would expect that the following values should work with the sensors used in my build.

For example, the first query “PM 10 Concentration” looks into the “luftdaten” database and its “feinstaub” measurement which groups all values retrieved from the device. The field “SDS_P1” contains the PM 10 value, and as group_function I selected “last” which returns the last recorded value from the dataset. The where clause limits the query to data retrieved in the last hour which ensures that Home Assistant either has up-to-date data or shows the sensor data as unavailable. In the value_template line I am just rounding the provided value to one decimal.

The second entry “PM 10 Concentration Mean” is based on the same dataset, but calculates the mean (see group_function) of all values retrieved in the last 24 hours.

I then configure two similar queries for the PM2.5 values, followed by temperature, humidity and pressure coming from the BME280 sensor.

Please note that the BME280 sensor provides the actual air pressure value while typically you want to adjust the pressure value to sea level. My sensor is about 200 metres above sea level, and I am using the value_template to adjust the value accordingly. The pressure value stored in InfluxDB is in Pa, and to convert that into more common hPa you will need to divide the value by 100.

The following formula is derived from the BMP180 datasheet and can be used to calculate the pressure at sea level, including unit conversion.

pressure\;at\;sea\;level\;(in\; hPa) = \frac{pressure\;(in\;Pa)}{(1-\frac{altitude\;in\;metres}{44330})^{5.255}\cdot100}

Long story short, I’d recommend to keep the maths in the value_template simple and pre-calculate the divisor (bottom of the fraction).

sensor:
  - platform: influxdb
    username: luftdaten
    password: !secret luftdaten_influxdb_password
    entity_namespace: luftdaten
    queries:
      - name: PM 10 Concentration
        unit_of_measurement: 'µg/m³'
        value_template: '{{ value | round(1) }}'
        group_function: last
        database: luftdaten
        measurement: feinstaub
        field: SDS_P1
        where: 'time > now() - 1h'
      - name: PM 10 Concentration Mean
        unit_of_measurement: 'µg/m³'
        value_template: '{{ value | round(1) }}'
        group_function: mean
        database: luftdaten
        measurement: feinstaub
        field: SDS_P1
        where: 'time > now() - 24h'
      - name: PM 2.5 Concentration
        unit_of_measurement: 'µg/m³'
        value_template: '{{ value | round(1) }}'
        group_function: last
        database: luftdaten
        measurement: feinstaub
        field: SDS_P2
        where: 'time > now() - 1h'
      - name: PM 2.5 Concentration Mean
        unit_of_measurement: 'µg/m³'
        value_template: '{{ value | round(1) }}'
        group_function: mean
        database: luftdaten
        measurement: feinstaub
        field: SDS_P2
        where: 'time > now() - 24h'
      - name: Outside Temperature
        unit_of_measurement: '°C'
        value_template: '{{ value | round(1) }}'
        group_function: last
        database: luftdaten
        measurement: feinstaub
        field: BME280_temperature
        where: 'time > now() - 1h'
      - name: Outside Humidity
        unit_of_measurement: '%'
        value_template: '{{ value | round(1) }}'
        group_function: last
        database: luftdaten
        measurement: feinstaub
        field: BME280_humidity
        where: 'time > now() - 1h'
      - name: Outside Pressure
        unit_of_measurement: 'hPa'
        value_template: '{{ ((value | float) / 97.65179) | round(1) }}'
        group_function: last
        database: luftdaten
        measurement: feinstaub
        field: BME280_pressure
        where: 'time > now() - 1h'

Air quality presentation

The mean value is then used to generate a more tangible air quality representation. In this case I am using the Air Quality Index recommended by the NSW Office of Environment & Heritage. Depending on where you live you may want to use a different way of calculating and presenting the air quality, and Wikipedia has collated air quality index definitions from around the world that may give you some ideas. Just make sure you understand how to calculate the index which usually is not the same as the measured value in µg/m³.

  - platform: template
    sensors:
      # Air Quality Index = (PM10 mean over 1 day / 50) * 100
      air_quality_pm10:
        friendly_name: 'Air Quality PM 10'
        value_template: >-
          {%if ((states.sensor.luftdaten_pm_10_concentration_mean.state | float) * 2.0) <= 33 %}Very Good
		  {% elif ((states.sensor.luftdaten_pm_10_concentration_mean.state | float) * 2.0) <= 66 | float > 33 %}Good
          {% elif ((states.sensor.luftdaten_pm_10_concentration_mean.state | float) * 2.0) <= 99 | float > 66 %}Fair
          {% elif ((states.sensor.luftdaten_pm_10_concentration_mean.state | float) * 2.0) <= 149 | float > 99 %}Poor
          {% elif ((states.sensor.luftdaten_pm_10_concentration_mean.state | float) * 2.0) <= 200 | float > 149 %}Very Poor
          {% elif ((states.sensor.luftdaten_pm_10_concentration_mean.state | float) * 2.0) > 200 %}Hazardous
          {%- endif %}
        entity_id: sensor.luftdaten_pm_10_concentration_mean
      # Air Quality Index = (PM2.5 mean over 1 day / 25) * 100
      air_quality_pm25:
        friendly_name: 'Air Quality PM 2.5'
        value_template: >-
          {%if ((states.sensor.luftdaten_pm_2_5_concentration_mean.state | float) * 4.0) <= 33 %}Very Good
          {% elif ((states.sensor.luftdaten_pm_2_5_concentration_mean.state | float) * 4.0) <= 66 | float > 33 %}Good
          {% elif ((states.sensor.luftdaten_pm_2_5_concentration_mean.state | float) * 4.0) <= 99 | float > 66 %}Fair
          {% elif ((states.sensor.luftdaten_pm_2_5_concentration_mean.state | float) * 4.0) <= 149 | float > 99 %}Poor
          {% elif ((states.sensor.luftdaten_pm_2_5_concentration_mean.state | float) * 4.0) <= 200 | float > 149 %}Very Poor
          {% elif ((states.sensor.luftdaten_pm_2_5_concentration_mean.state | float) * 4.0) > 200 %}Hazardous
          {%- endif %}
        entity_id: sensor.luftdaten_pm_2_5_concentration_mean

Please note that Home Assistant 0.85 introduced a slightly different way of how entity ids are generated from sensor names. If you use an older version, for example the PM2.5 mean sensor would get the entity id `sensor.luftdaten_pm_25_concentration_mean` instead.

Monitoring for firmware version updates

I defined one rest sensor to obtain the current software version. Originally I was planning to implement an automation that gets triggered when the version changes, but because the JSON access is not reliably available, the automation produced too many false alarms. A solution to this could be to improve the rest sensor so that it retries after a short amount of time if the initial connection fails.

  - platform: rest
    name: Software Version
    entity_namespace: luftdaten
    resource: http://<luftdaten device ip address>/data.json
    method: GET
    force_update: true
    scan_interval: 600
    value_template: '{{ value_json["software_version"] }}'

Some final touches

The following bit is required to hide the numeric mean values, and to add some nice cloud icons for the air quality sensors.

homeassistant:
  customize:
    sensor.luftdaten_pm_10_concentration_mean:
      hidden: true
    sensor.luftdaten_pm_2_5_concentration_mean:
      hidden: true
  customize_glob:
    "sensor.air_quality*":
      icon: mdi:cloud-outline
    "sensor.luftdaten_pm*_concentration":
      icon: mdi:cloud-outline

Displaying values in the user interface

For now I simply grouped all sensors in the UI.

Air quality sensor panel

Alerting

For now I kept the alerting part fairly simple and just send myself a notification each time the air quality state changes. Due to the long (24 hours mean) time it takes until the air quality state actually changes I am not expecting hat individual outliers at that time make this automation to trigger too often. If it does a simple for with a value of 5 minutes or so should alleviate that issue.

- id: air_quality_pm10_changed
  alias: "Air Quality PM10 Changed"
  trigger:
    - platform: state
      entity_id: sensor.air_quality_pm10
  action:
    - service: notify.pushover
      data_template:
        message: "Air Quality PM10 changed to <font color='#0000ff'>{{ trigger.to_state.state }}</font> (was <font color='#0000ff'>{{ trigger.from_state.state }}</font>).<br/>Air Quality PM2.5 is <font color='
#0000ff'>{{ states.binary_sensor.air_quality_pm25.state }}</font>"
        title: "Air Quality Changed"
        data:
          html: 1
- id: air_quality_pm25_changed
  alias: "Air Quality PM2.5 Changed"
  trigger:
    - platform: state
      entity_id: sensor.air_quality_pm25
  action:
    - service: notify.pushover
      data_template:
        message: "Air Quality PM2.5 changed to <font color='#0000ff'>{{ trigger.to_state.state }}</font> (was <font color='#0000ff'>{{ trigger.from_state.state }}</font>).<br/>Air Quality PM10 is <font color='
#0000ff'>{{ states.binary_sensor.air_quality_pm10.state }}</font>"
        title: "Air Quality Changed"
        data:
          html: 1

Outlook

I can’t really say how accurate the measured values really are, but I could certainly see a significant increase in the particulate matter concentration well above 100µg/m³ during a heavy dust storm we had back in November 2018.

According to its data sheet, the SDS011 sensor is only providing reliable data up to 70% relative humidity. I have occasionally noticed a few outliers during heavy thunderstorms, but no ongoing obvious issues. I’ll continue to observe this, and it may come in handy to have a humidity sensor on board, and take the measured humidity into account, maybe as an indicator for accuracy.

Compatibility

At the time of writing this post, I used:

  • Home Assistant 0.85.1 with Python 3.6.7
  • luftdaten.info firmware NRZ-2018-123B
  • InfluxDB 1.7.2 and Chronograf 1.7.5
  • Wemos D1 mini Pro

Update 29 Oct 2019

Today there has been some hazard reduction going on less than 10 kilometres away from our home, and not only could you see and smell the smoke, but the PM10 concentration visibly went up from about 9am this morning.

PM 10 Concentration Daily Graph

Update 19 Nov 2019

You may have seen in the news that that are currently many bushfires ongoing in New South Wales, some of which in and around Sydney. Over night a lot of smoke has been spreading across the city. Unfortunately, I found a small bug in the sensor configuration that calculates the air quality index correctly. I fixed the configuration above in the meantime. Now my sensors correctly show a “Hazardous” air quality. Time to automatically close the windows – if I had any that I could remote control.

Leave a Reply

Your email address will not be published. Required fields are marked *