Local Indoor and Outdoor Air Quality (AQI, NowCast, IAQI)

A few years ago, I picked up some AirGradient devices to keep an eye on the air in and out of my house (especially as "smoke season" has become a bit too common here in the PNW US). Because AirGradient didn't yet have a Home Assistant integration, I also started helping out with the ESPHome firmware, eventually contributing the on-device AQI calculations for the popular version from @MallocArray.

The biggest problem with on-device calculations for slow metrics like AQI is that the calculation buffer is lost when the device is rebooted or the firmware is updated. To recover, NowCast needs at least 2 hours worth of data, and the daily AQI is 18 (or a full day if following the official midnight-to-midnight calculation).

I've been talking about figuring out how to move these calculations into Home Assistant itself for years, so I finally worked up the motivation to do so over the last couple weeks. This also gave me a chance to look into indoor air quality metrics. This first release of the integration provides the following sensors, along with some additional info like official category names and color codes:

  • Indoor Sensors
    • IAQI: ATMO produced this scale and formula as a way to produce a rating
      similar to AQI, but for indoor spaces.
  • Outdoor Sensors
    • EPA NowCast AQI: This is what most people think of when they hear "AQI."
    • EPA AQI: Intended as a general air quality score spread across an entire day.

You can find all of the info about the plugin at github, including planned future work and a few items I'm looking for help with. Installation is currently via HACS as a custom repository:

And if you want a nice-looking dashboard for your AirGradient Outdoor (I don't have the new one with extra sensors, so I left those off), feel free to take inspiration from my own setup below (you'll need card-mod, mini-graph-card, and stack-in-card):

type: custom:stack-in-card
mode: vertical
keep:
  background: true
  border_radius: false
  margin: false
