From 940e6194816f25401e57ec76fd33fff54f814141 Mon Sep 17 00:00:00 2001 From: Jas Strong Date: Fri, 19 Dec 2025 10:42:11 -0800 Subject: [PATCH] [aqi, hm3301, pmsx003] Air Quality Index improvements (#12203) Co-authored-by: jas Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/aqi/__init__.py | 14 ++++++++ .../{hm3301 => aqi}/abstract_aqi_calculator.h | 6 ++-- .../{hm3301 => aqi}/aqi_calculator.h | 11 +++--- .../{hm3301 => aqi}/aqi_calculator_factory.h | 14 ++++---- .../{hm3301 => aqi}/caqi_calculator.h | 6 ++-- esphome/components/hm3301/hm3301.cpp | 2 +- esphome/components/hm3301/hm3301.h | 8 ++--- esphome/components/hm3301/sensor.py | 10 ++---- esphome/components/pmsx003/pmsx003.cpp | 34 ++++++++++++++++--- esphome/components/pmsx003/pmsx003.h | 12 +++++++ esphome/components/pmsx003/sensor.py | 26 ++++++++++++++ tests/components/pmsx003/common.yaml | 3 ++ 13 files changed, 109 insertions(+), 38 deletions(-) create mode 100644 esphome/components/aqi/__init__.py rename esphome/components/{hm3301 => aqi}/abstract_aqi_calculator.h (64%) rename esphome/components/{hm3301 => aqi}/aqi_calculator.h (94%) rename esphome/components/{hm3301 => aqi}/aqi_calculator_factory.h (55%) rename esphome/components/{hm3301 => aqi}/caqi_calculator.h (94%) diff --git a/CODEOWNERS b/CODEOWNERS index fc27253d23..21be3e36d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -42,6 +42,7 @@ esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apds9306/* @aodrenah esphome/components/api/* @esphome/core +esphome/components/aqi/* @freekode @jasstrong @ximex esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py new file mode 100644 index 0000000000..4b979ab406 --- /dev/null +++ b/esphome/components/aqi/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"] + +aqi_ns = cg.esphome_ns.namespace("aqi") +AQICalculatorType = aqi_ns.enum("AQICalculatorType") + +CONF_AQI = "aqi" +CONF_CALCULATION_TYPE = "calculation_type" + +AQI_CALCULATION_TYPE = { + "CAQI": AQICalculatorType.CAQI_TYPE, + "AQI": AQICalculatorType.AQI_TYPE, +} diff --git a/esphome/components/hm3301/abstract_aqi_calculator.h b/esphome/components/aqi/abstract_aqi_calculator.h similarity index 64% rename from esphome/components/hm3301/abstract_aqi_calculator.h rename to esphome/components/aqi/abstract_aqi_calculator.h index 038828e9de..7836c76cdc 100644 --- a/esphome/components/hm3301/abstract_aqi_calculator.h +++ b/esphome/components/aqi/abstract_aqi_calculator.h @@ -2,13 +2,11 @@ #include -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class AbstractAQICalculator { public: virtual uint16_t get_aqi(uint16_t pm2_5_value, uint16_t pm10_0_value) = 0; }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h similarity index 94% rename from esphome/components/hm3301/aqi_calculator.h rename to esphome/components/aqi/aqi_calculator.h index aa01060d2c..959d6a2438 100644 --- a/esphome/components/hm3301/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -1,10 +1,11 @@ #pragma once + #include #include "abstract_aqi_calculator.h" + // https://document.airnow.gov/technical-assistance-document-for-the-reporting-of-daily-air-quailty.pdf -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class AQICalculator : public AbstractAQICalculator { public: @@ -28,6 +29,9 @@ class AQICalculator : public AbstractAQICalculator { int calculate_index_(uint16_t value, int array[AMOUNT_OF_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 conc_lo = array[grid_index][0]; @@ -46,5 +50,4 @@ class AQICalculator : public AbstractAQICalculator { } }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/aqi_calculator_factory.h b/esphome/components/aqi/aqi_calculator_factory.h similarity index 55% rename from esphome/components/hm3301/aqi_calculator_factory.h rename to esphome/components/aqi/aqi_calculator_factory.h index 55608b6e51..db7eaab1bb 100644 --- a/esphome/components/hm3301/aqi_calculator_factory.h +++ b/esphome/components/aqi/aqi_calculator_factory.h @@ -3,8 +3,7 @@ #include "caqi_calculator.h" #include "aqi_calculator.h" -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { enum AQICalculatorType { CAQI_TYPE = 0, AQI_TYPE = 1 }; @@ -12,18 +11,17 @@ class AQICalculatorFactory { public: AbstractAQICalculator *get_calculator(AQICalculatorType type) { if (type == 0) { - return caqi_calculator_; + return &this->caqi_calculator_; } else if (type == 1) { - return aqi_calculator_; + return &this->aqi_calculator_; } return nullptr; } protected: - CAQICalculator *caqi_calculator_ = new CAQICalculator(); - AQICalculator *aqi_calculator_ = new AQICalculator(); + CAQICalculator caqi_calculator_; + AQICalculator aqi_calculator_; }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h similarity index 94% rename from esphome/components/hm3301/caqi_calculator.h rename to esphome/components/aqi/caqi_calculator.h index 3f338776d8..d493dcdf39 100644 --- a/esphome/components/hm3301/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "abstract_aqi_calculator.h" -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class CAQICalculator : public AbstractAQICalculator { public: @@ -48,5 +47,4 @@ class CAQICalculator : public AbstractAQICalculator { } }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index a19d9dd09f..00fb85397c 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -63,7 +63,7 @@ void HM3301Component::update() { int16_t aqi_value = -1; if (this->aqi_sensor_ != nullptr && pm_2_5_value != -1 && pm_10_0_value != -1) { - AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); + aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value); } diff --git a/esphome/components/hm3301/hm3301.h b/esphome/components/hm3301/hm3301.h index 6779b4e195..e155ed6b4b 100644 --- a/esphome/components/hm3301/hm3301.h +++ b/esphome/components/hm3301/hm3301.h @@ -3,7 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -#include "aqi_calculator_factory.h" +#include "esphome/components/aqi/aqi_calculator_factory.h" namespace esphome { namespace hm3301 { @@ -19,7 +19,7 @@ class HM3301Component : public PollingComponent, public i2c::I2CDevice { void set_pm_10_0_sensor(sensor::Sensor *pm_10_0_sensor) { pm_10_0_sensor_ = pm_10_0_sensor; } void set_aqi_sensor(sensor::Sensor *aqi_sensor) { aqi_sensor_ = aqi_sensor; } - void set_aqi_calculation_type(AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; } + void set_aqi_calculation_type(aqi::AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; } void setup() override; void dump_config() override; @@ -41,8 +41,8 @@ class HM3301Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *pm_10_0_sensor_{nullptr}; sensor::Sensor *aqi_sensor_{nullptr}; - AQICalculatorType aqi_calc_type_; - AQICalculatorFactory aqi_calculator_factory_ = AQICalculatorFactory(); + aqi::AQICalculatorType aqi_calc_type_; + aqi::AQICalculatorFactory aqi_calculator_factory_ = aqi::AQICalculatorFactory(); bool validate_checksum_(const uint8_t *data); uint16_t get_sensor_value_(const uint8_t *data, uint8_t i); diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 5eb1773518..389da97b1e 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -16,23 +17,16 @@ from esphome.const import ( ) DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["aqi"] CODEOWNERS = ["@freekode"] hm3301_ns = cg.esphome_ns.namespace("hm3301") HM3301Component = hm3301_ns.class_( "HM3301Component", cg.PollingComponent, i2c.I2CDevice ) -AQICalculatorType = hm3301_ns.enum("AQICalculatorType") -CONF_AQI = "aqi" -CONF_CALCULATION_TYPE = "calculation_type" UNIT_INDEX = "index" -AQI_CALCULATION_TYPE = { - "CAQI": AQICalculatorType.CAQI_TYPE, - "AQI": AQICalculatorType.AQI_TYPE, -} - def _validate(config): if CONF_AQI in config and CONF_PM_2_5 not in config: diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index eb10d19c91..3bdb5219ed 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -18,6 +18,8 @@ static const uint16_t PMS_CMD_MEASUREMENT_MODE_ACTIVE = 0x0001; // automaticall static const uint16_t PMS_CMD_SLEEP_MODE_SLEEP = 0x0000; // go to sleep mode static const uint16_t PMS_CMD_SLEEP_MODE_WAKEUP = 0x0001; // wake up from sleep mode +void PMSX003Component::setup() {} + void PMSX003Component::dump_config() { ESP_LOGCONFIG(TAG, "PMSX003:"); LOG_SENSOR(" ", "PM1.0STD", this->pm_1_0_std_sensor_); @@ -39,21 +41,36 @@ void PMSX003Component::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + + if (this->update_interval_ <= PMS_STABILISING_MS) { + ESP_LOGCONFIG(TAG, " Mode: active continuous (sensor default)"); + } else { + ESP_LOGCONFIG(TAG, " Mode: passive with sleep/wake cycles"); + } + this->check_uart_settings(9600); } void PMSX003Component::loop() { const uint32_t now = App.get_loop_component_start_time(); + // Initialize sensor mode on first loop + if (this->initialised_ == 0) { + if (this->update_interval_ > PMS_STABILISING_MS) { + // Long update interval: use passive mode with sleep/wake cycles + this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_PASSIVE); + this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP); + } else { + // Short/zero update interval: use active continuous mode + this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_ACTIVE); + } + this->initialised_ = 1; + } + // If we update less often than it takes the device to stabilise, spin the fan down // rather than running it constantly. It does take some time to stabilise, so we // need to keep track of what state we're in. if (this->update_interval_ > PMS_STABILISING_MS) { - if (this->initialised_ == 0) { - this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_PASSIVE); - this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP); - this->initialised_ = 1; - } switch (this->state_) { case PMSX003_STATE_IDLE: // Power on the sensor now so it'll be ready when we hit the update time @@ -248,6 +265,13 @@ 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, " diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index ba607b4487..229972e2e5 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -4,6 +4,7 @@ #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 { @@ -31,6 +32,7 @@ enum PMSX003State { class PMSX003Component : public uart::UARTDevice, public Component { public: PMSX003Component() = default; + void setup() override; void dump_config() override; void loop() override; @@ -72,6 +74,10 @@ 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 check_byte_(); void parse_data_(); @@ -115,6 +121,12 @@ 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 diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index bebd3a01ee..b2d6744547 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -1,5 +1,6 @@ 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, @@ -20,6 +21,7 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, CONF_UPDATE_INTERVAL, + DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, @@ -35,11 +37,13 @@ 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" @@ -77,6 +81,10 @@ 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 @@ -192,6 +200,19 @@ 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, } ) @@ -278,4 +299,9 @@ 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])) diff --git a/tests/components/pmsx003/common.yaml b/tests/components/pmsx003/common.yaml index 3c60995804..9dd79723d1 100644 --- a/tests/components/pmsx003/common.yaml +++ b/tests/components/pmsx003/common.yaml @@ -25,4 +25,7 @@ sensor: name: Particulate Count >5.0um pm_10_0um: name: Particulate Count >10.0um + aqi: + name: AQI + calculation_type: AQI update_interval: 30s