Sharing enlightening moments of home automation

Breathe better with this Indoor Air Quality Sensor

Still holding your breath after building your outside air quality sensor? Well, here comes the sequel where you are going to learn how to build a really simple but powerful indoor air quality sensor and how to process and present the sensor readings in Home Assistant.

We are mostly staying at home and working from home in this part of the world these days, and as such I wanted to get a better understanding of the air quality in our house. At the same time hazard reduction has started in the nearby national parks ahead of the bushfire season and you can smell the smoke in the air on some days.

IKEA has recently launched a new air quality sensor (VINDRIKTNING) which is very affordable and has already been hacked and integrated into Home Assistant. It uses a PM1006 sensor and measures particulate matter only. More on this in a future post.

For the following sensor I chose air quality components supported in ESPHome.

Bill of Material

  • ESP32
  • SDS011 sensor – measures particulate matter less than 2.5 and 10 µm in size
  • SGP30 breakout board – measures CO2 and TVOC (total Volatile Organic Compounds)
  • BME280 breakout board – measures temperature, humidity and air pressure
  • Case – Awesome 3D-printed case available on Thingiverse (Environment monitoring station by RolandSautner is licensed under the Creative Commons – Attribution license.)
  • Sundries such as screws, fly-screen, super glue


  • Fine Dust (PM 10 and PM 2.5): 0.0 to 999.9 μg /m3, ±15% and ±10μg/m3
  • CO2: 400 ppm to 60000 ppm
  • Chemicals (TVOC): 0 ppb to 60000 ppb
  • Temperature: -40 to 85°C, ±0.5°C to ±1.5°C
  • Humidity: 0 to 100% relative humidty, ±3%
  • Pressure: 300 to 1100 hPa, ±1.0hPa to ±1.7hPa


The wiring of this sensor is really simple. The SDS011 sensor just requires a RX and TX pin as well as 5V and GND. The SGP30 and BME280 sensors are connected to the I2C bus and require 3.3V and GND to work.

SDS011 SensorESP
SGP30 SensorESP
BME280 SensorESP
Air Quality Sensor Wiring


I printed the case parts without making any modifications despite the fact that I am using a BME280 sensor instead of the BME680 sensor that the case was designed for.

Air Quality Sensor Assembly 01

As usual I built a simple prototype PCB board just to get the wiring between ESP and sensors reasonably tidy.

Air Quality Sensor Assembly 02

The SDS011 module is screwed under the top case part using small standoffs – I still had some old standoffs and screws left from PC assembly times.

Air Quality Sensor Assembly 03

The next step is probably optional but I decided to use a piece of vinyl tube for the air intake of the SDS011 sensor.

Air Quality Sensor Assembly 04
Air Quality Sensor Assembly 05

The prototype PCB board I used was just large enough to drill two holes into it so that I could screw the board onto the top part of the case.

Air Quality Sensor Assembly 06
Air Quality Sensor Assembly 07

For the connection between ESP and SDS011 sensor I used a cable that came with the sensor and cut off the other end to then install socket headers for the ESP side.

Air Quality Sensor Assembly 08
Air Quality Sensor Assembly 09
Air Quality Sensor Assembly 10

Originally I had both the BME280 and SGP30 sensors on the same side of the case but then realised that the SGP30 is heating up to measure chemicals and the BME280 would then measure the dissipated heat. And so the final assembly guides these two sensors out of the case on two separate sides.

Air Quality Sensor Assembly 11

To fix the top case part to the bottom part I just used 18mm wood screws.

Air Quality Sensor Assembly 12
Air Quality Sensor Assembly 13

The vinyl tube had to be cut a bit to not stick out too much because there will be a piece of fly screen mounted over the top.

Air Quality Sensor Assembly 14

As mentioned ealier, the SGP30 sensor is now sticking out on the right hand side…

Air Quality Sensor Assembly 15

… and the BME280 on the left hand side so that the latter does not measure the heat dissipated by the SGP30 sensor.

Air Quality Sensor Assembly 16
Air Quality Sensor Assembly 17

The top cover is slid over the top of the case and then fixed with 18mm wood screws from the bottom.

Air Quality Sensor Assembly 18
Air Quality Sensor Assembly 19

To protect the sensors from crawling and flying insects, the case comes with two small frames. I cut some plastic fly screen to size and simply glued it to these frames.

Air Quality Sensor Assembly 20

These protectors can then be mounted with wood screws on the left and right to the bottom of the case.

Air Quality Sensor Assembly 21

Here is a view of the bottom of the case fully assembled with just the micro USB cable sticking out.

Air Quality Sensor Assembly 22

And this is how the device looks like when mounted onto a beam in our living room. The colour definitely helps to make it blend in.

Air Quality Sensor Assembly 23


