Skip to content

Air quality monitor

In January 2025 I built an air quality monitor to use for the Energy Devroom at FOSDEM. It was inspired by Chris Adams who raised the topic of air quality in FOSDEM Devrooms. Air quality is a good indicator for the level of ventilation and thus risk of transmitting airborn diseases.

Ready-made air quality monitors exist, but it is more fun to do it yourself.

The end result:

Research

Solutions were already being shared in the shared document for air quality monitoring at FOSDEM:

CO₂ sensors

There are different sensors available to measure CO₂ levels. This page by airgradient.com gives a good overview of different sensor types.

Sensirion SCD40 and SCD41

The Senserion SCD40 and SCD41 sensors are popular choices that are widely available. The SMD component is very small. SCD41 is the variant of SC40 intended for industrial applications, that can read higher CO₂ levels and is even more precise. The SC40 should suffice for this application of having a good estimation of CO₂ level and thus level of ventilation.

I got the GY-SCD40 module that has a Molex Picoblade connector.

Automatic calibration

Be aware that this sensor automatically self-calibrates by assuming the lowest CO₂ reading last week was an outdoor situation of 400ppm. Leaving the sensor too long in a room that is not ventilated enough to go down to outdoor levels will result in an offset in the values. This automatic calibration can be disabled and factory calibration can be restored.

Sensirion SCD30

The Senserion SCD30 is the predecessor of the SCD40 is also well tested. The sensor module is larger than the SCD40.

Sensirion SGP30

An even smaller sensor than SCD40 but doesn't measure CO₂ directly. Because of this, it can be thrown off by other gasses, including from deodorizers. But it is the cheaper option.

Wemos provides a SGP30 shield for the D1 Mini which makes it a cheap, compact and easy measurement solution.

Boards

The availability of sensor profiles and other convenient features in ESPHome made it a requirement to be able to run ESPHome. ESPHome supports five different microcontroller platforms, covering many development boards.

Wemos LOLIN D1 mini

The Wemos LOLIN D1 mini was mentioned a couple of times because of its low cost. I2C is available on pin 4 and 5 (for use with shields like the SGP30 shield for the D1 Mini) or on the JST-SH I2C connector. Unfortunately Wemos chose a different pin order than the commonly used pin order of Qwiic, also known as STEMMA QT.

Change of plans

I did start this project on a Wemos LOLIN D1 mini with a 8x8 RGB LED shield to use LED color as indicators. It became clear that it was uncertain if exposing the ESPHome web interface at FOSDEM would work reliably. Thus I needed a solution where the values could be read even if no internet or laptop was available. That brought me to the next board, the LilyGo T3 S3.

LilyGo T3 S3

Being interested in Meshtastic, this board seemed a good entry into the ecosystem. The LilyGo T3 S3 comes with a small OLED screen connected to I2C. The ESP32-S3FH4R2 microcontroller can drive a second I2C bus, for which any GPIO pins can be chosen. I chose the pins 43 and 44, connected to the middle JST-SH connector. This pinout is compatible with Qwiic, also known as STEMMA QT.

The screen and external connector make it a good solution for this project.

Work is being done to add support for SCD40/SCD41 sensor to Meshtastic. I hope to give this a try in the future using this LoRa device.

The configuration board was only recently merged in PlatformIO. It cannot be used until PlatformIO has published a new release and it is included in ESPHome. Until then, explicit parameters in ESPHome need to be used to use this board.

Design

Warnings

I decided to focus on the OLED screen for values and also for warnings.

Three levels of indicators are used:

  • Good: value is fine, no additional indicators.
  • Warning: value is out of normal range and intervention is needed. Icon is used to signal this.
  • Critical: value is far out of normal range and intervention is needed immediately. A blinking icon is used to signal this.

If a blinking critical indicator is on, the signal is strengthened by blinking a border at the edge of the screen.

I didn't see a reason to distinguish between too low or too high values.

