ESPHome air quality monitor based on Tom's Sensorbox v2, with extra sensors, a formaldehyde sensor with a buzzer alarm, SD card logging and optional screen printouts on a thermal printer. It works on its own and connects to Home Assistant through the ESPHome API.
- 3D Printer Emission Sensor Array (Sensorbox v2) by Tom (MadeWithLayers), CC BY: enclosure, main board SBR1 r1.1 (ESP32-S2, 2.8" ILI9341 display) and the ESPHome config r2 (go.toms3d.org/sbr1) this config started from.
- ZE08 CH2O Sensor Mount Base + Buzzer for Sensorbox v2 by Simply Maker, CC BY-NC-SA: mount for the ZE08-CH2O sensor and the buzzer.
- Addon board v0.2, own design (
hardware/enhanced-addon-v0.2/).
The print files are on the Printables pages above.
- ZE08-CH2O formaldehyde sensor and a buzzer, on the mount above.
- Addon board v0.2: SR602 PIR, LTR390 (UV/ambient light) and BME680 (temperature, humidity, pressure, gas). LTR390 and BME680 share one I²C bus; the board sits on a 4-pin header (3V3, GND, SCL, SDA) plus one wire for the PIR (GPIO39). Pinout: hardware/enhanced-addon-v0.2/README.md.
The config is esphome/airquality.yaml.
- Eight display pages: values, an info page with alarm limits, page list and controls, 1 h and 24 h graphs (air quality, climate, system) and a QR code for the web UI. Short press = next page, hold 5 s = print, 30 s without a press = back to the values page. The top row of the values page rotates every 10 s through temperature/humidity, dewpoint/absolute humidity, QNH with the 3 h pressure tendency, light/UV index and the AQI values.
- Values page: particles are always drawn (
--when the PMS delivers nothing), the gas block shows formaldehyde, ENS160 TVOC and whichever VOC sensor is fitted (SGP30 or SGP41 — the board has room for one), and every other block appears once its sensor has a reading. - Graphs auto-scale where the absolute level matters less (TVOC, particles, light) and keep fixed axes for CO2, temperature, humidity, pressure, dewpoint, heap, PSRAM and loop time.
- Buzzer alarm for CO2 and particulate matter (PM2.5, PM10); a limit must hold for 5 minutes before it sounds, so a candle or a dust burst does not trigger it. CH2O and TVOC get colour steps but never sound it — the basis of every threshold is in docs/thresholds.md.
- Display backlight follows presence (PIR) and ambient light, with three levels set in the web UI.
- Indoor air quality index from the BME680 (Bosch BSEC2).
- Per-sensor temperature and humidity offsets: enter the readings of a reference meter next to the box ("Reference Temperature", "Reference Humidity") and press "Calibrate Temperature" or "Calibrate Humidity" — the box sets all four offsets. The SCD40 carries its own 4 °C chip offset (ESPHome's default for self-heating), applied before the box's offset, so the two never add up. ENS160 and SGP30 take their temperature/humidity compensation from the SHT41. CO2 has its own button, "Calibrate SCD40 CO2 (420 ppm)": let the box run in fresh air for a few minutes, then press it (Sensirion forced recalibration; automatic self calibration stays on). The formaldehyde sensor has "CH2O Offset" and "Zero CH2O (fresh air)" — its datasheet gives no accuracy figure and the protocol has no zero command, so zero it in fresh air once and the colour steps start at 0. "LTR390 Window Correction" is the datasheet's window factor (WFAC) for a tinted cover: it multiplies lux and UV index and stays at 1.0 as long as the sensor sees no tinted glass. The SGP30 has no calibration button at all — it learns a baseline for 12 hours, and "SGP30 Baseline Timer" counts down to that; every reboot, flash or power cut restarts the count.
- SD card logging in the display module's card slot (GPIO35–38): one CSV row per minute, one file per day.
- Optional: screen print on a thermal printer, fan control for an air filter — both described below.
| Sensor | Measures | Interface |
|---|---|---|
| SCD40 | CO2, temperature, humidity | I²C |
| SHT41 | Temperature, humidity | I²C |
| SGP30 | TVOC, eCO2 | I²C |
| ENS160 + AHT21 | TVOC, eCO2, air quality index; temperature, humidity | I²C |
| BME680 | Temperature, humidity, pressure, gas (IAQ via BSEC2) | I²C |
| LTR390 | Ambient light, UV index | I²C |
| ZE08-CH2O | Formaldehyde | UART |
| PMSx003 | PM1, PM2.5, PM10 | UART |
| SR602 | Motion | GPIO |
The config also has an SGP41 block (VOC and NOx index) although no SGP41 is fitted — the SGP30 does that job.
It stays for a later upgrade, so every boot logs Get serial number failed and
sgp4x.sensor is marked FAILED. Comment it out if you don't plan to fit the chip.
The PMSx003 runs in two-minute cycles: the config asks for a reading every 2 min, and above 30 s ESPHome puts
the sensor to sleep in between and wakes it 30 s early, because the fan needs that long to stabilise. The
laser is rated for 30 000 h of operation, so this duty cycle turns about 3.5 years of continuous use into
roughly 14. Readings hold between cycles and -- only appears until the first frame after a boot.
Which sensor feeds which value: docs/sensor-capability-matrix.md.
Hold the top button for 5 s, or press "Print Screen" in the web UI, to print the current page on a 58 mm
QR204 thermal printer. The box sends the page over WiFi as ESC/POS (raw TCP, port 9100) to an ESP32-C3 wired
to the printer — a separate project, QR204-Printer-Bridge.
Address ("Printer Host", default thermal-printer.local, the bridge's own mDNS name) and print settings are
in the web UI. Without a printer everything else works as usual.
The box can drive an IKEA FÖRNUFTIG fan converted to ESPHome (Wemos D1 mini) with
3ative/ikea-air-filter: it talks to the filter over WiFi through
its web server REST API ("Fan Speed", 0–6), so Home Assistant isn't needed. That config has no web server —
add one with the same login as the box. type: basic is required (the box sends HTTP Basic authentication,
and ESPHome switches the default to digest in 2027.1.0), so don't leave the type implicit:
web_server:
port: 80
auth:
type: basic
username: !secret web_server_username
password: !secret web_server_passwordIn the box's web UI, group "Air Filter":
- "Filter Auto" turns the control on; while it is off, the fan keeps its speed.
- "Filter Host": the filter's IP address or host name.
- "Filter Speed 1/6 above PM2.5" and "… above TVOC": two limits per value (defaults 10 / 50 µg/m³ and 800 / 2000 ppb), speeds 2–5 lie in between ("Filter Steps"). 0 turns a value off. The higher of the two wins.
- "Filter Thresholds Reset": writes the four defaults back and re-evaluates the speed right away — needed because the values are restored from flash, so a changed default in the config never reaches a box that already has one stored.
- "Filter Status": result of the last request, for example
OK (speed 2, TVOC).
The box sends on every speed change and otherwise every 2 minutes, so the filter gets its speed back after a restart. A speed only drops once the value is about 10 % below its threshold. The ENS160 needs ~3 minutes after a reboot (an hour after its first power-up); until then only PM2.5 counts. The activated carbon takes up odours and VOCs, not CO2 — only fresh air lowers CO2.
| Path | Contents |
|---|---|
esphome/airquality.yaml |
The ESPHome config |
esphome/sd_logger.h, esphome/thermal_print.h, esphome/air_filter.h |
SD card logging, screen printing and air filter control (included by the config) |
esphome/secrets.example.yaml |
Template for secrets.yaml |
hardware/enhanced-addon-v0.2/ |
Addon board v0.2: Gerber and drill files (EasyEDA Pro export, AddonBoard v0.2.zip for ordering), a render and the pinout |
docs/ |
Sensor capability matrix, thresholds and control settings |
No prebuilt firmware: the config uses Bosch BSEC2 for the BME680, whose license doesn't allow distributing
firmware built with it, and your WiFi password, web UI login and API key end up compiled in anyway. Build it
yourself with ESPHome from the files in esphome/.
- Copy
esphome/secrets.example.yamltosecrets.yamland fill it in (WiFi, web UI login, API key) — a suitable API key is 32 random bytes in base64, for example fromopenssl rand -base64 32. - Put
airquality.yaml,sd_logger.h,thermal_print.h,air_filter.handsecrets.yamlin the same folder of your ESPHome setup, for example the ESPHome Device Builder in Home Assistant. - Adjust the time zone under
time:. The device gets its address by DHCP; for a fixed address add amanual_ip:block underwifi:(or a DHCP reservation in your router). - Flash the box, see below. Settings (alarm limits, backlight, printer, filter) are in the web UI at the
device's address; the login is the one from
secrets.yaml.
Tested with ESPHome 2026.10.
First time over USB (the ESP32-S2 has no firmware from this project yet):
- Connect the main board with a USB cable. If it isn't detected, hold the BOOT button while plugging it in.
- In the Device Builder open the device and choose Install. If the Device Builder runs on a different machine, choose Manual download (factory format) and flash the file with ESPHome Web in Chrome or Edge instead.
- The first build takes several minutes.
After that, choose Install → Wirelessly; no cable needed. If the box can't reach WiFi, it opens a fallback access point named like the device and a captive portal to enter new WiFi credentials.
CC BY 4.0, in line with the CC BY license of Tom's Sensorbox v2, which this project builds on.
esphome/airquality.yaml is a modified version of Tom's ESPHome config r2. The ZE08 mount by Simply Maker is
not part of this repository and keeps its own license (CC BY-NC-SA).
When you reuse this project, credit it as: "Sensor-Box-Enhanced by Ajimaru, based on Sensorbox v2 by Tom (MadeWithLayers)".