In the following ESPHome configuration I left out the usual standard things and focus on the sensors that this post is all about. The recommendation for the SGP30 sensor is to read its values every second, but I introduced a 5 second moving average filter to reduce the load onto Home Assistant a little bit. It’s quite the opposite for the SDS011 sensor which I set to update every 5 minutes to prolong its lifetime.

  name: ESP Air Quality
  platform: ESP32
  board: nodemcu-32s



  rx_pin: GPIO16
  tx_pin: GPIO17
  baud_rate: 9600

  - platform: bme280
    address: 0x76
      name: "Temperature"
      name: "Humidity"
      name: "Pressure"
        - lambda: return x / 0.9765179;
  - platform: sgp30
      name: "eCO2"
      accuracy_decimals: 1
        - sliding_window_moving_average:
            window_size: 5
            send_every: 5
      name: "TVOC"
      accuracy_decimals: 1
        - sliding_window_moving_average:
            window_size: 5
            send_every: 5
    store_baseline: yes
    address: 0x58
    update_interval: 1s
  - platform: sds011
      name: "PM 2.5 Concentration"
      name: "PM 10 Concentration"
    update_interval: 5min

Home Assistant

Looking at discrete values does not really make too much sense when it comes to air quality and instead you want to aggregate data and measure the average concentration over a period of time to get a better understanding of your air quality.


You may want to take a look at this custom component, available via HACS, which calculates an air quality index based on recommendations of the IAQ UK organisation. This takes all sensor values of this device into account to calculate a numerical indicator of your air quality.

    name: "Living Room"
      temperature: sensor.esp_air_quality_temperature
      humidity: sensor.esp_air_quality_humidity
      co2: sensor.esp_air_quality_eco2
      tvoc: sensor.esp_air_quality_tvoc
        - sensor.esp_air_quality_pm_2_5_concentration
        - sensor.esp_air_quality_pm_10_concentration

This configuration produces two sensors calculating a numerical and a human-readable value.

Indoor Air Quality UK Sensors

Refining sensor values

Because I live in Australia I wanted to see if there is something comparable here. The NSW government has published some information about indoor air pollutant but remains fairly vague with just very high level recommendations. More promising is the Handbook Indoor Air Quality from the Australian Building Codes Board which recommends the following:

  • CO2 maximum contaminant limit: 850 ppm averaged over 8 hours
  • TVOC maximum contaminant limit: 500 μg/m³ averaged over one hour
  • PM 2.5 maximum contaminant limit: 25 μg/m³ average over 24 hours
  • PM 10 maximum contaminant limit: 50 μg/m³ average over 24 hours

The following statistics sensors are averaging the discrete values from the ESPHome device based on the above recommendations.

  - platform: statistics
    entity_id: sensor.esp_air_quality_pm_2_5_concentration
    name: Indoor PM 2.5 Concentration Stats
    # 24 hours
      hours: 24
    # Should not get more than 1 value per minute.
    sampling_size: 1440
  - platform: statistics
    entity_id: sensor.esp_air_quality_pm_10_concentration
    name: Indoor PM 10 Concentration Stats
    # 24 hours
      hours: 24
    # Should not get more than 1 value per minute.
    sampling_size: 1440
  - platform: statistics
    entity_id: sensor.esp_air_quality_eco2
    name: Indoor eCO2 Concentration Stats
    # 8 hours
      hours: 8
    # Should not get more than 1 value per second.
    sampling_size: 28800
  - platform: statistics
    entity_id: sensor.esp_air_quality_tvoc
    name: Indoor TVOC Concentration Stats
    # 1 hour
      hours: 1
    # Should not get more than 1 value per second.
    sampling_size: 3600

The SGP30 sensor measures TVOC in ppb, so we will have to convert this when comparing the average value against the above recommended maximum. For now I am not taking varying temperature and air pressure into account, and stick to the following formula:

Concentration (μg/m³) = 0.0409 x concentration (ppb) x molecular weight, using an average weight of 100 g/mole.