Fonts and icons

ESPHome has a font renderer component with many features. It is easy to use Google Fonts directly, reducing the need for manual configuration. I chose to use Roboto Condensed as the base font, supplemented with Material icons.

Cable sleeve

The incompatibility of connectors between the board and the module resulted in two adapter cables via header connectors. I could have put heat shrink or even around the wire mess to clean it up, but that would be difficult to remove when adapting to a new project. I got the idea to create a miniature version of a spiral cable wrap, often used behind a computer or TV to bundle cables, by cutting a plastic straw at an angle. This worked rather well as the combination is still flexible and easy to put on and remove. Too bad I only found out about this great use of plastic straws after the environmental ban in Europe.

Code

Sensor is programmed in ESPHome meta-programming framework.

Download: air-quality-monitor-v1_0.yaml.

---
# SPDX-FileCopyrightText: 2025 Nico Rikken <nico@nicorikken.eu>
# SPDX-License-Identifier: AGPL-3.0-or-later

# Air quality sensor on LilyGO T3 S3 V1.2
# Choice of this device because I already had it. LoRa is not used.
# PlatformIO with support board not yet released, so custom config used.
# JST-SH connector GPIO 43/44 used for I2C communication to SCD40 CO2 sensor.

# Developed on ESPHome version 2024.12.4 https://pypi.org/project/esphome/2024.12.4/
# Included software listed here: https://github.com/esphome/esphome/blob/2024.12.4/requirements.txt
# Comes with PlatformIO version 6.1.16

#------------------------------------------------------------------------------------------------
#HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
# - toolchain-riscv32-esp @ 8.4.0+2021r2-patch5
# - toolchain-xtensa-esp32s3 @ 8.4.0+2021r2-patch5
#Dependency Graph
#|-- AsyncTCP-esphome @ 2.1.4
#|-- WiFi @ 2.0.0
#|-- FS @ 2.0.0
#|-- Update @ 2.0.0
#|-- ESPAsyncWebServer-esphome @ 3.2.2
#|-- ESPmDNS @ 2.0.0
#|-- Wire @ 2.0.0
#|-- ArduinoJson @ 6.18.5

esphome:
  name: air-quality-sensor
  name_add_mac_suffix: true
  friendly_name: "Air quality monitor"
  project:
    name: nico_rikken.air_quality_sensor
    version: "1.0"
  comment: "Air quality monitor 1.0 · Copyright 2025 Nico Rikken · License AGPL-3.0-or-later · https://nicorikken.eu"
  platformio_options:
    # TODO: remove specific configurations when PlatformIO Espressif 32 V6.10.0 is released and used in ESPHome
    build_flags: "-DBOARD_HAS_PSRAM"
    board_build.arduino.memory_type: qio_qspi
    board_build.flash_mode: dio
  on_boot:
    then:
      - delay: 5s
      - display.page.show: page_main

esp32:
  # TODO: Await upstream PlatformIO release for board support
  # https://github.com/platformio/platform-espressif32/pull/1523
  board: esp32-s3-devkitc-1
  variant: esp32s3
  flash_size: 4MB
  framework:
    type: arduino

psram:
  mode: octal
  speed: 80MHz

logger:
  level: INFO

# To configure wifi an ap, place a file secrets.yaml in the project directory.
# It should have have following contents (without indentation):
#
#   wifi_networks_0_ssid: "ChangeMe"
#   wifi_networks_0_password: "ChangeMe"
#   wifi_networks_1_ssid: "ChangeMe"
#   wifi_networks_1_password: "ChangeMe"
#   wifi_ap_ssid: "ChangeMe"
#   wifi_ap_password: "ChangeMe"
wifi:
  networks:
    - ssid: !secret wifi_networks_0_ssid
      password: !secret wifi_networks_0_password
    - ssid: !secret wifi_networks_1_ssid
      password: !secret wifi_networks_1_password
  ap:
    ssid: !secret wifi_ap_ssid
    password: !secret wifi_ap_password

