Featured image of post Over-engineering a home air quality dashboard

Over-engineering a home air quality dashboard

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
  6. Node Red Home Assistant Addon - Needs to be installed to create HA entities
  7. 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.

Custom ESP Based Sensor

Photo of my DIY AQI and CO2 monitoring device

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:

CountItemCost
1Sensiron SCD-40 CO2 Sensor - There are a few different CO2 sensors with varying accuracy on Adafruit. This CO2 sensor uses some photoacoustic magic to measure how CO2 gas particles exist, whereas some other sensors just estimate the compositionUS$58.95
1Plantower PM2.5 SensorUS$44.95
1ESP32-S3 Qt Py w/ StemmaUS$9.95
2STEMMA QT / Qwiic JST SH 4-pin CableUS$0.95
TotalUS$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

1
2
3
4
5
# Your Wi-Fi SSID and password
wifi_ssid: "SSID"
wifi_password: "PASSWORD"

ota: "RANDOMSTRING"

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.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
esphome:
  name: airquality
  platform: ESP32S3
  board: esp32-s3-devkitc-1

# Enable logging
logger:

ota:
  password: !secret ota

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

i2c:
  sda: 41
  scl: 40
  scan: false
  frequency: 10kHz

mqtt:
  broker: mqtt.example.com
  discovery_unique_id_generator: mac
  discovery_object_id_generator: device_name
  on_connect:
    - light.turn_on:
        id: neopixel
        effect: connected
        red: 0%
        green: 100%
        blue: 0%
        brightness: 75%
    - delay: 2s
    - light.turn_off:
        id: neopixel
  on_disconnect:
    - light.turn_on:
        id: neopixel
        effect: broken

button:
  - platform: restart
    name: "Restart"

  - platform: template
    name: "CO2 Recalibrate"
    entity_category: config
    on_press:
      then:
        - scd4x.perform_forced_calibration:
            value: !lambda 'return id(co2_cal).state;'

number:
  - platform: template
    name: "CO2 calibration value (ppm)"
    optimistic: true
    min_value: 350
    max_value: 4500
    initial_value: 420
    retain: false
    step: 1
    id: co2_cal
    icon: "mdi:molecule-co2"
    entity_category: "config"

# This board has a small LED that we can use to signal statuses
light:
  - platform: neopixelbus
    type: GRB
    variant: WS2812
    pin: 2
    num_leds: 1
    id: neopixel
    name: "neopixel-enable"
    internal: true
    restore_mode: ALWAYS_OFF
    effects:
      - pulse:
          name: connected
      - strobe:
          name: broken
          colors:
            - state: true
              duration: 500ms
              brightness: 100%
              red: 100%
              green: 0%
              blue: 0%
            - state: true
              brightness: 100%
              duration: 500ms
              red: 100%
              green: 100%
              blue: 0%

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: scd4x
    co2:
      name: "CO2"
      retain: false
      accuracy_decimals: 1
    temperature:
      name: "Temperature"
      accuracy_decimals: 2
      retain: false
    humidity:
      name: "Humidity"
      accuracy_decimals: 1
      retain: false
    # 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
    measurement_mode: low_power_periodic
    # Automatic calibration causes problems when indoors. Instead manually calibrate
    automatic_self_calibration: false
    update_interval: 60s

  # This next block of code converts the PM2.5 measurement into an AQI
  # Based on the US EPA algorithm
  - platform: copy
    source_id: pm25
    id: pm_2_5
    internal: true
    accuracy_decimals: 0
    filters:
    - sliding_window_moving_average:
        window_size: 5
        send_every: 10
    on_value:
      lambda: |-
        // https://en.wikipedia.org/wiki/Air_quality_index#Computing_the_AQI
        if (id(pm_2_5).state < 12.0) {
          // good
          id(aqi_text).publish_state("Good");
          id(pm_2_5_aqi).publish_state((50.0 - 0.0) / (12.0 - 0.0) * (id(pm_2_5).state - 0.0) + 0.0);
        } else if (id(pm_2_5).state < 35.4) {
          // moderate
          id(aqi_text).publish_state("Moderate");
          id(pm_2_5_aqi).publish_state((100.0 - 51.0) / (35.4 - 12.1) * (id(pm_2_5).state - 12.1) + 51.0);
        } else if (id(pm_2_5).state < 55.4) {
          // Unhealthy for Sensitive Groups
          id(aqi_text).publish_state("Unhealthy for Sensitive Groups");
          id(pm_2_5_aqi).publish_state((150.0 - 101.0) / (55.4 - 35.5) * (id(pm_2_5).state - 35.5) + 101.0);
        } else if (id(pm_2_5).state < 150.4) {
          // unhealthy
          id(aqi_text).publish_state("Unhealthy");
          id(pm_2_5_aqi).publish_state((200.0 - 151.0) / (150.4 - 55.5) * (id(pm_2_5).state - 55.5) + 151.0);
        } else if (id(pm_2_5).state < 250.4) {
          // very unhealthy
          id(aqi_text).publish_state("Very Unhealthy");
          id(pm_2_5_aqi).publish_state((300.0 - 201.0) / (250.4 - 150.5) * (id(pm_2_5).state - 150.5) + 201.0);
        } else if (id(pm_2_5).state < 350.4) {
          // hazardous
          id(aqi_text).publish_state("Hazardous");
          id(pm_2_5_aqi).publish_state((400.0 - 301.0) / (350.4 - 250.5) * (id(pm_2_5).state - 250.5) + 301.0);
        } else if (id(pm_2_5).state < 500.4) {
          // hazardous 2
          id(aqi_text).publish_state("Hazardous");
          id(pm_2_5_aqi).publish_state((500.0 - 401.0) / (500.4 - 350.5) * (id(pm_2_5).state - 350.5) + 401.0);
        }        

  - platform: template
    name: "PM 2.5 AQI"
    icon: "mdi:air-filter"
    accuracy_decimals: 0
    state_class: measurement
    device_class: aqi
    id: pm_2_5_aqi

text_sensor:
  - platform: template
    name: "AQI Description"
    icon: "mdi:air-filter"
    id: aqi_text

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.

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
    "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
}

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):

1
\[{"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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
influxdb:
  host: influxdb-influxdb2.datastore.svc.cluster.local.
  port: 8086
  ssl: false
  api_version: 2
  token: 'token'
  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

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.

1
{"\_\_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.

A detailed chart showing inaccurate readings

A long-term view

There were several relevant looking GitHub issues all reporting similar behavior:

To fix this issue, I temporarily set temperature_offset to be 0 and redeployed.

July 2023

Occasionally after rebooting the esp device, one of the two sensors would just not come online and report data. It was hard to track down, but I believe that setting i2c.scan: false made it more reliable.

September 2023

  • Changed to the Adafruit Qt Py ESP32-S3 which includes a stemma connector
  • Updated the ESPHome YAML to match the board and pinout
  • Added AQI calculation on device so we don’t need the Node Red flow for the custom sensor
Copyright - All Rights Reserved
Last updated on Sep 01, 2023 00:00 UTC

Comments

Comments are currently unavailable while I move to this new blog platform. To give feedback, send an email to adam [at] this website url.