[aqi] Implement a sensor that computes AQI (#12958)

Co-authored-by: jas <jas@asspa.in>
This commit is contained in:
Jas Strong
2026-01-06 13:31:50 -08:00
committed by GitHub
parent 4419bf02b1
commit 412ab5dbbf
14 changed files with 193 additions and 74 deletions

View File

@@ -10,38 +10,37 @@ namespace esphome::aqi {
class AQICalculator : public AbstractAQICalculator {
public:
uint16_t get_aqi(uint16_t pm2_5_value, uint16_t pm10_0_value) override {
int pm2_5_index = calculate_index_(pm2_5_value, pm2_5_calculation_grid_);
int pm10_0_index = calculate_index_(pm10_0_value, pm10_0_calculation_grid_);
int pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
int pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
return (pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index;
}
protected:
static const int AMOUNT_OF_LEVELS = 6;
static constexpr int NUM_LEVELS = 6;
int index_grid_[AMOUNT_OF_LEVELS][2] = {{0, 50}, {51, 100}, {101, 150}, {151, 200}, {201, 300}, {301, 500}};
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 50}, {51, 100}, {101, 150}, {151, 200}, {201, 300}, {301, 500}};
int pm2_5_calculation_grid_[AMOUNT_OF_LEVELS][2] = {{0, 9}, {10, 35}, {36, 55},
{56, 125}, {126, 225}, {226, INT_MAX}};
static constexpr int PM2_5_GRID[NUM_LEVELS][2] = {{0, 9}, {10, 35}, {36, 55}, {56, 125}, {126, 225}, {226, INT_MAX}};
int pm10_0_calculation_grid_[AMOUNT_OF_LEVELS][2] = {{0, 54}, {55, 154}, {155, 254},
{255, 354}, {355, 424}, {425, INT_MAX}};
static constexpr int PM10_0_GRID[NUM_LEVELS][2] = {{0, 54}, {55, 154}, {155, 254},
{255, 354}, {355, 424}, {425, INT_MAX}};
int calculate_index_(uint16_t value, int array[AMOUNT_OF_LEVELS][2]) {
int grid_index = get_grid_index_(value, array);
static int calculate_index(uint16_t value, const int array[NUM_LEVELS][2]) {
int grid_index = get_grid_index(value, array);
if (grid_index == -1) {
return -1;
}
int aqi_lo = index_grid_[grid_index][0];
int aqi_hi = index_grid_[grid_index][1];
int aqi_lo = INDEX_GRID[grid_index][0];
int aqi_hi = INDEX_GRID[grid_index][1];
int conc_lo = array[grid_index][0];
int conc_hi = array[grid_index][1];
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
}
int get_grid_index_(uint16_t value, int array[AMOUNT_OF_LEVELS][2]) {
for (int i = 0; i < AMOUNT_OF_LEVELS; i++) {
static int get_grid_index(uint16_t value, const int array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
if (value >= array[i][0] && value <= array[i][1]) {
return i;
}

View File

@@ -0,0 +1,52 @@
#include "aqi_sensor.h"
#include "esphome/core/log.h"
namespace esphome::aqi {
static const char *const TAG = "aqi";
void AQISensor::setup() {
if (this->pm_2_5_sensor_ != nullptr) {
this->pm_2_5_sensor_->add_on_state_callback([this](float value) {
this->pm_2_5_value_ = value;
// Defer calculation to avoid double-publishing if both sensors update in the same loop
this->defer("update", [this]() { this->calculate_aqi_(); });
});
}
if (this->pm_10_0_sensor_ != nullptr) {
this->pm_10_0_sensor_->add_on_state_callback([this](float value) {
this->pm_10_0_value_ = value;
this->defer("update", [this]() { this->calculate_aqi_(); });
});
}
}
void AQISensor::dump_config() {
ESP_LOGCONFIG(TAG, "AQI Sensor:");
ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI");
if (this->pm_2_5_sensor_ != nullptr) {
ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str());
}
if (this->pm_10_0_sensor_ != nullptr) {
ESP_LOGCONFIG(TAG, " PM10 Sensor: '%s'", this->pm_10_0_sensor_->get_name().c_str());
}
LOG_SENSOR(" ", "AQI", this);
}
void AQISensor::calculate_aqi_() {
if (std::isnan(this->pm_2_5_value_) || std::isnan(this->pm_10_0_value_)) {
return;
}
AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_);
if (calculator == nullptr) {
ESP_LOGW(TAG, "Unknown AQI calculator type");
return;
}
uint16_t aqi =
calculator->get_aqi(static_cast<uint16_t>(this->pm_2_5_value_), static_cast<uint16_t>(this->pm_10_0_value_));
this->publish_state(aqi);
}
} // namespace esphome::aqi

View File

@@ -0,0 +1,31 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "aqi_calculator_factory.h"
namespace esphome::aqi {
class AQISensor : public sensor::Sensor, public Component {
public:
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::DATA; }
void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; }
void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; }
void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; }
protected:
void calculate_aqi_();
sensor::Sensor *pm_2_5_sensor_{nullptr};
sensor::Sensor *pm_10_0_sensor_{nullptr};
AQICalculatorType aqi_calc_type_{AQI_TYPE};
AQICalculatorFactory aqi_calculator_factory_;
float pm_2_5_value_{NAN};
float pm_10_0_value_{NAN};
};
} // namespace esphome::aqi

