Logo TheStaticTurtle


Monitoring CO2 levels, the DIY way

Building a DIY CO2 and PM2.5 sensor to measure the air quality in my office and improve my home-maker life.



As I spend most of my day in my office 🤓, I thought it would be a good idea to measure the CO2 levels of the room. CO2 isn't as dangerous as CO, but it can still pose some issues if the levels are too high. And after being in a room that hasn't any ventilation for +10h straight, the CO2 level is definitely high.

This study, found that the level of CO2 has a very negative effect on performance, particularly on thinking in general:

CO2 Levels on human activities from a study of the Lawrence Berkeley National Laboratory
Which is sadly something that I noticed before after a long day 😕.

I also wanted to measure the PM2.5 concentration, as it could prove useful for automations down the road.

My office does have a window and, in summer, it's pretty easy, as I leave it open all the time. In winter, however, the room gets cold quickly. Which is why I wanted a sensor that can tell me if I need to open the window.

Options

So, what's available out there? Well, first of, I want something that won't send everything to some cloud that I don't control. That reduce the options quite a bit.

IMO, the Aranet4 from Aranet is the best sensor if I wanted something pre-made. Looks very well-made and is compatible with HomeAssistant. However, it's pretty pricey.

As I also wanted to measure the quantity of particulates floating in the air and I couldn't find a sensor that measured both that was at low enought price.

It meant DIY 🧐!

DIY

First, what do I actually need. I already have temperature and humidity from a Mi-Thermometer, I don't care much about the light level. What I do care about is:

After reading a few blog posts and watching a few videos, I choose to use the MH-Z19, it can measure the CO2 concentration in ppm from 0 to 5000ppm and the temperature and output it to either serial or PWM. Seems perfect for my needs.

For the particulate matter sensor, I choose a PMS5003 which gives me PM2.5, PM1.0 and PM10

Hardware

3D

I first hoped into SolidWorks and my something that reassembles a case.

The PMS5003 has an intake fan in it and by default the air is ejected on the same side:

PM5003 air pattern
That doesn't really work for me, as I also want the MHZ19 to use the same air. And while technically, you could find a position which would work, I wanted to avoid spending a lot of time on this. So, I simply opened it up, figured out the air pattern and drill holes on the other side.

So, here is what I came up with:

3D Model assembly

The air comes in from the side, ejected on the other side and then passes over the MHZ19.

On the front, I added a recess for a washer that I'll use a button using the esp32 touch library, a recess for the OLED and a hole for LEDs:

Front of 3D model.

On one side I added an intake hole for the fan, an output, and a hole for the USB wire.

Assembly

Assembly was a little finicky. I started by inserting the OLED:

OLED Installed.

Then poured hot glue in the LED hole to act as a diffuser and then put the LED strip in:

LEDs installed

Unfortunately, my 3D print wasn't perfect and the PM5003 didn't fit by less than a millimeter. So, I used some hot air to ease it in 😏:

PMS5003 and MHZ19 installed

Then with a lot of patience, I manage to solder everything to the ESP32 and hot glued it in place:

ESP32 soldered in

A bonus of the 3D print being slightly out of size, is that I can use the flat side of the PMS5003 to stick it to the wall with some double side tape.

Software

Usually, I would write my own firmware for the ESP32 that would publish the sensor data to my MQTT server and I would create the entities myself in HomeAssistant.

However, this time I chose to use ESPHome because it's way easier to use and integrates perfectly with HomeAssistant.

So let's get started. First thing to do is to set up esphome:

 1esphome:
 2  name: office-air-quality
 3
 4esp32:
 5  board: esp32dev
 6  framework:
 7    type: arduino
 8
 9logger:
10
11api:
12  encryption:
13    key: "FTt4GyAtVoM8tIqBhNSW3QF+SND9NaIA97l1FkfTdGg="
14    
15ota:
16  password: "8ebf931b96986fa4d46fb35987957b3d"
17  id: my_ota
18  
19wifi:
20  ssid: !secret wifi_ssid
21  password: !secret wifi_password
22  ap:
23    ssid: "Office-Air-Quality"
24    password: !secret ap_password
25
26captive_portal:

Next is the interface setup, I need the two UARTs for the pms5003 and mhz19, the I2C for the OLED, the touch interface for the button and the fonts for the OLED

 1i2c:
 2  - id: i2c_oled
 3    sda: 18
 4    scl: 19
 5
 6uart:
 7  - id: uart_pms5003
 8    rx_pin: GPIO16
 9    tx_pin: GPIO17
10    baud_rate: 9600
11  - id: uart_mhz19
12    rx_pin: GPIO26
13    tx_pin: GPIO25
14    baud_rate: 9600
15
16esp32_touch:
17  setup_mode: false
18
19font:
20  - file: "gfonts://Lato"
21    id: lcdfont_big
22    size: 15
23  - file: "gfonts://Roboto"
24    id: lcdfont_small
25    size: 12
26  - file: "gfonts://Roboto"
27    id: lcdfont_verysmall
28    size: 10

Then I need to add the sensors, fortunately ESPHome has platforms for the pms5003 and the mhz19. As I use an ESP32, I can also use the esp32_touch platform for the user button.

 1sensor:
 2  - platform: pmsx003
 3    uart_id: uart_pms5003
 4    type: PMSX003
 5    pm_1_0:
 6      id: pmsx003_pm1_0
 7      name: "Concentration particules <1.0µm"
 8    pm_2_5:
 9      id: pmsx003_pm2_5
