Over-engineering a home air quality dashboard

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.

Prerequisite Software

This project depends on a few software components. This post will assume that you have these set up already.

  1. HomeAssistant – Home Automation central system manages sensor lifecycle
  2. MQTT Broker – Message broker for ingesting sensor data
  3. InfluxDB – Time series database for short and medium-term data retention
  4. Grafana (Optional) – Used for building complex visualization dashboards
  5. Node Red – Graphical programming environment that I use to process sensor data
    1. Node Red Home Assistant Addon – Needs to be installed to create HA entities
  6. 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.

Courtesy of the CA Air Resources Board

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

Photo of my DIY AQI and CO2 monitoring device

I also created my own sensor using off the shelf components.

Ingredients:

CountItemCost
1Sensiron 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 compositionUS$58.95
1Plantower PM2.5 SensorUS$44.95
1ESP8266 FeatherUS$19.95
1STEMMA QT / Qwiic JST SH 4-pin CableUS$0.95
1JST SH 4-pin to Male Headers CableUS$0.95
TotalUS$125.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)
esphome: name: airquality platform: ESP8266 board: huzzah # Enable logging logger: ota: password: !secret ota wifi: ssid: !secret wifi_ssid password: !secret wifi_password i2c: sda: SDA scl: SCL scan: true mqtt: broker: mqtt.home.ajacqu.es 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' temperature_offset: 1.5 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
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.

SCD32 Field Calibration Guide (pdf)

Due to global CO2 emissions, the average outdoor CO2 concentration is slowly increasing over the years, so this will sensor will slowly diverge:

From Climate.gov. Average CO2 concentration as measured from Mauna Loa Observatory in Hawaii

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

Air pressure fluctuations over a day

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.

Manufacturer Detail Page

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.

Leave a Reply

Your email address will not be published. Required fields are marked *