View File

@@ -1,6 +1,5 @@
#pragma once
#include "esphome/core/log.h"
#include "abstract_aqi_calculator.h"
namespace esphome::aqi {
@@ -8,37 +7,37 @@ namespace esphome::aqi {
class CAQICalculator : public AbstractAQICalculator {
public:
uint16_t get_aqi(uint16_t pm2_5_value, uint16_t pm10_0_value) override {
int pm2_5_index = calculate_index_(pm2_5_value, pm2_5_calculation_grid_);
int pm10_0_index = calculate_index_(pm10_0_value, pm10_0_calculation_grid_);
int pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
int pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
return (pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index;
}
protected:
static const int AMOUNT_OF_LEVELS = 5;
static constexpr int NUM_LEVELS = 5;
int index_grid_[AMOUNT_OF_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
int pm2_5_calculation_grid_[AMOUNT_OF_LEVELS][2] = {{0, 15}, {16, 30}, {31, 55}, {56, 110}, {111, 400}};
static constexpr int PM2_5_GRID[NUM_LEVELS][2] = {{0, 15}, {16, 30}, {31, 55}, {56, 110}, {111, 400}};
int pm10_0_calculation_grid_[AMOUNT_OF_LEVELS][2] = {{0, 25}, {26, 50}, {51, 90}, {91, 180}, {181, 400}};
static constexpr int PM10_0_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 90}, {91, 180}, {181, 400}};
int calculate_index_(uint16_t value, int array[AMOUNT_OF_LEVELS][2]) {
int grid_index = get_grid_index_(value, array);
static int calculate_index(uint16_t value, const int array[NUM_LEVELS][2]) {
int grid_index = get_grid_index(value, array);
if (grid_index == -1) {
return -1;
}
int aqi_lo = index_grid_[grid_index][0];
int aqi_hi = index_grid_[grid_index][1];
int aqi_lo = INDEX_GRID[grid_index][0];
int aqi_hi = INDEX_GRID[grid_index][1];
int conc_lo = array[grid_index][0];
int conc_hi = array[grid_index][1];
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
}
int get_grid_index_(uint16_t value, int array[AMOUNT_OF_LEVELS][2]) {
for (int i = 0; i < AMOUNT_OF_LEVELS; i++) {
static int get_grid_index(uint16_t value, const int array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
if (value >= array[i][0] && value <= array[i][1]) {
return i;
}

View File

@@ -0,0 +1,51 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_PM_2_5,
CONF_PM_10_0,
DEVICE_CLASS_AQI,
STATE_CLASS_MEASUREMENT,
)
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns
CODEOWNERS = ["@jasstrong"]
DEPENDENCIES = ["sensor"]
UNIT_INDEX = "index"
AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(
AQISensor,
unit_of_measurement=UNIT_INDEX,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
)
.extend(
{
cv.Required(CONF_PM_2_5): cv.use_id(sensor.Sensor),
cv.Required(CONF_PM_10_0): cv.use_id(sensor.Sensor),
cv.Required(CONF_CALCULATION_TYPE): cv.enum(
AQI_CALCULATION_TYPE, upper=True
),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
pm_2_5_sensor = await cg.get_variable(config[CONF_PM_2_5])
cg.add(var.set_pm_2_5_sensor(pm_2_5_sensor))
pm_10_0_sensor = await cg.get_variable(config[CONF_PM_10_0])
cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor))
cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE]))

View File

@@ -1,3 +1,5 @@
import logging
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE
@@ -16,6 +18,8 @@ from esphome.const import (
UNIT_MICROGRAMS_PER_CUBIC_METER,
)
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["aqi"]
CODEOWNERS = ["@freekode"]
@@ -99,7 +103,12 @@ async def to_code(config):
sens = await sensor.new_sensor(config[CONF_PM_10_0])
cg.add(var.set_pm_10_0_sensor(sens))
# Remove before 2026.12.0
if CONF_AQI in config:
_LOGGER.warning(
"The 'aqi' option in hm3301 is deprecated, "
"please use the standalone 'aqi' sensor platform instead."
)
sens = await sensor.new_sensor(config[CONF_AQI])
cg.add(var.set_aqi_sensor(sens))
cg.add(var.set_aqi_calculation_type(config[CONF_AQI][CONF_CALCULATION_TYPE]))

View File

@@ -265,13 +265,6 @@ void PMSX003Component::parse_data_() {
if (this->pm_particles_25um_sensor_ != nullptr)
this->pm_particles_25um_sensor_->publish_state(pm_particles_25um);
// Calculate and publish AQI if sensor is configured
if (this->aqi_sensor_ != nullptr) {
aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_);
int32_t aqi_value = calculator->get_aqi(pm_2_5_concentration, pm_10_0_concentration);
this->aqi_sensor_->publish_state(aqi_value);
}
if (this->type_ == PMSX003_TYPE_5003T) {
ESP_LOGD(TAG,
"Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, "

View File

@@ -4,7 +4,6 @@
#include "esphome/core/helpers.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include "esphome/components/aqi/aqi_calculator_factory.h"
namespace esphome {
namespace pmsx003 {
@@ -74,10 +73,6 @@ class PMSX003Component : public uart::UARTDevice, public Component {
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }
void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
void set_aqi_sensor(sensor::Sensor *aqi_sensor) { aqi_sensor_ = aqi_sensor; }
void set_aqi_calculation_type(aqi::AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; }
protected:
optional<bool> check_byte_();
void parse_data_();
@@ -121,12 +116,6 @@ class PMSX003Component : public uart::UARTDevice, public Component {
// Temperature and Humidity
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
// AQI
sensor::Sensor *aqi_sensor_{nullptr};
aqi::AQICalculatorType aqi_calc_type_;
aqi::AQICalculatorFactory aqi_calculator_factory_ = aqi::AQICalculatorFactory();
};
} // namespace pmsx003

View File

@@ -1,6 +1,5 @@
import esphome.codegen as cg
from esphome.components import sensor, uart
from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE
import esphome.config_validation as cv
from esphome.const import (
CONF_FORMALDEHYDE,
@@ -21,7 +20,6 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
CONF_UPDATE_INTERVAL,
DEVICE_CLASS_AQI,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
@@ -37,13 +35,11 @@ from esphome.const import (
CODEOWNERS = ["@ximex"]
DEPENDENCIES = ["uart"]
AUTO_LOAD = ["aqi"]
pmsx003_ns = cg.esphome_ns.namespace("pmsx003")
PMSX003Component = pmsx003_ns.class_("PMSX003Component", uart.UARTDevice, cg.Component)
PMSX003Sensor = pmsx003_ns.class_("PMSX003Sensor", sensor.Sensor)
UNIT_INDEX = "index"
TYPE_PMSX003 = "PMSX003"
TYPE_PMS5003T = "PMS5003T"
TYPE_PMS5003ST = "PMS5003ST"
@@ -81,10 +77,6 @@ def validate_pmsx003_sensors(value):
for key, types in SENSORS_TO_TYPE.items():
if key in value and value[CONF_TYPE] not in types:
raise cv.Invalid(f"{value[CONF_TYPE]} does not have {key} sensor!")
if CONF_AQI in value and CONF_PM_2_5 not in value:
raise cv.Invalid("AQI computation requires PM 2.5 sensor")
if CONF_AQI in value and CONF_PM_10_0 not in value:
raise cv.Invalid("AQI computation requires PM 10 sensor")
return value
@@ -200,19 +192,6 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_AQI): sensor.sensor_schema(
unit_of_measurement=UNIT_INDEX,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(
{
cv.Required(CONF_CALCULATION_TYPE): cv.enum(
AQI_CALCULATION_TYPE, upper=True
),
}
),
cv.Optional(CONF_UPDATE_INTERVAL, default="0s"): validate_update_interval,
}
)
@@ -299,9 +278,4 @@ async def to_code(config):
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
cg.add(var.set_humidity_sensor(sens))
if CONF_AQI in config:
sens = await sensor.new_sensor(config[CONF_AQI])
cg.add(var.set_aqi_sensor(sens))
cg.add(var.set_aqi_calculation_type(config[CONF_AQI][CONF_CALCULATION_TYPE]))
cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL]))

View File

@@ -0,0 +1,22 @@
sensor:
- platform: template
id: pm25_sensor
name: "PM2.5"
lambda: "return 25.0;"
- platform: template
id: pm10_sensor
name: "PM10"
lambda: "return 50.0;"
- platform: aqi
name: "Air Quality Index (AQI)"
pm_2_5: pm25_sensor
pm_10_0: pm10_sensor
calculation_type: AQI
- platform: aqi
name: "Air Quality Index (CAQI)"
pm_2_5: pm25_sensor
pm_10_0: pm10_sensor
calculation_type: CAQI

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -25,7 +25,4 @@ sensor:
name: Particulate Count >5.0um
pm_10_0um:
name: Particulate Count >10.0um
aqi:
name: AQI
calculation_type: AQI
update_interval: 30s