ex-nerd
(Chris)
June 14, 2026, 11:15pm
1
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:
Air Quality Metrics for Home Assistant
ex-nerd
(Chris)
June 14, 2026, 11:17pm
2
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