The first of the following template sensors is converting the TVOC value into µg/m³. The two particulate matter sensors are following an air quality index defined by the NSW Department of Planning, Industry and Environment (except for the averaging period). Finally, the four binary sensors are indicating whether the abovementioned maximum contaminent limits have been exceeded.

  - sensor:
      - name: Indoor TVOC Concentration
        state: "{{ (states('sensor.indoor_tvoc_concentration_stats') | float(default=0.0) * 4.09) | round(2, default=0.0) }}"
        unit_of_measurement: 'µg/m³'
        device_class: volatile_organic_compounds
        availability: "{{ not is_state('sensor.indoor_tvoc_concentration_stats', 'unavailable') }}"
        icon: mdi:molecule
      # Air Quality Index = (PM10 mean over 1 day / 50) * 100
      - name: Indoor Air Quality PM 10
        state: >-
          {%if ((states('sensor.indoor_pm_10_concentration_stats') | float(default=0.0)) * 2.0) <= 33 %}Very Good
          {% elif ((states('sensor.indoor_pm_10_concentration_stats') | float(default=0.0)) * 2.0) <= 66 | float(default=0.0) > 33 %}Good
          {% elif ((states('sensor.indoor_pm_10_concentration_stats') | float(default=0.0)) * 2.0) <= 99 | float(default=0.0) > 66 %}Fair
          {% elif ((states('sensor.indoor_pm_10_concentration_stats') | float(default=0.0)) * 2.0) <= 149 | float(default=0.0) > 99 %}Poor
          {% elif ((states('sensor.indoor_pm_10_concentration_stats') | float(default=0.0)) * 2.0) <= 200 | float(default=0.0) > 149 %}Very Poor
          {% elif ((states('sensor.indoor_pm_10_concentration_stats') | float(default=0.0)) * 2.0) > 200 %}Hazardous
          {%- endif %}
        icon: mdi:cloud-outline
        availability: "{{ not is_state('sensor.indoor_pm_10_concentration_stats', 'unavailable') }}"
      # Air Quality Index = (PM2.5 mean over 1 day / 25) * 100
      - name: Indoor Air Quality PM 2.5
        state: >-
          {%if ((states('sensor.indoor_pm_2_5_concentration_stats') | float(default=0.0)) * 4.0) <= 33 %}Very Good
          {% elif ((states('sensor.indoor_pm_2_5_concentration_stats') | float(default=0.0)) * 4.0) <= 66 | float(default=0.0) > 33 %}Good
          {% elif ((states('sensor.indoor_pm_2_5_concentration_stats') | float(default=0.0)) * 4.0) <= 99 | float(default=0.0) > 66 %}Fair
          {% elif ((states('sensor.indoor_pm_2_5_concentration_stats') | float(default=0.0)) * 4.0) <= 149 | float(default=0.0) > 99 %}Poor
          {% elif ((states('sensor.indoor_pm_2_5_concentration_stats') | float(default=0.0)) * 4.0) <= 200 | float(default=0.0) > 149 %}Very Poor
          {% elif ((states('sensor.indoor_pm_2_5_concentration_stats') | float(default=0.0)) * 4.0) > 200 %}Hazardous
          {%- endif %}
        icon: mdi:cloud-outline
        availability: "{{ not is_state('sensor.indoor_pm_2_5_concentration_stats', 'unavailable') }}"
  - binary_sensor:
      - name: Indoor TVOC Maximum Contaminant
        state: "{{ (states('sensor.indoor_tvoc_concentration') | float(default=0.0)) >= 500.0 }}"
        device_class: gas
        availability: "{{ not is_state('sensor.indoor_tvoc_concentration', 'unavailable') }}"
      - name: Indoor eCO2 Maximum Contaminant
        state: "{{ (states('sensor.indoor_eco2_concentration_stats') | float(default=0.0)) >= 850.0 }}"
        device_class: gas
        availability: "{{ not is_state('sensor.indoor_eco2_concentration_stats', 'unavailable') }}"
      - name: Indoor PM 2.5 Maximum Contaminant
        state: "{{ (states('sensor.indoor_pm_2_5_concentration_stats') | float(default=0.0)) >= 25.0 }}"
        device_class: smoke
        availability: "{{ not is_state('sensor.indoor_pm_2_5_concentration_stats', 'unavailable') }}"
      - name: Indoor PM 10 Maximum Contaminant
        state: "{{ (states('sensor.indoor_pm_10_concentration_stats') | float(default=0.0)) >= 50.0 }}"
        device_class: smoke
        availability: "{{ not is_state('sensor.indoor_pm_10_concentration_stats', 'unavailable') }}"

The above configuration results in the following entities being created in Home Assistant.


Sensors eCO2


Sensors TVOC

PM 2.5

Sensors PM 2.5

PM 10

Sensors PM 10

I think this is probably good enough for now, and we will see how useful this information is over time.

A couple of weeks ago we had a tradie here who was cutting tiles and generating a lot of dust. As expected the TVOC and particulate matter sensor readings went through the roof, so this new device is definitely working.


The next step is now to have a think about how to visualise the air quality for the family. I will most likely start with displaying the IAQ UK integration’s information on the tablets in the house, and then look at notifications later.


At the time of writing this post, I used:

  • Home Assistant 2021.10.6 with Python 3.9.7 in Docker
  • ESPHome 2021.10.3
  • Creality CR-6 SE with eSUN PLA+ filament

Air Quality Series

  1. Outdoor Air Quality Sensor sharing your data with
  2. Custom built Indoor Air Quality Sensor
  3. IKEA Vindriktning modification


Leave a Reply

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