From 4f9241be792d4ce0d34079f40dc203219f61a828 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Mar 2026 10:35:41 +0100 Subject: [PATCH] Add air quality conditions (#166407) --- .../components/air_quality/condition.py | 150 +++++ .../components/air_quality/conditions.yaml | 588 ++++++++++++++++++ .../components/air_quality/icons.json | 59 ++ .../components/air_quality/strings.json | 338 ++++++++++ .../components/automation/__init__.py | 1 + .../components/air_quality/test_condition.py | 577 +++++++++++++++++ 6 files changed, 1713 insertions(+) create mode 100644 homeassistant/components/air_quality/condition.py create mode 100644 homeassistant/components/air_quality/conditions.yaml create mode 100644 tests/components/air_quality/test_condition.py diff --git a/homeassistant/components/air_quality/condition.py b/homeassistant/components/air_quality/condition.py new file mode 100644 index 00000000000..0e11ce9016b --- /dev/null +++ b/homeassistant/components/air_quality/condition.py @@ -0,0 +1,150 @@ +"""Provides conditions for air quality.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.condition import ( + Condition, + make_entity_numerical_condition, + make_entity_numerical_condition_with_unit, + make_entity_state_condition, +) +from homeassistant.util.unit_conversion import ( + CarbonMonoxideConcentrationConverter, + MassVolumeConcentrationConverter, + NitrogenDioxideConcentrationConverter, + NitrogenMonoxideConcentrationConverter, + OzoneConcentrationConverter, + SulphurDioxideConcentrationConverter, + UnitlessRatioConverter, +) + + +def _make_detected_condition( + device_class: BinarySensorDeviceClass, +) -> type[Condition]: + """Create a detected condition for a binary sensor device class.""" + return make_entity_state_condition( + {BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON + ) + + +def _make_cleared_condition( + device_class: BinarySensorDeviceClass, +) -> type[Condition]: + """Create a cleared condition for a binary sensor device class.""" + return make_entity_state_condition( + {BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF + ) + + +CONDITIONS: dict[str, type[Condition]] = { + # Binary sensor conditions (detected/cleared) + "is_gas_detected": _make_detected_condition(BinarySensorDeviceClass.GAS), + "is_gas_cleared": _make_cleared_condition(BinarySensorDeviceClass.GAS), + "is_co_detected": _make_detected_condition(BinarySensorDeviceClass.CO), + "is_co_cleared": _make_cleared_condition(BinarySensorDeviceClass.CO), + "is_smoke_detected": _make_detected_condition(BinarySensorDeviceClass.SMOKE), + "is_smoke_cleared": _make_cleared_condition(BinarySensorDeviceClass.SMOKE), + # Numerical sensor conditions with unit conversion + "is_co_value": make_entity_numerical_condition_with_unit( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CarbonMonoxideConcentrationConverter, + ), + "is_ozone_value": make_entity_numerical_condition_with_unit( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + OzoneConcentrationConverter, + ), + "is_voc_value": make_entity_numerical_condition_with_unit( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + MassVolumeConcentrationConverter, + ), + "is_voc_ratio_value": make_entity_numerical_condition_with_unit( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS + ) + }, + CONCENTRATION_PARTS_PER_BILLION, + UnitlessRatioConverter, + ), + "is_no_value": make_entity_numerical_condition_with_unit( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROGEN_MONOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + NitrogenMonoxideConcentrationConverter, + ), + "is_no2_value": make_entity_numerical_condition_with_unit( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROGEN_DIOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + NitrogenDioxideConcentrationConverter, + ), + "is_so2_value": make_entity_numerical_condition_with_unit( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.SULPHUR_DIOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SulphurDioxideConcentrationConverter, + ), + # Numerical sensor conditions without unit conversion (single-unit device classes) + "is_co2_value": make_entity_numerical_condition( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + valid_unit=CONCENTRATION_PARTS_PER_MILLION, + ), + "is_pm1_value": make_entity_numerical_condition( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "is_pm25_value": make_entity_numerical_condition( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "is_pm4_value": make_entity_numerical_condition( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "is_pm10_value": make_entity_numerical_condition( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "is_n2o_value": make_entity_numerical_condition( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROUS_OXIDE + ) + }, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the air quality conditions.""" + return CONDITIONS diff --git a/homeassistant/components/air_quality/conditions.yaml b/homeassistant/components/air_quality/conditions.yaml new file mode 100644 index 00000000000..d2589bb612a --- /dev/null +++ b/homeassistant/components/air_quality/conditions.yaml @@ -0,0 +1,588 @@ +# --- Common condition fields --- + +.condition_behavior: &condition_behavior + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +# --- Number or entity selectors --- + +.number_or_entity_co: &number_or_entity_co + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "ppb" + - "ppm" + - "mg/m³" + - "μg/m³" + - domain: sensor + device_class: carbon_monoxide + - domain: number + device_class: carbon_monoxide + translation_key: number_or_entity + +.number_or_entity_co2: &number_or_entity_co2 + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + unit_of_measurement: "ppm" + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: "ppm" + - domain: sensor + device_class: carbon_dioxide + - domain: number + device_class: carbon_dioxide + translation_key: number_or_entity + +.number_or_entity_pm1: &number_or_entity_pm1 + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + unit_of_measurement: "μg/m³" + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm1 + - domain: number + device_class: pm1 + translation_key: number_or_entity + +.number_or_entity_pm25: &number_or_entity_pm25 + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + unit_of_measurement: "μg/m³" + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm25 + - domain: number + device_class: pm25 + translation_key: number_or_entity + +.number_or_entity_pm4: &number_or_entity_pm4 + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + unit_of_measurement: "μg/m³" + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm4 + - domain: number + device_class: pm4 + translation_key: number_or_entity + +.number_or_entity_pm10: &number_or_entity_pm10 + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + unit_of_measurement: "μg/m³" + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm10 + - domain: number + device_class: pm10 + translation_key: number_or_entity + +.number_or_entity_ozone: &number_or_entity_ozone + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "ppb" + - "ppm" + - "μg/m³" + - domain: sensor + device_class: ozone + - domain: number + device_class: ozone + translation_key: number_or_entity + +.number_or_entity_voc: &number_or_entity_voc + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "μg/m³" + - "mg/m³" + - domain: sensor + device_class: volatile_organic_compounds + - domain: number + device_class: volatile_organic_compounds + translation_key: number_or_entity + +.number_or_entity_voc_ratio: &number_or_entity_voc_ratio + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "ppb" + - "ppm" + - domain: sensor + device_class: volatile_organic_compounds_parts + - domain: number + device_class: volatile_organic_compounds_parts + translation_key: number_or_entity + +.number_or_entity_no: &number_or_entity_no + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "ppb" + - "μg/m³" + - domain: sensor + device_class: nitrogen_monoxide + - domain: number + device_class: nitrogen_monoxide + translation_key: number_or_entity + +.number_or_entity_no2: &number_or_entity_no2 + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "ppb" + - "ppm" + - "μg/m³" + - domain: sensor + device_class: nitrogen_dioxide + - domain: number + device_class: nitrogen_dioxide + translation_key: number_or_entity + +.number_or_entity_n2o: &number_or_entity_n2o + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + unit_of_measurement: "μg/m³" + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: nitrous_oxide + - domain: number + device_class: nitrous_oxide + translation_key: number_or_entity + +.number_or_entity_so2: &number_or_entity_so2 + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "ppb" + - "μg/m³" + - domain: sensor + device_class: sulphur_dioxide + - domain: number + device_class: sulphur_dioxide + translation_key: number_or_entity + +# --- Unit selectors --- + +.unit_co: &unit_co + required: false + selector: + select: + options: + - "ppb" + - "ppm" + - "mg/m³" + - "μg/m³" + +.unit_ozone: &unit_ozone + required: false + selector: + select: + options: + - "ppb" + - "ppm" + - "μg/m³" + +.unit_no2: &unit_no2 + required: false + selector: + select: + options: + - "ppb" + - "ppm" + - "μg/m³" + +.unit_no: &unit_no + required: false + selector: + select: + options: + - "ppb" + - "μg/m³" + +.unit_so2: &unit_so2 + required: false + selector: + select: + options: + - "ppb" + - "μg/m³" + +.unit_voc: &unit_voc + required: false + selector: + select: + options: + - "μg/m³" + - "mg/m³" + +.unit_voc_ratio: &unit_voc_ratio + required: false + selector: + select: + options: + - "ppb" + - "ppm" + +# --- Binary sensor targets --- + +.target_gas: &target_gas + entity: + - domain: binary_sensor + device_class: gas + +.target_co_binary: &target_co_binary + entity: + - domain: binary_sensor + device_class: carbon_monoxide + +.target_smoke: &target_smoke + entity: + - domain: binary_sensor + device_class: smoke + +# --- Sensor targets --- + +.target_co_sensor: &target_co_sensor + entity: + - domain: sensor + device_class: carbon_monoxide + +.target_co2: &target_co2 + entity: + - domain: sensor + device_class: carbon_dioxide + +.target_pm1: &target_pm1 + entity: + - domain: sensor + device_class: pm1 + +.target_pm25: &target_pm25 + entity: + - domain: sensor + device_class: pm25 + +.target_pm4: &target_pm4 + entity: + - domain: sensor + device_class: pm4 + +.target_pm10: &target_pm10 + entity: + - domain: sensor + device_class: pm10 + +.target_ozone: &target_ozone + entity: + - domain: sensor + device_class: ozone + +.target_voc: &target_voc + entity: + - domain: sensor + device_class: volatile_organic_compounds + +.target_voc_ratio: &target_voc_ratio + entity: + - domain: sensor + device_class: volatile_organic_compounds_parts + +.target_no: &target_no + entity: + - domain: sensor + device_class: nitrogen_monoxide + +.target_no2: &target_no2 + entity: + - domain: sensor + device_class: nitrogen_dioxide + +.target_n2o: &target_n2o + entity: + - domain: sensor + device_class: nitrous_oxide + +.target_so2: &target_so2 + entity: + - domain: sensor + device_class: sulphur_dioxide + +# --- Binary sensor conditions --- + +.condition_binary_common: &condition_binary_common + fields: + behavior: *condition_behavior + +is_gas_detected: + <<: *condition_binary_common + target: *target_gas + +is_gas_cleared: + <<: *condition_binary_common + target: *target_gas + +is_co_detected: + <<: *condition_binary_common + target: *target_co_binary + +is_co_cleared: + <<: *condition_binary_common + target: *target_co_binary + +is_smoke_detected: + <<: *condition_binary_common + target: *target_smoke + +is_smoke_cleared: + <<: *condition_binary_common + target: *target_smoke + +# --- Numerical sensor conditions with unit conversion --- + +is_co_value: + target: *target_co_sensor + fields: + behavior: *condition_behavior + above: *number_or_entity_co + below: *number_or_entity_co + unit: *unit_co + +is_ozone_value: + target: *target_ozone + fields: + behavior: *condition_behavior + above: *number_or_entity_ozone + below: *number_or_entity_ozone + unit: *unit_ozone + +is_voc_value: + target: *target_voc + fields: + behavior: *condition_behavior + above: *number_or_entity_voc + below: *number_or_entity_voc + unit: *unit_voc + +is_voc_ratio_value: + target: *target_voc_ratio + fields: + behavior: *condition_behavior + above: *number_or_entity_voc_ratio + below: *number_or_entity_voc_ratio + unit: *unit_voc_ratio + +is_no_value: + target: *target_no + fields: + behavior: *condition_behavior + above: *number_or_entity_no + below: *number_or_entity_no + unit: *unit_no + +is_no2_value: + target: *target_no2 + fields: + behavior: *condition_behavior + above: *number_or_entity_no2 + below: *number_or_entity_no2 + unit: *unit_no2 + +is_so2_value: + target: *target_so2 + fields: + behavior: *condition_behavior + above: *number_or_entity_so2 + below: *number_or_entity_so2 + unit: *unit_so2 + +# --- Numerical sensor conditions without unit conversion --- + +is_co2_value: + target: *target_co2 + fields: + behavior: *condition_behavior + above: *number_or_entity_co2 + below: *number_or_entity_co2 + +is_pm1_value: + target: *target_pm1 + fields: + behavior: *condition_behavior + above: *number_or_entity_pm1 + below: *number_or_entity_pm1 + +is_pm25_value: + target: *target_pm25 + fields: + behavior: *condition_behavior + above: *number_or_entity_pm25 + below: *number_or_entity_pm25 + +is_pm4_value: + target: *target_pm4 + fields: + behavior: *condition_behavior + above: *number_or_entity_pm4 + below: *number_or_entity_pm4 + +is_pm10_value: + target: *target_pm10 + fields: + behavior: *condition_behavior + above: *number_or_entity_pm10 + below: *number_or_entity_pm10 + +is_n2o_value: + target: *target_n2o + fields: + behavior: *condition_behavior + above: *number_or_entity_n2o + below: *number_or_entity_n2o diff --git a/homeassistant/components/air_quality/icons.json b/homeassistant/components/air_quality/icons.json index b64343987a0..d4b299a0de9 100644 --- a/homeassistant/components/air_quality/icons.json +++ b/homeassistant/components/air_quality/icons.json @@ -1,4 +1,63 @@ { + "conditions": { + "is_co2_value": { + "condition": "mdi:molecule-co2" + }, + "is_co_cleared": { + "condition": "mdi:check-circle" + }, + "is_co_detected": { + "condition": "mdi:molecule-co" + }, + "is_co_value": { + "condition": "mdi:molecule-co" + }, + "is_gas_cleared": { + "condition": "mdi:check-circle" + }, + "is_gas_detected": { + "condition": "mdi:gas-cylinder" + }, + "is_n2o_value": { + "condition": "mdi:factory" + }, + "is_no2_value": { + "condition": "mdi:factory" + }, + "is_no_value": { + "condition": "mdi:factory" + }, + "is_ozone_value": { + "condition": "mdi:weather-sunny-alert" + }, + "is_pm10_value": { + "condition": "mdi:blur" + }, + "is_pm1_value": { + "condition": "mdi:blur" + }, + "is_pm25_value": { + "condition": "mdi:blur" + }, + "is_pm4_value": { + "condition": "mdi:blur" + }, + "is_smoke_cleared": { + "condition": "mdi:check-circle" + }, + "is_smoke_detected": { + "condition": "mdi:smoke-detector-variant" + }, + "is_so2_value": { + "condition": "mdi:factory" + }, + "is_voc_ratio_value": { + "condition": "mdi:air-filter" + }, + "is_voc_value": { + "condition": "mdi:air-filter" + } + }, "entity_component": { "_": { "default": "mdi:air-filter" diff --git a/homeassistant/components/air_quality/strings.json b/homeassistant/components/air_quality/strings.json index 3c06dfeb42b..8d999edaec0 100644 --- a/homeassistant/components/air_quality/strings.json +++ b/homeassistant/components/air_quality/strings.json @@ -1,5 +1,13 @@ { "common": { + "condition_above_description": "Require the value to be above this value.", + "condition_above_name": "Above", + "condition_behavior_description": "How the value should match on the targeted entities.", + "condition_behavior_name": "Behavior", + "condition_below_description": "Require the value to be below this value.", + "condition_below_name": "Below", + "condition_unit_description": "All values will be converted to this unit when evaluating the condition.", + "condition_unit_name": "Unit of measurement", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_changed_above_name": "Above", @@ -13,7 +21,337 @@ "trigger_unit_description": "All values will be converted to this unit when evaluating the trigger.", "trigger_unit_name": "Unit of measurement" }, + "conditions": { + "is_co2_value": { + "description": "Tests the carbon dioxide level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + } + }, + "name": "Carbon dioxide value" + }, + "is_co_cleared": { + "description": "Tests if one or more carbon monoxide sensors are cleared.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + } + }, + "name": "Carbon monoxide cleared" + }, + "is_co_detected": { + "description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + } + }, + "name": "Carbon monoxide detected" + }, + "is_co_value": { + "description": "Tests the carbon monoxide level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::condition_unit_description%]", + "name": "[%key:component::air_quality::common::condition_unit_name%]" + } + }, + "name": "Carbon monoxide value" + }, + "is_gas_cleared": { + "description": "Tests if one or more gas sensors are cleared.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + } + }, + "name": "Gas cleared" + }, + "is_gas_detected": { + "description": "Tests if one or more gas sensors are detecting gas.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + } + }, + "name": "Gas detected" + }, + "is_n2o_value": { + "description": "Tests the nitrous oxide level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + } + }, + "name": "Nitrous oxide value" + }, + "is_no2_value": { + "description": "Tests the nitrogen dioxide level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::condition_unit_description%]", + "name": "[%key:component::air_quality::common::condition_unit_name%]" + } + }, + "name": "Nitrogen dioxide value" + }, + "is_no_value": { + "description": "Tests the nitrogen monoxide level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::condition_unit_description%]", + "name": "[%key:component::air_quality::common::condition_unit_name%]" + } + }, + "name": "Nitrogen monoxide value" + }, + "is_ozone_value": { + "description": "Tests the ozone level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::condition_unit_description%]", + "name": "[%key:component::air_quality::common::condition_unit_name%]" + } + }, + "name": "Ozone value" + }, + "is_pm10_value": { + "description": "Tests the PM10 level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + } + }, + "name": "PM10 value" + }, + "is_pm1_value": { + "description": "Tests the PM1 level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + } + }, + "name": "PM1 value" + }, + "is_pm25_value": { + "description": "Tests the PM2.5 level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + } + }, + "name": "PM2.5 value" + }, + "is_pm4_value": { + "description": "Tests the PM4 level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + } + }, + "name": "PM4 value" + }, + "is_smoke_cleared": { + "description": "Tests if one or more smoke sensors are cleared.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + } + }, + "name": "Smoke cleared" + }, + "is_smoke_detected": { + "description": "Tests if one or more smoke sensors are detecting smoke.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + } + }, + "name": "Smoke detected" + }, + "is_so2_value": { + "description": "Tests the sulphur dioxide level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::condition_unit_description%]", + "name": "[%key:component::air_quality::common::condition_unit_name%]" + } + }, + "name": "Sulphur dioxide value" + }, + "is_voc_ratio_value": { + "description": "Tests the volatile organic compounds ratio of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::condition_unit_description%]", + "name": "[%key:component::air_quality::common::condition_unit_name%]" + } + }, + "name": "Volatile organic compounds ratio value" + }, + "is_voc_value": { + "description": "Tests the volatile organic compounds level of one or more entities.", + "fields": { + "above": { + "description": "[%key:component::air_quality::common::condition_above_description%]", + "name": "[%key:component::air_quality::common::condition_above_name%]" + }, + "behavior": { + "description": "[%key:component::air_quality::common::condition_behavior_description%]", + "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "below": { + "description": "[%key:component::air_quality::common::condition_below_description%]", + "name": "[%key:component::air_quality::common::condition_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::condition_unit_description%]", + "name": "[%key:component::air_quality::common::condition_unit_name%]" + } + }, + "name": "Volatile organic compounds value" + } + }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "number_or_entity": { "choices": { "entity": "Entity", diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 15de06dc915..4d2d50006cf 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -118,6 +118,7 @@ SERVICE_TRIGGER = "trigger" NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions" _EXPERIMENTAL_CONDITION_PLATFORMS = { + "air_quality", "alarm_control_panel", "assist_satellite", "battery", diff --git a/tests/components/air_quality/test_condition.py b/tests/components/air_quality/test_condition.py new file mode 100644 index 00000000000..1ba70a346c5 --- /dev/null +++ b/tests/components/air_quality/test_condition.py @@ -0,0 +1,577 @@ +"""Test air quality conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + CONF_ABOVE, + CONF_BELOW, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + assert_numerical_condition_unit_conversion, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_numerical_condition_above_below_all, + parametrize_numerical_condition_above_below_any, + parametrize_target_entities, + target_entities, +) + +_UGM3_CONDITION_OPTIONS = {"unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER} +_UGM3_UNIT_ATTRIBUTES = { + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +} +_PPB_CONDITION_OPTIONS = {"unit": CONCENTRATION_PARTS_PER_BILLION} +_PPB_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION} +_PPM_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION} + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.fixture +async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple sensor entities associated with different targets.""" + return await target_entities(hass, "sensor") + + +@pytest.mark.parametrize( + "condition", + [ + "air_quality.is_gas_detected", + "air_quality.is_gas_cleared", + "air_quality.is_co_detected", + "air_quality.is_co_cleared", + "air_quality.is_smoke_detected", + "air_quality.is_smoke_cleared", + "air_quality.is_co_value", + "air_quality.is_co2_value", + "air_quality.is_pm1_value", + "air_quality.is_pm25_value", + "air_quality.is_pm4_value", + "air_quality.is_pm10_value", + "air_quality.is_ozone_value", + "air_quality.is_voc_value", + "air_quality.is_voc_ratio_value", + "air_quality.is_no_value", + "air_quality.is_no2_value", + "air_quality.is_n2o_value", + "air_quality.is_so2_value", + ], +) +async def test_air_quality_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the air quality conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="air_quality.is_gas_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, + ), + *parametrize_condition_states_any( + condition="air_quality.is_gas_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, + ), + *parametrize_condition_states_any( + condition="air_quality.is_co_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, + ), + *parametrize_condition_states_any( + condition="air_quality.is_co_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, + ), + *parametrize_condition_states_any( + condition="air_quality.is_smoke_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE + }, + ), + *parametrize_condition_states_any( + condition="air_quality.is_smoke_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE + }, + ), + ], +) +async def test_air_quality_binary_condition_behavior_any( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the air quality binary sensor condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="air_quality.is_gas_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, + ), + *parametrize_condition_states_all( + condition="air_quality.is_gas_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, + ), + *parametrize_condition_states_all( + condition="air_quality.is_co_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, + ), + *parametrize_condition_states_all( + condition="air_quality.is_co_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, + ), + *parametrize_condition_states_all( + condition="air_quality.is_smoke_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE + }, + ), + *parametrize_condition_states_all( + condition="air_quality.is_smoke_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE + }, + ), + ], +) +async def test_air_quality_binary_condition_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the air quality binary sensor condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_numerical_condition_above_below_any( + "air_quality.is_co_value", + device_class="carbon_monoxide", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_ozone_value", + device_class="ozone", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_voc_value", + device_class="volatile_organic_compounds", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_voc_ratio_value", + device_class="volatile_organic_compounds_parts", + condition_options=_PPB_CONDITION_OPTIONS, + unit_attributes=_PPB_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_no_value", + device_class="nitrogen_monoxide", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_no2_value", + device_class="nitrogen_dioxide", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_so2_value", + device_class="sulphur_dioxide", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + ], +) +async def test_air_quality_numerical_with_unit_condition_behavior_any( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test air quality numerical conditions with unit conversion and 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_numerical_condition_above_below_all( + "air_quality.is_co_value", + device_class="carbon_monoxide", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_ozone_value", + device_class="ozone", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_voc_value", + device_class="volatile_organic_compounds", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_voc_ratio_value", + device_class="volatile_organic_compounds_parts", + condition_options=_PPB_CONDITION_OPTIONS, + unit_attributes=_PPB_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_no_value", + device_class="nitrogen_monoxide", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_no2_value", + device_class="nitrogen_dioxide", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_so2_value", + device_class="sulphur_dioxide", + condition_options=_UGM3_CONDITION_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + ], +) +async def test_air_quality_numerical_with_unit_condition_behavior_all( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test air quality numerical conditions with unit conversion and 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_numerical_condition_above_below_any( + "air_quality.is_co2_value", + device_class="carbon_dioxide", + unit_attributes=_PPM_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_pm1_value", + device_class="pm1", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_pm25_value", + device_class="pm25", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_pm4_value", + device_class="pm4", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_pm10_value", + device_class="pm10", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_any( + "air_quality.is_n2o_value", + device_class="nitrous_oxide", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + ], +) +async def test_air_quality_numerical_no_unit_condition_behavior_any( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test air quality numerical conditions without unit conversion and 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_numerical_condition_above_below_all( + "air_quality.is_co2_value", + device_class="carbon_dioxide", + unit_attributes=_PPM_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_pm1_value", + device_class="pm1", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_pm25_value", + device_class="pm25", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_pm4_value", + device_class="pm4", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_pm10_value", + device_class="pm10", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_condition_above_below_all( + "air_quality.is_n2o_value", + device_class="nitrous_oxide", + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + ], +) +async def test_air_quality_numerical_no_unit_condition_behavior_all( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test air quality numerical conditions without unit conversion and 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_air_quality_condition_unit_conversion_co( + hass: HomeAssistant, +) -> None: + """Test that the CO condition converts units correctly.""" + _unit_ugm3 = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER} + _unit_ppm = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION} + _unit_invalid = {ATTR_UNIT_OF_MEASUREMENT: "not_a_valid_unit"} + + await assert_numerical_condition_unit_conversion( + hass, + condition="air_quality.is_co_value", + entity_id="sensor.test", + pass_states=[ + { + "state": "500", + "attributes": { + "device_class": "carbon_monoxide", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + } + ], + fail_states=[ + { + "state": "100", + "attributes": { + "device_class": "carbon_monoxide", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + } + ], + numerical_condition_options=[ + { + CONF_ABOVE: 0.2, + CONF_BELOW: 0.8, + "unit": CONCENTRATION_PARTS_PER_MILLION, + }, + { + CONF_ABOVE: 200, + CONF_BELOW: 800, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + ], + limit_entity_condition_options={ + CONF_ABOVE: "sensor.above", + CONF_BELOW: "sensor.below", + }, + limit_entities=("sensor.above", "sensor.below"), + limit_entity_states=[ + ( + {"state": "0.2", "attributes": _unit_ppm}, + {"state": "0.8", "attributes": _unit_ppm}, + ), + ( + {"state": "200", "attributes": _unit_ugm3}, + {"state": "800", "attributes": _unit_ugm3}, + ), + ], + invalid_limit_entity_states=[ + ( + {"state": "0.2", "attributes": _unit_invalid}, + {"state": "0.8", "attributes": _unit_invalid}, + ), + ( + {"state": "200", "attributes": _unit_invalid}, + {"state": "800", "attributes": _unit_invalid}, + ), + ], + )