Illustration by Audrey Lee
Air—it’s invisible, I can’t see it, but I feel effects of it in so many ways, temperature, humidity, gas composition, but I lacked sensors to measure it. In this post, I walk through some different Air Quality sensors that I found and how I wired them up into a dashboard.
Table of Contents
Prerequisite Software
This project depends on a few software components. This post will assume that you have these set up already.
- HomeAssistant – Home Automation central system manages sensor lifecycle
- MQTT Broker – Message broker for ingesting sensor data
- InfluxDB – Time series database for short and medium-term data retention
- Grafana (Optional) – Used for building complex visualization dashboards
- Node Red – Graphical programming environment that I use to process sensor data
- Node Red Home Assistant Addon – Needs to be installed to create HA entities
- ESPHome – Required for DIY built sensors
Architecture Diagram
My system architecture for collecting and storing environmental sensor data looks like this:

A Quick Intro to AQI Metrics
AQI stands for Air Quality Index. It doesn’t actually represent a singular consistent metric and different organizations and governments calculate their indexes slightly different. Popular ones include the EPA AQI, AQandU
More information on the different types of indexes can be found here.
Air quality sensors don’t directly calculate the AQI and instead track low level metrics such as PM1.0, PM2.5, PM10, etc. PM2.5 corresponds to the amount of Particulate Matter that is 2.5μm or smaller.

PM10 includes dust, pollen, and mold, whereas PM2.5 starts to identify soot and smoke. Note that there are many particles that are smaller that can’t be easily measured with most home sensors. Some sensors (like the custom sensor I have) may end up estimating PM1.0 counts from the PM2.5 counts.

Given this, we’ll need the formulas that each one uses to be able to calculate the AQI indexes.
- EPA AQI – Found on page 9 of this pdf
Custom ESP Based Sensor

I also created my own sensor using off the shelf components. You don’t have to use the same exact ESP MCU as me. I initially used an ESP8266, but later iterations of my sensors switched to the ESP32 with Stemma connectors. Stemma is a connector standard that most Adafruit sensors include making it even easier to connect.
Ingredients:
Count | Item | Cost |
1 | Sensiron SCD-30 NDIR CO2 Sensor – There are a few different CO2 sensors with varying accuracy on Adafruit. The NDIR CO2 sensor uses a laser spectrometer to measure how CO2 gas particles absorb the light, whereas some other sensors just estimate the composition | US$58.95 |
1 | Plantower PM2.5 Sensor | US$44.95 |
1 | ESP32-C3 Qt Py w/ Stemma | US$9.95 |
2 | STEMMA QT / Qwiic JST SH 4-pin Cable | US$0.95 |
Total | US$115.75 |
ESPHome
I use ESPHome to build and update the firmware running on the controller. Originally, I wrote the code myself, but ESPHome provides all the glue code needed to pull data from i2c devices and publish it to MQTT.
secrets.yaml
# Your Wi-Fi SSID and password
wifi_ssid: "SSID"
wifi_password: "PASSWORD"
ota: "RANDOMSTRING"
Code language: PHP (php)
The following config works with the Adafruit QT Py ESP32-C3 linked earlier. If you have a different board make sure to update the esphome and i2c blocks.
esphome:
name: airquality
platform: ESP32C3
board: esp32-c3-devkitm-1
# Enable logging
logger:
ota:
password: !secret ota
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
i2c:
sda: 5
scl: 6
scan: true
mqtt:
broker: mqtt.home.ajacqu.es
discovery_unique_id_generator: mac
discovery_object_id_generator: device_name
button:
- platform: restart
name: "Restart"
status_led:
pin:
number: GPIO2
inverted: true
sensor:
- platform: pmsa003i
pm_1_0:
name: "PM1.0"
pm_2_5:
name: "PM2.5"
pm_10_0:
name: "PM10.0"
pmc_0_3:
name: "PMC <0.3µm"
pmc_0_5:
name: "PMC <0.5µm"
pmc_1_0:
name: "PMC <1µm"
pmc_2_5:
name: "PMC <2.5µm"
pmc_5_0:
name: "PMC <5µm"
pmc_10_0:
name: "PMC <10µm"
update_interval: 60s
- platform: scd30
co2:
name: "CO2"
accuracy_decimals: 1
temperature:
name: "Temperature"
accuracy_decimals: 2
humidity:
name: "Humidity"
accuracy_decimals: 1
# The below number is based on the average air pressure for my
# area in mBar divided by 1000 (1.009 == 1009mBar)
# If you don't know what this is, then disable this.
ambient_pressure_compensation: '1.009'
# Seems to be broken, see https://github.com/esphome/issues/issues/3063
temperature_offset: 0
address: 0x61
update_interval: 60s
Code language: YAML (yaml)
Accuracy and Calibration
One of the issues I’ve noticed with the CO2 sensor is that it seems to show sensor values that are incorrect–below the average outdoor CO2 ppm. The NDIR sensor I have needs to be recalibrated periodically to know how the spectrometer measurements correlate to a specific PPM. The Sensiron SCD-30 data sheet specifies an accuracy of ±30ppm from 400-10k ppm. The auto calibration mode assumes that the sensor is periodically exposed to outdoor fresh air and assumes that outdoor ppm is 400ppm.
ASC assumes that the lowest CO2 concentration the
SCD32 Field Calibration Guide (pdf)
SCD30 is exposed to corresponds to 400 ppm. The sensor
estimates the most likely reading corresponding to this
background level and identifies this as 400ppm.
Due to global CO2 emissions, the average outdoor CO2 concentration is slowly increasing over the years, so this will sensor will slowly diverge:

