EDIT 06/2024: The US AQI thresholds have been updated and you should use the updated script, below
I live in Northern Californa, and for this fire season I recently got a couple PMS7003 particulate sensor from China, along with a couple of ESP32 dev boards. This sensor has a laser diode that can measure the particulate concentration at pm2.5 (particles > 2.5µm) and pm10 (particles > 10µm), and ESPHome has a config that knows how to read the data and send it to HA via the ESPHome integration.
However, as far as I can tell HA doesn’t have any sort of filter to convert the concentration readings to AQI, which is a farily simple but non-linear calculation. So here’s the template I whipped up, based on The AQI Equation (2015 - Obsolete on May 6th, 2024) - Air Quality and AQI Info - AirNow Discussion Forum
templates:
- sensor:
- name: "Family Room AQI (pm2.5)"
state: >-
{% macro aqi(val, val_l, val_h, aqi_l, aqi_h) -%}
{{(((aqi_h-aqi_l)/(val_h - val_l) * (val - val_l)) + aqi_l)|round(0)}}
{%- endmacro %}
{% set v = states('sensor.family_room_pm2_5')|round(1) %}
{% if v <= 12.0 %}
{{aqi(v, 0, 12.0, 0, 50)}}
{% elif 12.0 < v <= 35.4 %}
{{aqi(v, 12.1, 35.4, 51, 100)}}
{% elif 35.4 < v <= 55.4 %}
{{aqi(v, 35.5, 55.4, 101, 150)}}
{% elif 55.4 < v <= 150.5 %}
{{aqi(v, 55.5, 150.4, 151, 200)}}
{% elif 150.4 < v <= 250.4 %}
{{aqi(v, 150.4, 250.4, 201, 300)}}
{% elif 250.5 < v <= 500.4 %}
{{aqi(v, 250.5, 500.4, 301, 500)}}
{% else %}
Holy shit it's bad. Get out!
{% endif %}
unit_of_measurement: AQI
device_class: aqi
Relatedly, here’s a fragment of the ESPHome config I’m using for the sensor. It takes readings every second, which is way too much data. This chops that down to every minute, I could really probably get away with every 5 or 10 minutes and be just fine:
sensor:
- platform: pmsx003
type: PMSX003
pm_2_5:
name: "pm2.5"
unit_of_measurement: "µg/m³"
icon: "mdi:face-mask-outline"
state_class: "measurement"
filters:
- sliding_window_moving_average:
window_size: 60
send_every: 60
pm_10_0:
name: "pm10"
unit_of_measurement: "µg/m³"
icon: "mdi:face-mask-outline"
state_class: "measurement"
filters:
- sliding_window_moving_average:
window_size: 60
send_every: 60