web_server:
  port: 80
  version: 3

i2c:
  - id: bus_oled
    sda: GPIO18
    scl: GPIO17
  - id: bus_43
    sda: GPIO43
    scl: GPIO44

substitutions:
  # Material symbols
  icon_co2: "\U0000e7b0" # co2
  icon_humidity: "\U0000f87e" # humidity_percentage
  icon_temperature: "\U0000e846" # thermometer
  icon_warning: "\U0000e002" # warning
  icon_critical: "\U0000e99a" # dangerous

  # Thresholds
  co2_warning: "800"
  co2_critical: "1200"
  temperature_low_critical: "15"
  temperature_low_warning: "18"
  temperature_high_warning: "26"
  temperature_high_critical: "28"
  humidity_low_critical: "30"
  humidity_low_warning: "40"
  humidity_high_warning: "60"
  humidity_high_critical: "70"

  # Display coordinates
  disp_row_1: "0"
  disp_row_2: "20"
  disp_row_3: "40"
  disp_col_1: "2"
  disp_col_2: "22"
  disp_col_3: "42"

  # Enums
  e_good: "0"
  e_warn: "1"
  e_crit: "2"

globals:
  # State enums: e_good (0): ok, e_warn (1): warning, e_crit (2): critical
  - id: co2_state_enum
    type: int
    initial_value: "${e_good}"
  - id: temperature_state_enum
    type: int
    initial_value: "${e_good}"
  - id: humidity_state_enum
    type: int
    initial_value: "${e_good}"
  - id: display_blink
    type: bool
    initial_value: 'true'

interval:
  # Toggle display_blink, to animate critical warnings
  - interval: 1s
    then:
      - lambda: |-
          id(display_blink) = !id(display_blink);
      - component.update: oled

display:
  - platform: ssd1306_i2c
    id: oled
    model: "SSD1306 128x64"
    i2c_id: bus_oled
    pages:
      - id: page_boot
        lambda: |-
          it.print(0, 0,  id(roboto_12), "Air quality monitor v1.0");
          it.print(0, 12, id(roboto_12), "Copyright 2025");
          it.print(0, 24, id(roboto_12), "Nico Rikken");
          it.print(0, 36, id(roboto_12), "License AGPL-3.0-or-later");
          it.print(0, 48, id(roboto_12), "https://nicorikken.eu");

      # NOTE: Display rendering takes a long time: 265ms instead of acceptable 30ms.
      - id: page_main
        lambda: |-
          // Blinking border at critical values
          if ( id(display_blink) && (id(co2_state_enum) == ${e_crit} || id(temperature_state_enum) == ${e_crit} || id(humidity_state_enum) == ${e_crit}) ) {
            it.rectangle(0, 0, 128, 64);
          }
          // CO2 sensor information
          // Blinking critical symbol
          if ( ( id(co2_state_enum) == ${e_crit} ) && id(display_blink) ){
            it.print(${disp_col_1}, ${disp_row_1}, id(roboto_20_material), "${icon_critical}");
          }
          // Warning symbol
          if ( id(co2_state_enum) == ${e_warn} ){
            it.print(${disp_col_1}, ${disp_row_1}, id(roboto_20_material), "${icon_warning}");
          }
          // Symbol
          it.print(${disp_col_2}, ${disp_row_1}, id(roboto_20_material), "${icon_co2}");
          // Value
          it.printf(${disp_col_3}, ${disp_row_1}, id(roboto_20_material), "%.0fppm", id(co2).state);

          // Temperature sensor information
          // Blinking critical symbol
          if ( ( id(temperature_state_enum) == ${e_crit} ) && id(display_blink) ){
            it.print(${disp_col_1}, ${disp_row_2}, id(roboto_20_material), "${icon_critical}");
          }
          // Warning symbol
          if ( id(temperature_state_enum) == ${e_warn} ){
            it.print(${disp_col_1}, ${disp_row_2}, id(roboto_20_material), "${icon_warning}");
          }
          // Symbol
          it.print(${disp_col_2}, ${disp_row_2}, id(roboto_20_material), "${icon_temperature}");
          // Value
          it.printf(${disp_col_3}, ${disp_row_2}, id(roboto_20_material), "%.1f°C", id(temperature).state);

          // Humidity sensor information
          // Blinking critical symbol
          if ( ( id(humidity_state_enum) == ${e_crit} ) && id(display_blink) ){
            it.print(${disp_col_1}, ${disp_row_3}, id(roboto_20_material), "${icon_critical}");
          }
          // Warning symbol
          if ( id(humidity_state_enum) == ${e_warn} ){
            it.print(${disp_col_1}, ${disp_row_3}, id(roboto_20_material), "${icon_warning}");
          }
          // Symbol
          it.print(${disp_col_2}, ${disp_row_3}, id(roboto_20_material), "${icon_humidity}");
          // Value
          it.printf(${disp_col_3}, ${disp_row_3}, id(roboto_20_material), "%.1f%%", id(humidity).state);