In addition, the ambient air pressure influences sensor readings and this changes throughout the day due to changing weather conditions.

Thus, we have multiple variables that will all cause drift over time:
- Sensor drift due to spectrometer sensor drifts
- Global CO2 concentration increasing causing underestimation of CO2 ppm over years
- Ambient pressure fluctuations due to changing weather
- Not exposing the sensor to fresh air when auto calibration is enabled
There’s only so much I can do to ensure accuracy of this data. I set an average air pressure to compensate and open the window once or twice a week to ensure a reference value.
PurpleAir Indoor Sensor
Before I decided to purchase an air quality sensor, I looked up some independent reviews of the accuracy of different sensors. The US EPA released a report showing that the PurpleAir did reasonably well for the price point, so I bought one to test it out.
Measures:
- Air Quality Index
- Air Pressure
- Temperature
- Humidity
PurpleAir sensors can publish data directly to the PurpleAir Map, but I want to export this data to Prometheus or InfluxDB. One way is to use an already built Purple Prometheus Exporter (purpleprom) which fetches from Purple’s API, calculates several AQI metrics, then exposes them to a Prometheus scraper.

This will work, however this depends on their API called over the Internet and only gets the data into Prometheus. My home lab tries to avoid dependencies on Internet services and use local devices where possible.
Luckily, after connecting to my Wi-Fi network, the devices exposes an HTTP endpoint with all sensor data at the URL: http://{ip}:80/json. The following shows an example response (Interesting values are highlighted):
{
"SensorId": "01:23:45:67:89:ab",
"DateTime": "2022/07/26T05:18:38z",
"Geo": "PurpleAir-1234",
"Mem": 17552,
"memfrag": 18,
"memfb": 14464,
"memcs": 936,
"Id": 8436,
"lat": 47.00000,
"lon": -122.00000,
"Adc": 0.02,
"loggingrate": 15,
"place": "inside",
"version": "7.00",
"uptime": 253112,
"rssi": -72,
"period": 119,
"httpsuccess": 8501,
"httpsends": 8503,
"hardwareversion": "2.0",
"hardwarediscovered": "2.0+BME280+PMSX003-A",
"current_temp_f": 87,
"current_humidity": 33,
"current_dewpoint_f": 54,
"pressure": 1003.65,
"p25aqic": "rgb(174,246,0)",
"pm2.5_aqi": 44,
"pm1_0_cf_1": 5.4,
"p_0_3_um": 1215.85,
"pm2_5_cf_1": 10.65,
"p_0_5_um": 375.31,
"pm10_0_cf_1": 10.96,
"p_1_0_um": 74.28,
"pm1_0_atm": 5.4,
"p_2_5_um": 9.75,
"pm2_5_atm": 10.65,
"p_5_0_um": 0.58,
"pm10_0_atm": 10.96,
"p_10_0_um": 0,
"pa_latency": 177,
// ... Some more
}
Code language: JSON / JSON with Comments (json)
These fields provide the relevant information to be able to calculate the AQI.
Processing Sensor Data
I now have two different sensors that are ready to provide data. My custom sensor is publishing data through MQTT (and some data is already available in Home Assistant) and the PurpleAir sensor is on the Wi-Fi network and ready. Feel free to skip parts if you have only sensor but not the other.
PurpleAir Sensor
Every minute, my Node Red flow calls the HTTP endpoint on the sensor to grab the latest sensor values, calculates the AQI from the PM2.5 metric, then populates several sensors into Home Assistant.