10      name: "Concentration particules <2.5µm"
11    pm_10_0:
12      id: pmsx003_pm10_0
13      name: "Concentration particules <10.0µm"
14  - platform: mhz19
15    uart_id: uart_mhz19
16    co2:
17      id: mhz19_co2
18      name: "Capteur CO2"
19    temperature:
20      id: mhz19_temp
21      internal: true
22    update_interval: 30s
23    automatic_baseline_calibration: false
24    
25binary_sensor:
26  - platform: esp32_touch
27    internal: true
28    name: "Button"
29    pin: GPIO27
30    threshold: 1000
31    on_press:
32      - display.page.show_next: oled

Next are the outputs, I used a small OLED with 3 pages with the only thing changing being the PM2.5 / PM1.0 / PM10, CO2 is always displayed. And the light with the internal key to hide it from HomeAssistant.

 1display:
 2  - platform: ssd1306_i2c
 3    id: oled
 4    model: "SSD1306 128x32"
 5    address: 0x3C
 6    pages:
 7      - lambda: |-
 8          it.print(0, 0 , id(lcdfont_big), "CO2  ");
 9          it.print(0, 15, id(lcdfont_big), "PM2.5");
10          it.print(54, 1 , id(lcdfont_small), String((int)(id(mhz19_co2).state)).c_str());
11          it.print(54, 17, id(lcdfont_small), String((int)(id(pmsx003_pm2_5).state)).c_str());
12          it.print(92, 3 , id(lcdfont_verysmall), "ppm");
13          it.print(92, 18, id(lcdfont_verysmall), "ug/m3");          
14      - lambda: |-
15          it.print(0, 0 , id(lcdfont_big), "CO2  ");
16          it.print(0, 15, id(lcdfont_big), "PM1.0");
17          it.print(54, 1 , id(lcdfont_small), String((int)(id(mhz19_co2).state)).c_str());
18          it.print(54, 17, id(lcdfont_small), String((int)(id(pmsx003_pm1_0).state)).c_str());
19          it.print(92, 3 , id(lcdfont_verysmall), "ppm");
20          it.print(92, 18, id(lcdfont_verysmall), "ug/m3");          
21      - lambda: |-
22          it.print(0, 0 , id(lcdfont_big), "CO2  ");
23          it.print(0, 15, id(lcdfont_big), "PM10");
24          it.print(54, 1 , id(lcdfont_small), String((int)(id(mhz19_co2).state)).c_str());
25          it.print(54, 17, id(lcdfont_small), String((int)(id(pmsx003_pm10_0).state)).c_str());
26          it.print(92, 3 , id(lcdfont_verysmall), "ppm");
27          it.print(92, 18, id(lcdfont_verysmall), "ug/m3");          
28
29light:
30  - id: status_led
31    internal: true
32    platform: neopixelbus
33    variant: WS2811
34    pin: GPIO33
35    num_leds: 9
36    type: GRB

Thankfully, esphome automatically updates the OLED content. Which is not the case of the LEDs, so I used the SNTP platform to execute a script every 1 second.

The script does the following:

 1time:
 2  - platform: sntp
 3    on_time:
 4      - seconds: /1
 5        then:
 6          - lambda: |-
 7              LightCall status_led_call = id(status_led).turn_on();
 8
 9              status_led_call.set_state(true);
10              status_led_call.set_transition_length(100);
11
12              if(id(mhz19_co2).state >= 1500) {
13                status_led_call.set_rgb(0.392, 0.015, 0.588); // Purple
14                status_led_call.set_brightness(1.00);
15              } else  if(id(mhz19_co2).state >= 1200) {
16                status_led_call.set_rgb(0.921, 0.000, 0.000); // Red
17                status_led_call.set_brightness(0.75);
18              } else  if(id(mhz19_co2).state >= 900) {
19                status_led_call.set_rgb(0.620, 0.392, 0.000); // Orange
20                status_led_call.set_brightness(0.27);
21              } else if(id(mhz19_co2).state >= 650) {
22                status_led_call.set_rgb(0.031, 0.690, 0.007); // Green
23                status_led_call.set_brightness(0.18);
24              } else {
25                status_led_call.set_rgb(0.007, 0.600, 0.749); // Blue
26                status_led_call.set_brightness(0.15);
27              }
28
29              status_led_call.perform();              

Results

Flashed the compiled esphome firmware and was greeted by some wonderful logs:

[20:43:27][D][pmsx003:236]: Got PM1.0 Concentration: 3 µg/m^3, PM2.5 Concentration 4 µg/m^3, PM10.0 Concentration: 4 µg/m^3 
[20:43:28][D][mhz19:057]: MHZ19 Received CO₂=693ppm Temperature=24°C Status=0x00

The LEDs and OLED lighted up with the correct info:

Sensor put on the wall

I added the entities in HomeAssistant, left it for a while and got these wonderful graphs:

HomeAssistant CO2 sensor
HomeAssistant PM sensor
Guess when I started soldering for another project 😅.

Conclusion

Overall, I'm pleased with this project, I didn't take very long to make, it's not too big, it's not really distracting unless the level is too high. It's going to be a really useful tool for long programming or soldering sessions.

CommentsShortcut to: Comments

Want to chat about this article? Just post a message down here. Chat is powered by giscus and all discussions can be found here: TheStaticTurtle/blog-comments