font:
  - id: roboto_20_material
    file: "gfonts://Roboto+Condensed"
    size: 20
    extras:
      - file: "gfonts://Material+Symbols+Outlined"
        # Find glyphs in https://fonts.google.com/icons
        glyphs:
          - "${icon_co2}"
          - "${icon_humidity}"
          - "${icon_temperature}"
          - "${icon_critical}"
          - "${icon_warning}"
  - id: roboto_12
    file: "gfonts://Roboto+Condensed"
    size: 12

sensor:
  - platform: scd4x
    i2c_id: bus_43
    id: scd40
    co2:
      id: co2
      name: "CO2"
      on_value_range:
        - below: ${co2_warning}
          then:
            - globals.set:
                id: co2_state_enum
                value: "${e_good}"
        - above: ${co2_warning}
          below: ${co2_critical}
          then:
            - globals.set:
                id: co2_state_enum
                value: "${e_warn}"
        - above: ${co2_critical}
          then:
            - globals.set:
                id: co2_state_enum
                value: "${e_crit}"
    humidity:
      id: humidity
      name: "Humidity"
      on_value_range:
        - below: ${humidity_low_critical}
          then:
            - globals.set:
                id: humidity_state_enum
                value: "${e_crit}"
        - above: ${humidity_low_critical}
          below: ${humidity_low_warning}
          then:
            - globals.set:
                id: humidity_state_enum
                value: "${e_warn}"
        - above: ${humidity_low_warning}
          below: ${humidity_high_warning}
          then:
            - globals.set:
                id: humidity_state_enum
                value: "${e_good}"
        - above: ${humidity_high_warning}
          below: ${humidity_high_critical}
          then:
            - globals.set:
                id: humidity_state_enum
                value: "${e_warn}"
        - above: ${humidity_high_critical}
          then:
            - globals.set:
                id: humidity_state_enum
                value: "${e_crit}"
    temperature:
      id: temperature
      name: "Temperature"
      on_value_range:
        - below: ${temperature_low_critical}
          then:
            - globals.set:
                id: temperature_state_enum
                value: "${e_crit}"
        - above: ${temperature_low_critical}
          below: ${temperature_low_warning}
          then:
            - globals.set:
                id: temperature_state_enum
                value: "${e_warn}"
        - above: ${temperature_low_warning}
          below: ${temperature_high_warning}
          then:
            - globals.set:
                id: temperature_state_enum
                value: "${e_good}"
        - above: ${temperature_high_warning}
          below: ${temperature_high_critical}
          then:
            - globals.set:
                id: temperature_state_enum
                value: "${e_warn}"
        - above: ${temperature_high_critical}
          then:
            - globals.set:
                id: temperature_state_enum
                value: "${e_crit}"