Here’s the Node Red flow for above. Copy and paste this into the Node Red UI to use it as a template.
[{"id":"3f4962eb31025362","type":"tab","label":"Flow 1","disabled":false,"info":"","env":[]},{"id":"f12427f26b727dc9","type":"junction","z":"3f4962eb31025362","x":1420,"y":800,"wires":[["3a856e7ab6d6d4c6","bc26db0709e3d34b"]]},{"id":"96894d899bdea100","type":"change","z":"3f4962eb31025362","name":"","rules":[{"t":"set","p":"pm25","pt":"msg","to":"payload.pm2_5_cf_1","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":1140,"y":720,"wires":[["f024a8c76b1f9ce0","c121d65dd0f7dc45","f12427f26b727dc9","98a883db43c85b0f"]]},{"id":"9e79ed78dd67fc08","type":"switch","z":"3f4962eb31025362","name":"If error","property":"error","propertyType":"msg","rules":[{"t":"null"},{"t":"nnull"}],"checkall":"true","repair":false,"outputs":2,"x":970,"y":720,"wires":[["96894d899bdea100"],[]]},{"id":"f024a8c76b1f9ce0","type":"function","z":"3f4962eb31025362","name":"Normalize Temperature","func":"msg.payload = msg.payload.current_temp_f - 8;\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1530,"y":720,"wires":[["9a5bbf8cc529fb67"]]},{"id":"3ec53bbca46d0c79","type":"http request","z":"3f4962eb31025362","name":"","method":"GET","ret":"obj","paytoqs":"ignore","url":"http://192.168.1.45/json","tls":"","persist":false,"proxy":"","authType":"","senderr":false,"x":810,"y":720,"wires":[["9e79ed78dd67fc08"]]},{"id":"c121d65dd0f7dc45","type":"switch","z":"3f4962eb31025362","name":"> 0","property":"payload.pressure","propertyType":"msg","rules":[{"t":"gt","v":"0","vt":"num"}],"checkall":"true","repair":false,"outputs":1,"x":1470,"y":760,"wires":[["b1364f17e9a4f113"]]},{"id":"b96470e4363c8622","type":"inject","z":"3f4962eb31025362","name":"every minute","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"60","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":620,"y":720,"wires":[["3ec53bbca46d0c79"]]},{"id":"98a883db43c85b0f","type":"link call","z":"3f4962eb31025362","name":"","links":["4e1646049cd8c3fa"],"linkType":"static","timeout":"30","x":1500,"y":680,"wires":[["fae3173cf70b26fc"]]},{"id":"3a856e7ab6d6d4c6","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir Humidity","entityConfig":"feea3b65bc43902a","version":0,"state":"payload.current_humidity","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":1790,"y":800,"wires":[[]]},{"id":"9a5bbf8cc529fb67","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir Temperature","entityConfig":"24eaa8400b7264e6","version":0,"state":"payload","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":1800,"y":720,"wires":[[]]},{"id":"fae3173cf70b26fc","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir 1m iAQI","entityConfig":"5bd2e57458ace436","version":0,"state":"iaqi.epa","stateType":"msg","attributes":[{"property":"type","value":"iaqi","valueType":"str"}],"inputOverride":"allow","outputProperties":[],"x":1790,"y":680,"wires":[[]]},{"id":"b1364f17e9a4f113","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir Air Pressure","entityConfig":"3d98f8971f231416","version":0,"state":"payload.pressure","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":1800,"y":760,"wires":[[]]},{"id":"bc26db0709e3d34b","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir Wi-Fi rx RSSI","entityConfig":"d04ff191e844b1a0","version":0,"state":"payload.rssi","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":1810,"y":840,"wires":[[]]},{"id":"4e1646049cd8c3fa","type":"link in","z":"3f4962eb31025362","name":"Compute AQI","links":[],"x":765,"y":920,"wires":[["c256eaec2e31c57f"]]},{"id":"bb93a33f9c5e9a7d","type":"link out","z":"3f4962eb31025362","name":"link out 2","mode":"return","links":[],"x":1075,"y":920,"wires":[]},{"id":"c256eaec2e31c57f","type":"function","z":"3f4962eb31025362","name":"Compute AQI","func":"// https://github.com/hrbonz/python-aqi/blob/master/aqi/algos/epa.py\nconst data = {\n 'aqi': [\n [0, 50],\n [51, 100],\n [101, 150],\n [151, 200],\n [201, 300],\n [301, 400],\n [401, 500]\n ],\n 'bp': {\n POLLUTANT_PM25: [\n [0.0, 12.0],\n [12.1, 35.4],\n [35.5, 55.4],\n [55.5, 150.4],\n [150.5, 250.4],\n [250.5, 350.4],\n [350.5, 500.4],\n ]\n }\n}\n\nfunction convertToIaqi(value) {\n if (value < 0) {\n throw new Error(`PM2.5 can't be negative: ${value}`);\n }\n const adjValue = Math.floor(value * 10) / 10;\n const breakpoints = data.bp.POLLUTANT_PM25;\n var bplo = 0;\n var bphi = 0;\n var index = 0;\n for (const bp of breakpoints) {\n if (adjValue >= bp[0] && adjValue <= bp[1]) {\n bplo = bp[0];\n bphi = bp[1];\n break;\n }\n index++;\n }\n \n const aqival = data.aqi[index];\n if (!aqival) {\n throw new Error(`Can't calculate AQI for '${value}' index out of range: ${index}.`);\n }\n //return index;\n const aqiValue = (aqival[1] - aqival[0]) / (bphi - bplo) * (adjValue - bplo) + aqival[0];\n \n return Math.round(aqiValue);\n}\n\nmsg.iaqi = {\n epa: convertToIaqi(msg.pm25),\n aqandu: convertToIaqi(0.778 * msg.pm25 + 2.65),\n lrapa: convertToIaqi(Math.max(0, 0.5 * msg.pm25 - 0.66))\n};\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":920,"y":920,"wires":[["bb93a33f9c5e9a7d"]]},{"id":"feea3b65bc43902a","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"sensor config for PurpleAir Humidity","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"PurpleAir Humidity"},{"property":"icon","value":"mdi:water-percent"},{"property":"entity_category","value":""},{"property":"device_class","value":"humidity"},{"property":"unit_of_measurement","value":"%"},{"property":"state_class","value":"measurement"}],"resend":true},{"id":"24eaa8400b7264e6","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"sensor config for PurpleAir Temperature","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"PurpleAir Temperature"},{"property":"icon","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":"temperature"},{"property":"unit_of_measurement","value":"°F"},{"property":"state_class","value":"measurement"}],"resend":true},{"id":"5bd2e57458ace436","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"sensor config for PurpleAir 1m EPA","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":""},{"property":"icon","value":"mdi:blur"},{"property":"entity_category","value":""},{"property":"device_class","value":"aqi"},{"property":"unit_of_measurement","value":"aqi"},{"property":"state_class","value":"measurement"}],"resend":true},{"id":"3d98f8971f231416","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"sensor config for PurpleAir Air Pressure","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"PurpleAir Temperature"},{"property":"icon","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":"temperature"},{"property":"unit_of_measurement","value":"°C"},{"property":"state_class","value":"measurement"}],"resend":true},{"id":"d04ff191e844b1a0","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"PurpleAir Wi-Fi RSSI","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":""},{"property":"icon","value":""},{"property":"entity_category","value":"diagnostic"},{"property":"device_class","value":"signal_strength"},{"property":"unit_of_measurement","value":"dBm"},{"property":"state_class","value":"measurement"}],"resend":false},{"id":"434f6479916be393","type":"server","name":"Home Assistant","version":5,"addon":false,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true,"heartbeat":false,"heartbeatInterval":"30","areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":"at: ","statusYear":"hidden","statusMonth":"short","statusDay":"numeric","statusHourCycle":"h23","statusTimeFormat":"h:m","enableGlobalContextStore":true}]
Custom Sensor
I configured ESPHome to publish raw sensor data to MQTT and HomeAssistant is configured to also connect to the broker and create entities. When I turned on my sensor, I get the following sensors:

The only thing I'm missing is the IAQI metrics. Using the same function node from before, this is easy:

And the JSON for Node Red (copy and paste into the UI):
[{"id":"3f4962eb31025362","type":"tab","label":"Air Quality","disabled":false,"info":"","env":[]},{"id":"f12427f26b727dc9","type":"junction","z":"3f4962eb31025362","x":780,"y":380,"wires":[["3a856e7ab6d6d4c6","bc26db0709e3d34b"]]},{"id":"96894d899bdea100","type":"change","z":"3f4962eb31025362","name":"","rules":[{"t":"set","p":"sensor_name","pt":"msg","to":"living_room","tot":"str"},{"t":"set","p":"pm25","pt":"msg","to":"payload.pm2_5_cf_1","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":640,"y":300,"wires":[["f024a8c76b1f9ce0","c121d65dd0f7dc45","f12427f26b727dc9","98a883db43c85b0f"]]},{"id":"9e79ed78dd67fc08","type":"switch","z":"3f4962eb31025362","name":"If error","property":"error","propertyType":"msg","rules":[{"t":"null"},{"t":"nnull"}],"checkall":"true","repair":false,"outputs":2,"x":470,"y":300,"wires":[["96894d899bdea100"],[]]},{"id":"f024a8c76b1f9ce0","type":"function","z":"3f4962eb31025362","name":"Normalize Temperature","func":"msg.payload = msg.payload.current_temp_f - 8;\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":890,"y":300,"wires":[["9a5bbf8cc529fb67"]]},{"id":"3ec53bbca46d0c79","type":"http request","z":"3f4962eb31025362","name":"","method":"GET","ret":"obj","paytoqs":"ignore","url":"http://192.168.1.160/json","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":310,"y":300,"wires":[["9e79ed78dd67fc08"]]},{"id":"c121d65dd0f7dc45","type":"switch","z":"3f4962eb31025362","name":"> 0","property":"payload.pressure","propertyType":"msg","rules":[{"t":"gt","v":"0","vt":"num"}],"checkall":"true","repair":false,"outputs":1,"x":830,"y":340,"wires":[["b1364f17e9a4f113"]]},{"id":"b96470e4363c8622","type":"inject","z":"3f4962eb31025362","name":"every minute","props":[{"p":"payload"}],"repeat":"60","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":120,"y":300,"wires":[["3ec53bbca46d0c79"]]},{"id":"98a883db43c85b0f","type":"link call","z":"3f4962eb31025362","name":"","links":["4e1646049cd8c3fa"],"linkType":"static","timeout":"30","x":860,"y":260,"wires":[["fae3173cf70b26fc"]]},{"id":"3a856e7ab6d6d4c6","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir Humidity","entityConfig":"feea3b65bc43902a","version":0,"state":"payload.current_humidity","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":1150,"y":420,"wires":[[]]},{"id":"9a5bbf8cc529fb67","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir Temperature","entityConfig":"24eaa8400b7264e6","version":0,"state":"payload","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":1160,"y":300,"wires":[[]]},{"id":"fae3173cf70b26fc","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir 1m EPA","entityConfig":"5bd2e57458ace436","version":0,"state":"iaqi.epa","stateType":"msg","attributes":[{"property":"type","value":"iaqi","valueType":"str"}],"inputOverride":"allow","outputProperties":[],"x":1150,"y":240,"wires":[[]]},{"id":"b1364f17e9a4f113","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir Air Pressure","entityConfig":"3d98f8971f231416","version":0,"state":"payload.pressure","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":1160,"y":360,"wires":[[]]},{"id":"bc26db0709e3d34b","type":"ha-sensor","z":"3f4962eb31025362","name":"PurpleAir Wi-Fi rx RSSI","entityConfig":"d04ff191e844b1a0","version":0,"state":"payload.rssi","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":1170,"y":480,"wires":[[]]},{"id":"4e1646049cd8c3fa","type":"link in","z":"3f4962eb31025362","name":"Compute AQI","links":[],"x":265,"y":500,"wires":[["c256eaec2e31c57f"]]},{"id":"bb93a33f9c5e9a7d","type":"link out","z":"3f4962eb31025362","name":"link out 2","mode":"return","links":[],"x":575,"y":500,"wires":[]},{"id":"c256eaec2e31c57f","type":"function","z":"3f4962eb31025362","name":"Compute AQI","func":"// https://github.com/hrbonz/python-aqi/blob/master/aqi/algos/epa.py\nconst data = {\n 'aqi': [\n [0, 50],\n [51, 100],\n [101, 150],\n [151, 200],\n [201, 300],\n [301, 400],\n [401, 500]\n ],\n 'bp': {\n POLLUTANT_PM25: [\n [0.0, 12.0],\n [12.1, 35.4],\n [35.5, 55.4],\n [55.5, 150.4],\n [150.5, 250.4],\n [250.5, 350.4],\n [350.5, 500.4],\n ]\n }\n}\n\nfunction convertToIaqi(value) {\n if (value < 0) {\n throw new Error(`PM2.5 can't be negative: ${value}`);\n }\n const adjValue = Math.floor(value * 10) / 10;\n const breakpoints = data.bp.POLLUTANT_PM25;\n var bplo = 0;\n var bphi = 0;\n var index = 0;\n for (const bp of breakpoints) {\n if (adjValue >= bp[0] && adjValue <= bp[1]) {\n bplo = bp[0];\n bphi = bp[1];\n break;\n }\n index++;\n }\n \n const aqival = data.aqi[index];\n if (!aqival) {\n throw new Error(`Can't calculate AQI for '${value}' index out of range: ${index}.`);\n }\n //return index;\n const aqiValue = (aqival[1] - aqival[0]) / (bphi - bplo) * (adjValue - bplo) + aqival[0];\n \n return Math.round(aqiValue);\n}\n\nmsg.iaqi = {\n epa: convertToIaqi(msg.pm25),\n aqandu: convertToIaqi(0.778 * msg.pm25 + 2.65),\n lrapa: convertToIaqi(Math.max(0, 0.5 * msg.pm25 - 0.66))\n};\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":420,"y":500,"wires":[["bb93a33f9c5e9a7d"]]},{"id":"feea3b65bc43902a","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"sensor config for PurpleAir Humidity","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"Humidity"},{"property":"icon","value":"mdi:water-percent"},{"property":"entity_category","value":""},{"property":"device_class","value":"humidity"},{"property":"unit_of_measurement","value":"%"},{"property":"state_class","value":"measurement"}],"resend":true},{"id":"24eaa8400b7264e6","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"sensor config for PurpleAir Temperature","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"Temperature"},{"property":"icon","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":"temperature"},{"property":"unit_of_measurement","value":"°F"},{"property":"state_class","value":"measurement"}],"resend":true},{"id":"5bd2e57458ace436","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"sensor config for PurpleAir 1m EPA","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"AQI (EPA)"},{"property":"icon","value":"mdi:blur"},{"property":"entity_category","value":""},{"property":"device_class","value":"aqi"},{"property":"unit_of_measurement","value":"aqi"},{"property":"state_class","value":"measurement"}],"resend":true},{"id":"3d98f8971f231416","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"sensor config for PurpleAir Air Pressure","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"Air Pressure"},{"property":"icon","value":"mdi:gauge"},{"property":"entity_category","value":""},{"property":"device_class","value":"pressure"},{"property":"unit_of_measurement","value":"mbar"},{"property":"state_class","value":"measurement"}],"resend":true},{"id":"d04ff191e844b1a0","type":"ha-entity-config","server":"434f6479916be393","deviceConfig":"","name":"PurpleAir Wi-Fi RSSI","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"Wi-Fi RSSI"},{"property":"icon","value":""},{"property":"entity_category","value":"diagnostic"},{"property":"device_class","value":"signal_strength"},{"property":"unit_of_measurement","value":"dBm"},{"property":"state_class","value":"measurement"}],"resend":false},{"id":"434f6479916be393","type":"server","name":"Home Assistant","version":5,"addon":false,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true,"heartbeat":false,"heartbeatInterval":"30","areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":"at: ","statusYear":"hidden","statusMonth":"short","statusDay":"numeric","statusHourCycle":"h23","statusTimeFormat":"h:m","enableGlobalContextStore":true}]
Publishing to InfluxDB
HomeAssistant stores sensor data in the Recorder, which by default uses SQLite, but can be changed to use any SQL database like Postgres or MySQL. These aren't efficient at storing large amounts of time series data, so instead I store time series data long-term in InfluxDB using the HA Integration.
HomeAssistant by default publishes all entities and all attributes for each state to InfluxDB. This results in *a lot* of useless data being stored and will quickly consume a large amount of disk space. To prevent this, I will explicitly whitelist just the sensor data and ignore any attributes that aren't useful.
influxdb:
host: influxdb-influxdb2.datastore.svc.cluster.local.
port: 8086
ssl: false
api_version: 2
token: '<strong>token</strong>'
bucket: homeassistant
organization: influxdata
component_config_domain:
sensor:
ignore_attributes:
- attribution
- device_class
- state_class
- last_reset
- integration
- description
- unit_of_measurement
- type
include:
domain:
- sensor
Code language: YAML (yaml)
Grafana
Grafana provides the ability to build richer dashboards compared to HomeAssistant. I created an Air Quality monitoring dashboard that fetches data from InfluxDB:

Grafana Dashboard JSON. To import, go to Grafana > Dashboards > Import.
{"__inputs":[{"name":"DS_INFLUXDB","label":"InfluxDB","description":"","type":"datasource","pluginId":"influxdb","pluginName":"InfluxDB"}],"__elements":{},"__requires":[{"type":"panel","id":"gauge","name":"Gauge","version":""},{"type":"grafana","id":"grafana","name":"Grafana","version":"9.0.5"},{"type":"datasource","id":"influxdb","name":"InfluxDB","version":"1.0.0"},{"type":"panel","id":"state-timeline","name":"State timeline","version":""},{"type":"panel","id":"timeseries","name":"Time series","version":""}],"annotations":{"list":[{"builtIn":1,"datasource":{"type":"datasource","uid":"grafana"},"enable":true,"hide":true,"iconColor":"rgba(0, 211, 255, 1)","name":"Annotations & Alerts","target":{"limit":100,"matchAny":false,"tags":[],"type":"dashboard"},"type":"dashboard"}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":null,"links":[],"liveNow":false,"panels":[{"collapsed":false,"datasource":{"type":"prometheus","uid":"PBFA97CFB590B2093"},"gridPos":{"h":1,"w":24,"x":0,"y":0},"id":18,"panels":[],"title":"Current AQI","type":"row"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"max":500,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[]},"gridPos":{"h":5,"w":3,"x":0,"y":1},"id":19,"options":{"orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"text":{}},"pluginVersion":"9.0.5","targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> filter(fn: (r) => strings.hasSuffix(v: r[\"entity_id\"], suffix: \"_iaqi\"))\r\n |> last()\r\n |> keep(columns: [\"_value\"])\r\n |> mean()","refId":"A"}],"title":"Current PM 2.5 AQI","type":"gauge"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"mappings":[],"max":500,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[]},"gridPos":{"h":5,"w":3,"x":3,"y":1},"id":20,"options":{"orientation":"auto","reduceOptions":{"calcs":["mean"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"text":{}},"pluginVersion":"9.0.5","targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> filter(fn: (r) => strings.hasSuffix(v: r[\"entity_id\"], suffix: \"_aqandu\"))\r\n |> last()\r\n |> keep(columns: [\"_value\"])\r\n |> mean()","refId":"A"}],"title":"Current PM 2.5 AQI (AQandU)","type":"gauge"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"mappings":[],"max":500,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[]},"gridPos":{"h":5,"w":3,"x":6,"y":1},"id":21,"options":{"orientation":"auto","reduceOptions":{"calcs":["mean"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"text":{}},"pluginVersion":"9.0.5","targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> filter(fn: (r) => strings.hasSuffix(v: r[\"entity_id\"], suffix: \"_lrapa\"))\r\n |> last()\r\n |> keep(columns: [\"_value\"])\r\n |> mean()","refId":"A"}],"title":"Current PM 2.5 AQI (LRAPA)","type":"gauge"},{"collapsed":false,"datasource":{"type":"prometheus","uid":"PBFA97CFB590B2093"},"gridPos":{"h":1,"w":24,"x":0,"y":6},"id":8,"panels":[],"title":"AQI History","type":"row"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"area"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[{"matcher":{"id":"byFrameRefID","options":"G"},"properties":[{"id":"color","value":{"fixedColor":"blue","mode":"fixed"}}]},{"matcher":{"id":"byFrameRefID","options":"F"},"properties":[{"id":"color","value":{"fixedColor":"green","mode":"fixed"}}]},{"matcher":{"id":"byFrameRefID","options":"E"},"properties":[{"id":"color","value":{"fixedColor":"red","mode":"fixed"}}]}]},"gridPos":{"h":11,"w":8,"x":0,"y":7},"id":2,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"8.3.3","targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"from(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\r\n |> yield(name: \"mean\")","refId":"A"}],"title":"PM 2.5 AQI","type":"timeseries"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"max":100,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":11,"w":7,"x":8,"y":7},"id":23,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"from(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"%\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\r\n |> yield(name: \"mean\")","refId":"A"}],"title":"Humidity","type":"timeseries"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisLabel":"°F","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"line"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"yellow","value":null},{"color":"green","value":60},{"color":"red","value":80}]}},"overrides":[{"matcher":{"id":"byFrameRefID","options":"A"},"properties":[{"id":"displayName","value":"${__field.labels.entity_id}"}]},{"matcher":{"id":"byFrameRefID","options":"B"},"properties":[{"id":"color","value":{"mode":"fixed"}}]}]},"gridPos":{"h":11,"w":9,"x":15,"y":7},"id":39,"interval":"5m","options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"entity_id\"] != \"fridge_door_temperature_measurement\" and not strings.hasSuffix(v: r[\"entity_id\"], suffix: \"index\") and not strings.hasSuffix(v: r[\"entity_id\"], suffix: \"point\"))\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"°F\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> filter(fn: (r) => r[\"domain\"] == \"sensor\")\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> fill(usePrevious: true)\r\n |> map(fn: (r) => ({ r with name: r.entity_id }))\r\n |> yield(name: \"mean\")\r\n\r\n// |> filter(fn: (r) => r[\"entity_id\"] == \"living_room_temperature\" or r[\"entity_id\"] == \"home_temperature\" or r[\"entity_id\"] == \"button_temperature_measurement\" or r[\"entity_id\"] == \"bedroom_temperature_esp\" or r[\"entity_id\"] == \"bedroom_temperature_ecobee\" or r[\"entity_id\"] == \"hallway_button_temperature_measurement\")","refId":"A"}],"title":"Temperature","type":"timeseries"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"line"}},"decimals":4,"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]},"unit":"pressurembar"},"overrides":[]},"gridPos":{"h":8,"w":3,"x":0,"y":18},"id":33,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"from(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"mbar\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> filter(fn: (r) => r[\"domain\"] == \"sensor\")\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\r\n |> yield(name: \"mean\")","refId":"A"}],"title":"Air Pressure","type":"timeseries"},{"alert":{"alertRuleTags":{},"conditions":[{"evaluator":{"params":[1000],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A","5m","now"]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"executionErrorState":"alerting","for":"5m","frequency":"1m","handler":1,"name":"CO2 PPM alert","noDataState":"no_data","notifications":[]},"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"custom":{"axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"area"}},"links":[],"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"#EAB839","value":900},{"color":"red","value":1000}]},"unit":"ppm"},"overrides":[]},"gridPos":{"h":7,"w":4,"x":3,"y":18},"id":29,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"from(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"ppm\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> filter(fn: (r) => r[\"domain\"] == \"sensor\")\r\n |> filter(fn: (r) => r[\"entity_id\"] == \"bedroom_co2\")\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\r\n |> yield(name: \"mean\")","refId":"A"}],"thresholds":[{"colorMode":"critical","op":"gt","value":1000,"visible":true}],"title":"CO2 PPM","type":"timeseries"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"custom":{"fillOpacity":70,"lineWidth":0,"spanNulls":false},"displayName":"${__field.labels.entity_id}","mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[]},"gridPos":{"h":7,"w":10,"x":7,"y":18},"id":31,"options":{"alignValue":"left","legend":{"displayMode":"list","placement":"bottom"},"mergeValues":true,"rowHeight":0.9,"showValue":"never","tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n |> filter(fn: (r) => strings.hasSuffix(v: r[\"entity_id\"], suffix: \"_iaqi\"))\r\n |> yield(name: \"mean\")","refId":"A"}],"title":"Air Quality","type":"state-timeline"}],"refresh":"","schemaVersion":36,"style":"dark","tags":[],"templating":{"list":[]},"time":{"from":"now-3h","to":"now"},"timepicker":{"refresh_intervals":["10s","30s","1m","5m","15m","30m","1h","2h","1d"]},"timezone":"America/Los_Angeles","title":"Air Quality (Public)","uid":"ASFASr23rfasdf","version":4,"weekStart":""}
Conclusion
In this post, I walked through two different sensors: a DIY sensor and a prebuilt solution. Showed how to connect to Home Assistant, InfluxDB, and create some basic dashboards for visualization. Future projects include improving accuracy or even designing a custom PCB instead of stringing together sensors with wires.
Updates
Jan 2023
After running this for a few months, I noticed that my measured CO2 started getting less accurate. It seemed to get in a bad state until I reset the esp8266, then it would work okay.


There were several relevant looking GitHub issues all reporting similar behavior:
- https://github.com/esphome/issues/issues/3063
- https://github.com/esphome/issues/issues/3407
- https://github.com/esphome/issues/issues/3529
- https://github.com/esphome/issues/issues/3063
To fix this issue, I temporarily set temperature_offset to be 0 and redeployed.