cards:
  - type: markdown
    content: >
      {%- set category_entity = 'sensor.outdoor_air_quality_nowcast_category' -%}
      {%- set time_entity = 'sensor.outdoor_air_quality_nowcast_data_expected' -%}
      
      {% if not has_value(category_entity) %}
        # NowCast: Calculating
        {% set target = states(time_entity) | as_datetime %}
        {% set mins = ((target - now()).total_seconds() / 60) | int if target is not none else 0 %}
        Available in {% if mins > 60 %}{{ mins // 60 }} hours {% endif %}{{ mins % 60 }} minutes
      {% else %}
        # NowCast: {{ state_translated(category_entity) }}
      {% endif %}
    card_mod:
      style: |
        ha-card {
          {% set aqi = states('sensor.outdoor_air_quality_nowcast_aqi') -%}
          {%- set aqi = aqi | float if is_number(aqi) else -1 -%}
          {%- if aqi < 0 -%}
            background-color: #a0a0a0; color: #393939;
          {%- elif aqi < 50 -%}
            background-color: #00e400; color: #393939;
          {%- elif aqi < 100 -%}
            background-color: #ffff00; color: #004000;
          {%- elif aqi < 150 -%}
            background-color: #ff7e00; color: #404000;
          {%- elif aqi < 200 -%}
            background-color: #ff0000; color: #400000;
          {%- elif aqi < 300 -%}
              background-color: #8f3f97; color: #300030;
          {%- else -%}
            background-color: #7e0023; color: #200000;
          {% endif %}
          text-align: center;
        }
        h1 {
          line-height: 1px;
          margin: 0 !important;
          padding: 0 !important;
          border: 1px solid red !important;
        }
  - type: conditional
    conditions:
      - entity: sensor.outdoor_air_quality_nowcast_aqi
        state_not: unknown
      - entity: sensor.outdoor_air_quality_nowcast_aqi
        state_not: unavailable
    card:
      type: gauge
      entity: sensor.outdoor_air_quality_nowcast_aqi
      needle: true
      min: 0
      max: 500
      segments:
        - from: 0
          color: "#00e400"
        - from: 50
          color: "#ffff00"
        - from: 100
          color: "#ff7e00"
        - from: 150
          color: "#ff0000"
        - from: 200
          color: "#8f3f97"
        - from: 300
          color: "#7e0023"
      name: ""
  - type: custom:mini-graph-card
    entities:
      - entity: sensor.outdoor_air_quality_nowcast_aqi
    color_thresholds:
      - value: 0
        color: "#00e400"
      - value: 50
        color: "#ffff00"
      - value: 100
        color: "#ff0000"
      - value: 200
        color: "#8f3f97"
      - value: 300
        color: "#7e0023"
    show:
      labels: false
      fill: fade
      name: false
      state: true
      icon: false
    min_bound_range: 20
    line_width: 4
    hours_to_show: 24
    points_per_hour: 1
    card_mod:
      style: |
        div.states {
          display: none !important;
          position: absolute;
          bottom: 0;
          left: 0;
        }
        ha-card:hover div.states {
          display:block !important;
        }
  - type: markdown
    content: >
      {%- set category_entity = 'sensor.outdoor_air_quality_daily_category' -%}
      {%- set time_entity = 'sensor.outdoor_air_quality_daily_data_expected' -%}
      
      {% if not has_value(category_entity) %}
        # AQI: Calculating
        {% set target = states(time_entity) | as_datetime %}
        {% set mins = ((target - now()).total_seconds() / 60) | int if target is not none else 0 %}
        Available in {% if mins > 60 %}{{ mins // 60 }} hours {% endif %}{{ mins % 60 }} minutes
      {% else %}
        # AQI: {{ state_translated(category_entity) }}
      {% endif %}
    card_mod:
      style: |
        ha-card {
          {% set aqi = states('sensor.outdoor_air_quality_daily_aqi') -%}
          {%- set aqi = aqi | float if is_number(aqi) else -1 -%}
          {%- if aqi < 0 -%}
            background-color: #a0a0a0; color: #393939;
          {%- elif aqi < 50 -%}
            background-color: #00e400; color: #393939;
          {%- elif aqi < 100 -%}
            background-color: #ffff00; color: #004000;
          {%- elif aqi < 150 -%}
            background-color: #ff7e00; color: #404000;
          {%- elif aqi < 200 -%}
            background-color: #ff0000; color: #400000;
          {%- elif aqi < 300 -%}
              background-color: #8f3f97; color: #300030;
          {%- else -%}
            background-color: #7e0023; color: #200000;
          {% endif %}
          text-align: center;
        }
        h1 {
          line-height: 1px;
          margin: 0 !important;
          padding: 0 !important;
          border: 1px solid red !important;
        }
  - type: conditional
    conditions:
      - entity: sensor.outdoor_air_quality_daily_aqi
        state_not: unknown
      - entity: sensor.outdoor_air_quality_daily_aqi
        state_not: unavailable
    card:
      type: gauge
      entity: sensor.outdoor_air_quality_daily_aqi
      needle: true
      min: 0
      max: 500
      segments:
        - from: 0
          color: "#00e400"
        - from: 50
          color: "#ffff00"
        - from: 100
          color: "#ff7e00"
        - from: 150
          color: "#ff0000"
        - from: 200
          color: "#8f3f97"
        - from: 300
          color: "#7e0023"
      name: ""
  - type: custom:mini-graph-card
    entities:
      - entity: sensor.outdoor_air_quality_daily_aqi
    color_thresholds:
      - value: 0
        color: "#00e400"
      - value: 50
        color: "#ffff00"
      - value: 100
        color: "#ff0000"
      - value: 200
        color: "#8f3f97"
      - value: 300
        color: "#7e0023"
    show:
      labels: false
      fill: fade
      name: false
      state: true
      icon: false
    min_bound_range: 20
    line_width: 4
    hours_to_show: 72
    points_per_hour: 0.125
    card_mod:
      style: |
        div.states {
          display: none !important;
          position: absolute;
          bottom: 0;
          left: 0;
        }
        ha-card:hover div.states {
          display:block !important;
        }
  - type: horizontal-stack
    cards:
      - type: custom:mini-graph-card
        entities:
          - entity: sensor.airgradient_open_air_pm_1_0_average
            name: PM1
            state_adaptive_color: true
            color: wheat
        line_width: 2
        hours_to_show: 24
        min_bound_range: 10
        show:
          labels: false
          fill: fade
          name: true
          icon: false
          name_adaptive_color: true
      - type: custom:mini-graph-card
        entities:
          - entity: sensor.airgradient_open_air_pm_2_5_average
            name: PM2.5
            state_adaptive_color: true
        color_thresholds:
          - value: 0
            color: "#00e400"
          - value: 9.1
            color: "#ffff00"
          - value: 35.5
            color: "#ff7e00"
          - value: 55.5
            color: "#ff0000"
          - value: 125.5
            color: "#8f3f97"
          - value: 225.5
            color: "#7e0023"
        line_width: 2
        hours_to_show: 24
        min_bound_range: 10
        show:
          labels: false
          fill: fade
          name: true
          icon: false
          name_adaptive_color: true
      - type: custom:mini-graph-card
        entities:
          - entity: sensor.airgradient_open_air_pm_10_0_average
            name: PM10
            state_adaptive_color: true
        color_thresholds:
          - value: 0
            color: "#00e400"
          - value: 55
            color: "#ffff00"
          - value: 155
            color: "#ff7e00"
          - value: 255
            color: "#ff0000"
          - value: 355
            color: "#8f3f97"
          - value: 425
            color: "#7e0023"
        line_width: 2
        hours_to_show: 24
        min_bound_range: 10
        show:
          labels: false
          fill: fade
          name: true
          icon: false
          name_adaptive_color: true
columns: 1