Particulates kill, build your sensor now!
(Updated: )
One metric still missing from my home sensors (vanwerkhoven.org) is particulates (wikipedia.org), which are a known health hazard (ycombinator.com). Although I (secretly) already had a board running for a while, I wasn’t satisfied enough to share it. Today, it’s ready for sharing, including screaming headline for a change ;).
This board builds on my previous (smaller) design (vanwerkhoven.org) without particulate sensor, and also sports a custom-designed PCB (which you can order here!).
Starting with the end, it looks like:
Introduction ¶
Building on my previous sensor projects, I’m now adding a Nova Fitness SDS011 (inovafitness.com) PM2.5/PM10 fine dust sensor, widely used & recommended by e.g. RIVM (samenmetenaanluchtkwaliteit.nl), Luftdaten (sensor.community), and EU VAQUUMS (vaquums.eu).
For basic/context/intro, please read my previous guides here (vanwerkhoven.org) and here (vanwerkhoven.org).
Bill of materials ¶
- Special-built BigSensorThing PCB (tweakers.net) -- 9 euros with same-day shipping!
- ESP8266 WeMos D1 mini (tinytronics.nl) (do not get the pro! it’s too big)
- Nova Fitness SDS011 fine dust sensor (tinytronics.nl)
- Winsen MH-Z19B CO2 sensor (tinytronics.nl)
- Optional: 1.3" 128x64 OLED (tinytronics.nl) or 1.5" 128x128 OLED (tinytronics.nl) (I use a 1.5" here)
- BME280 module with level converter (tinytronics.nl) - N.B. ensure you have a BME280 module that accepts 5V to use my print!
- DS18B20 temperature sensor (tinytronics.nl)
- Male pin headers straight (for modules) (tinytronics.nl)
- Male pin headers 90 degrees (for SDS011 & optionally DS18B20) (tinytronics.nl)
- Optional: Dupont female-female jumper wires (to increase distance of DS18B20) (tinytronics.nl)
- M3 screws >20mm thread (gamma.nl) or 2x 10mm M3 afstandsbusjes (tinytronics.nl) -- you need at least 15mm clearance between the PCB and the SDS011
- Optional: get a hose (tinytronics.nl) to sample air from a distance
Some caveats/considerations:
- Get a flexible/small USB micro cable. The Wemos/Lolin D1 Mini Pro is a bit too long to comfortably fit a USB cable at the bottom without extruding. I actually built mine with the Pro before I figured this out, and it sort of stands because I had a very flexible USB cable. Even with the regular (smaller) Wemos D1 mini, a short/flexible USB micro plug helps.
- Since I power everything on 5V and the BME280 only accepts 1.7-3.6V as input voltage (bosch-sensortec.com), ensure you get a BME280 module with voltage regulator, e.g. the one linked above. It should explicitly note that input voltage can be either 3.3V or 5V.
Bill of process ¶
Software integration ¶
Install esphome as documented here (vanwerkhoven.org).
Hardware integration ¶
Solder male header pins onto PCB for (1) D1 mini, (2) MH-Z19B, (3) OLED, (4) BME280, (5) DS18B20 and (6) SDS011 connector (use 90 deg here!)
N.B. For the MH-Z19B you can save a few headers by only connecting the necessary VCC, GND, RX and TX pins. (You could also only solder used pins for the D1 mini but I chose to connect all so I can optionally connect additional stuff via jumper wires).
N.B. 2 you can connect the DS18B20 on the front or on the back
Solder on modules.
Sandwitch PCB & SDS011, and wrap serial cable around / through the sandwich. Optionally (this design), connect the DS18B20 using double female Dupont wires to reduce/prevent self-heating.
This design directly powers the ESP8266 board, so no need for (micro) USB port as in previous designs.
Software configuration ¶
Again similar to previous projects, with modification to add SDS011. Some notes:
- Ensure you reduce OLED refresh rate to e.g. 30s
- I only got stable operation by disabling UART logger.
- Optionally use deep sleep loop, losing EMA
- I filter some sensors as exponential moving average (EMA) (wikipedia.org) mode as well to easily get a average of the last ~12hrs. Somehow I can’t get all sensors to run like this, perhaps I maxed out the ESP8266.
esphome:
name: esp_bigsensorthing
platform: ESP8266
board: d1_mini_pro # Change to d1_mini if you don't have a pro
wifi:
ssid: "your wifi SSID"
password: "your wifi password"
#fast_connect: True # Required to connect to hidden SSIDs and only with one network
domain: ".lan"
# reboot_timeout: 0s # Do not reboot if no wifi (can be useful for offline use)
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Esp Living Fallback Hotspot"
password: "your fallback password"
logger:
# Disable because somehow this conflicts with double serial ports of SDS011 and MH-Z19B
# Disable UART Logging to fix MHZ-19B preamble issue when using hardware UART, see https://github.com/esphome/issues/issues/488
# N.B. disabling logger crashes my Mac because of buggy CH34x driver, so I disable it as last setting.
baud_rate: 0
# level: VERBOSE
# Enable Home Assistant API for logging over wifi.
# Disable reboot loop by setting reboot_timeout to 0s!
# See warning on https://esphome.io/components/mqtt.html
api:
reboot_timeout: 0s
# Disable webserver, since we push data over mqtt. Can be useful for
# diagnostics, but takes up quite some memory
# web_server:
# port: 80
# Optionally use deep sleep loop to reduce power consumption/self-heating.
# deep_sleep:
# run_duration: 30s
# sleep_duration: 150s
# Allow OTA updates
ota:
mqtt:
# For mobile = WAN : use FQDN, for local (IoT network - no WAN), use home IP.
broker: "192.168.0.1"
#broker: home.yourhostname.org
port: 1883
username: "esp_board_client"
password: "AQFCg72z5MqFihspHGbkqOj9"
uart:
- id: myuart0 # For SDS011 pm2.5/pm10
rx_pin: GPIO14 # = D5
tx_pin: GPIO12 # = D6
baud_rate: 9600
- id: myuart1 # For MH-Z19B CO2
rx_pin: GPIO13 # = D7
tx_pin: GPIO15 # = D8
baud_rate: 9600
# For BME280 & OLED
i2c:
sda: GPIO4 # = D2 = 4
scl: GPIO5 # = D1 = 5
scan: False
frequency: 100kHz # Trying to get more stable OLED value display
# For Dallas temp sensors connected to pin GPIO0 = D3, enable internal pull-up
# resistor
dallas:
id: ds18b20_temp_sensor
pin:
number: GPIO0 # D3 = 0
# inverted: True
mode: INPUT_PULLUP
update_interval: 60s # if changed also update EMA alpha
sensor:
- platform: wifi_signal
name: "WiFi Signal"
update_interval: 60s
id: mywifi
- platform: uptime
name: Uptime
update_interval: 60s
id: myuptime
- platform: sds011
pm_2_5:
state_topic: influx/environv3/quantity/pm25/source/sds011/board/esp_bigsensorthing/location/home/room/living/value/state
name: "SDS011 PM2.5"
id: sds011_pm25
pm_10_0:
state_topic: influx/environv3/quantity/pm10/source/sds011/board/esp_bigsensorthing/location/home/room/living/value/state
name: "SDS011 PM10"
id: sds011_pm10
update_interval: 10min # factory default 0min, set to 10min to save limited 8000h laser lifetime. Also reduces fan noise
id: mysds011
uart_id: myuart0
- platform: dallas
index: 0
name: "DS18B20 Temperature"
state_topic: influx/environv3/quantity/T/source/dallas1/board/esp_bigsensorthing/location/home/room/living/value/state
id: ds18b20_temp
- platform: mhz19
co2:
name: "MH-Z19 CO2"
state_topic: influx/environv3/quantity/CO2/source/mhz19b/board/esp_bigsensorthing/location/home/room/living/value/state
id: mhz_19_co2
temperature:
name: "MH-Z19 Temperature"
state_topic: influx/environv3/quantity/T/source/mhz19b/board/esp_bigsensorthing/location/home/room/living/value/state
id: mhz_19_temp
update_interval: 60s
uart_id: myuart1
- platform: mhz19
co2:
name: "MH-Z19 CO2"
id: mhz_19_co2_ema
filters:
- exponential_moving_average:
alpha: 0.0028 # We want ~12 hour averaging, at 60s update, that's 12*3600/60 = 720points, thus alpha should be 2/1440 ~ 0.0028
send_every: 1
update_interval: 60s # if changed also update alpha
uart_id: myuart1
- platform: bme280
temperature:
name: "BME280 Temperature"
oversampling: 1x
id: bme_280_temp
state_topic: influx/environv3/quantity/T/source/bme280/board/esp_bigsensorthing/location/home/room/living/value/state
pressure:
name: "BME280 Pressure"
id: bme_280_press
state_topic: influx/environv3/quantity/P/source/bme280/board/esp_bigsensorthing/location/home/room/living/value/state
humidity:
name: "BME280 Humidity"
id: bme_280_rh
state_topic: influx/environv3/quantity/RH/source/bme280/board/esp_bigsensorthing/location/home/room/living/value/state
address: 0x76
update_interval: 60s
- platform: bme280
humidity:
name: "BME280 Humidity"
id: bme_280_rh_ema
filters:
- exponential_moving_average:
alpha: 0.0028 # We want ~12 hour averaging, at 60s update, that's 12*3600/60 = 720points, thus alpha should be 2/1440 ~ 0.0028
send_every: 1
address: 0x76
update_interval: 60s # if you change this don't forget to update alpha
font:
- file: "slkscr.ttf"
id: my_font1
size: 8
- file: "Arial.ttf"
id: my_font3
size: 16
# # I want to use ø, drop unused chars in return
glyphs:
['ø', '~', '.', '%', '(', ')', '+', '-', '_', ':', '°', '0',
'1', '2', '3', '4', '5', '6', '7', '8', '9', ' ', '/']
display:
- platform: ssd1327_i2c
model: "SSD1327 128x128"
update_interval: 30s # Don't forget to set this to prevent esphome crash due to default 5s
# reset_pin: D0
address: 0x3C
rotation: 180°
# Small font, without EMA
# lambda: |-
# it.printf(0, 0, id(my_font1), "CO2 (PPM): %d", int(id(mhz_19_co2).state));
# it.printf(0, 10, id(my_font1), "T1 (C): %.1f", id(ds18b20_temp).state);
# it.printf(0, 20, id(my_font1), "T2 (C): %.1f", id(bme_280_temp).state);
# it.printf(0, 30, id(my_font1), "RH (%%): %.1f", id(bme_280_rh).state);
# it.printf(0, 40, id(my_font1), "P (HPa): %d", int(id(bme_280_press).state));
# it.printf(0, 50, id(my_font1), "PM (2.5): %5d", int(id(sds011_pm25).state));
# it.printf(0, 60, id(my_font1), "PM (10): %5d", int(id(sds011_pm10).state));
# Bigger font, mixed EMA
lambda: |-
it.printf(0, 0, id(my_font1), "CO2");
it.printf(0, 8, id(my_font1), "PPM");
it.printf(24, 0, id(my_font3), "%d (~%d)", int(id(mhz_19_co2).state), int(id(mhz_19_co2_ema).state));
it.printf(0, 16, id(my_font1), "T1");
it.printf(0, 24, id(my_font1), "C");
it.printf(24, 16, id(my_font3), "%.1f", id(ds18b20_temp).state);
it.printf(0, 32, id(my_font1), "T2");
it.printf(0, 40, id(my_font1), "C");
it.printf(24, 32, id(my_font3), "%.1f", id(bme_280_temp).state);
it.printf(0, 48, id(my_font1), "RH");
it.printf(0, 56, id(my_font1), "%%");
it.printf(24, 48, id(my_font3), "%.1f (~%.1f)", (id(bme_280_rh).state), (id(bme_280_rh_ema).state));
it.printf(0, 64, id(my_font1), "P");
it.printf(0, 72, id(my_font1), "HPa");
it.printf(24, 64, id(my_font3), "%d", int(id(bme_280_press).state));
it.printf(0, 80, id(my_font1), "PM");
it.printf(0, 88, id(my_font1), "2.5");
it.printf(24, 80, id(my_font3), "%.1f", id(sds011_pm25).state);
it.printf(0, 96, id(my_font1), "PM");
it.printf(0, 104, id(my_font1), "10");
it.printf(24, 96, id(my_font3), "%.1f", id(sds011_pm10).state);