"""Test air quality trigger.""" from typing import Any import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass 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_ENTITY_ID, STATE_OFF, STATE_ON, ) from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, arm_trigger, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_gated_by_labs_flag, assert_trigger_options_supported, parametrize_numerical_state_value_changed_trigger_states, parametrize_numerical_state_value_crossed_threshold_trigger_states, parametrize_target_entities, parametrize_trigger_states, target_entities, ) _UGM3_UNIT_ATTRIBUTES = { ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER } _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( "trigger_key", [ "air_quality.gas_detected", "air_quality.gas_cleared", "air_quality.co_detected", "air_quality.co_cleared", "air_quality.smoke_detected", "air_quality.smoke_cleared", "air_quality.co_changed", "air_quality.co_crossed_threshold", "air_quality.co2_changed", "air_quality.co2_crossed_threshold", "air_quality.pm1_changed", "air_quality.pm1_crossed_threshold", "air_quality.pm25_changed", "air_quality.pm25_crossed_threshold", "air_quality.pm4_changed", "air_quality.pm4_crossed_threshold", "air_quality.pm10_changed", "air_quality.pm10_crossed_threshold", "air_quality.ozone_changed", "air_quality.ozone_crossed_threshold", "air_quality.voc_changed", "air_quality.voc_crossed_threshold", "air_quality.voc_ratio_changed", "air_quality.voc_ratio_crossed_threshold", "air_quality.no_changed", "air_quality.no_crossed_threshold", "air_quality.no2_changed", "air_quality.no2_crossed_threshold", "air_quality.n2o_changed", "air_quality.n2o_crossed_threshold", "air_quality.so2_changed", "air_quality.so2_crossed_threshold", ], ) async def test_air_quality_triggers_gated_by_labs_flag( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str ) -> None: """Test the air quality triggers are gated by the labs flag.""" await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) _CHANGED_THRESHOLD = {"threshold": {"type": "any"}} _PLAIN_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} _PPB_CROSSED_THRESHOLD = { "threshold": { "type": "above", "value": { "number": 50, "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION, }, } } _UGM3_CROSSED_THRESHOLD = { "threshold": { "type": "above", "value": { "number": 50, "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, } } @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ ("air_quality.gas_detected", {}, True, True), ("air_quality.gas_cleared", {}, True, True), ("air_quality.co_detected", {}, True, True), ("air_quality.co_cleared", {}, True, True), ("air_quality.smoke_detected", {}, True, True), ("air_quality.smoke_cleared", {}, True, True), ("air_quality.co_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.co_crossed_threshold", _UGM3_CROSSED_THRESHOLD, True, True), ("air_quality.co2_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.co2_crossed_threshold", _PLAIN_CROSSED_THRESHOLD, True, True), ("air_quality.pm1_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.pm1_crossed_threshold", _PLAIN_CROSSED_THRESHOLD, True, True), ("air_quality.pm25_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.pm25_crossed_threshold", _PLAIN_CROSSED_THRESHOLD, True, True), ("air_quality.pm4_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.pm4_crossed_threshold", _PLAIN_CROSSED_THRESHOLD, True, True), ("air_quality.pm10_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.pm10_crossed_threshold", _PLAIN_CROSSED_THRESHOLD, True, True), ("air_quality.ozone_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.ozone_crossed_threshold", _UGM3_CROSSED_THRESHOLD, True, True), ("air_quality.voc_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.voc_crossed_threshold", _UGM3_CROSSED_THRESHOLD, True, True), ("air_quality.voc_ratio_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.voc_ratio_crossed_threshold", _PPB_CROSSED_THRESHOLD, True, True), ("air_quality.no_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.no_crossed_threshold", _UGM3_CROSSED_THRESHOLD, True, True), ("air_quality.no2_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.no2_crossed_threshold", _UGM3_CROSSED_THRESHOLD, True, True), ("air_quality.n2o_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.n2o_crossed_threshold", _PLAIN_CROSSED_THRESHOLD, True, True), ("air_quality.so2_changed", _CHANGED_THRESHOLD, False, False), ("air_quality.so2_crossed_threshold", _UGM3_CROSSED_THRESHOLD, True, True), ], ) async def test_air_quality_trigger_options_validation( hass: HomeAssistant, trigger_key: str, base_options: dict[str, Any] | None, supports_behavior: bool, supports_duration: bool, ) -> None: """Test that air_quality triggers support the expected options.""" await assert_trigger_options_supported( hass, trigger_key, base_options, supports_behavior=supports_behavior, supports_duration=supports_duration, ) @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), ) @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ *parametrize_trigger_states( trigger="air_quality.co_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.co_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.gas_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.gas_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.smoke_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE }, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.smoke_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE }, trigger_from_none=False, ), ], ) async def test_air_quality_trigger_binary_sensor_behavior_each( hass: HomeAssistant, target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, entities_in_target: int, trigger: str, trigger_options: dict[str, Any], states: list[TriggerStateDescription], ) -> None: """Test air quality triggers fire for binary_sensor entities. Covers gas, CO, and smoke device classes. """ await assert_trigger_behavior_each( hass, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, entities_in_target=entities_in_target, trigger=trigger, trigger_options=trigger_options, states=states, ) @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), ) @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ *parametrize_trigger_states( trigger="air_quality.co_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.co_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.gas_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.gas_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.smoke_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE }, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.smoke_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE }, trigger_from_none=False, ), ], ) async def test_air_quality_trigger_binary_sensor_behavior_first( hass: HomeAssistant, target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, entities_in_target: int, trigger: str, trigger_options: dict[str, Any], states: list[TriggerStateDescription], ) -> None: """Test air quality trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, entities_in_target=entities_in_target, trigger=trigger, trigger_options=trigger_options, states=states, ) @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), ) @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ *parametrize_trigger_states( trigger="air_quality.co_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.co_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.gas_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.gas_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS}, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.smoke_detected", target_states=[STATE_ON], other_states=[STATE_OFF], required_filter_attributes={ ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE }, trigger_from_none=False, ), *parametrize_trigger_states( trigger="air_quality.smoke_cleared", target_states=[STATE_OFF], other_states=[STATE_ON], required_filter_attributes={ ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE }, trigger_from_none=False, ), ], ) async def test_air_quality_trigger_binary_sensor_behavior_all( hass: HomeAssistant, target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, entities_in_target: int, trigger: str, trigger_options: dict[str, Any], states: list[TriggerStateDescription], ) -> None: """Test air quality trigger fires when all binary_sensors have changed state.""" await assert_trigger_behavior_all( hass, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, entities_in_target=entities_in_target, trigger=trigger, trigger_options=trigger_options, states=states, ) @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("sensor"), ) @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ # With unit conversion (µg/m³ base unit) *parametrize_numerical_state_value_changed_trigger_states( "air_quality.co_changed", device_class=SensorDeviceClass.CO, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.co_crossed_threshold", device_class=SensorDeviceClass.CO, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.ozone_changed", device_class=SensorDeviceClass.OZONE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.ozone_crossed_threshold", device_class=SensorDeviceClass.OZONE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.voc_changed", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.voc_crossed_threshold", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.no_changed", device_class=SensorDeviceClass.NITROGEN_MONOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.no_crossed_threshold", device_class=SensorDeviceClass.NITROGEN_MONOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.no2_changed", device_class=SensorDeviceClass.NITROGEN_DIOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.no2_crossed_threshold", device_class=SensorDeviceClass.NITROGEN_DIOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.so2_changed", device_class=SensorDeviceClass.SULPHUR_DIOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.so2_crossed_threshold", device_class=SensorDeviceClass.SULPHUR_DIOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), # With unit conversion (ppb base unit) *parametrize_numerical_state_value_changed_trigger_states( "air_quality.voc_ratio_changed", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, threshold_unit=CONCENTRATION_PARTS_PER_BILLION, unit_attributes=_PPB_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.voc_ratio_crossed_threshold", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, threshold_unit=CONCENTRATION_PARTS_PER_BILLION, unit_attributes=_PPB_UNIT_ATTRIBUTES, ), # Without unit conversion (single-unit device classes) *parametrize_numerical_state_value_changed_trigger_states( "air_quality.co2_changed", device_class=SensorDeviceClass.CO2, unit_attributes=_PPM_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.co2_crossed_threshold", device_class=SensorDeviceClass.CO2, unit_attributes=_PPM_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.pm1_changed", device_class=SensorDeviceClass.PM1, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm1_crossed_threshold", device_class=SensorDeviceClass.PM1, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.pm25_changed", device_class=SensorDeviceClass.PM25, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm25_crossed_threshold", device_class=SensorDeviceClass.PM25, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.pm4_changed", device_class=SensorDeviceClass.PM4, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm4_crossed_threshold", device_class=SensorDeviceClass.PM4, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.pm10_changed", device_class=SensorDeviceClass.PM10, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm10_crossed_threshold", device_class=SensorDeviceClass.PM10, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_changed_trigger_states( "air_quality.n2o_changed", device_class=SensorDeviceClass.NITROUS_OXIDE, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.n2o_crossed_threshold", device_class=SensorDeviceClass.NITROUS_OXIDE, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), ], ) async def test_air_quality_trigger_sensor_behavior_each( hass: HomeAssistant, target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, entities_in_target: int, trigger: str, trigger_options: dict[str, Any], states: list[TriggerStateDescription], ) -> None: """Test air quality trigger fires for sensor entities.""" await assert_trigger_behavior_each( hass, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, entities_in_target=entities_in_target, trigger=trigger, trigger_options=trigger_options, states=states, ) @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("sensor"), ) @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ # With unit conversion (µg/m³ base unit) *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.co_crossed_threshold", device_class=SensorDeviceClass.CO, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.ozone_crossed_threshold", device_class=SensorDeviceClass.OZONE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.voc_crossed_threshold", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.no_crossed_threshold", device_class=SensorDeviceClass.NITROGEN_MONOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.no2_crossed_threshold", device_class=SensorDeviceClass.NITROGEN_DIOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.so2_crossed_threshold", device_class=SensorDeviceClass.SULPHUR_DIOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), # With unit conversion (ppb base unit) *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.voc_ratio_crossed_threshold", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, threshold_unit=CONCENTRATION_PARTS_PER_BILLION, unit_attributes=_PPB_UNIT_ATTRIBUTES, ), # Without unit conversion (single-unit device classes) *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.co2_crossed_threshold", device_class=SensorDeviceClass.CO2, unit_attributes=_PPM_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm1_crossed_threshold", device_class=SensorDeviceClass.PM1, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm25_crossed_threshold", device_class=SensorDeviceClass.PM25, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm4_crossed_threshold", device_class=SensorDeviceClass.PM4, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm10_crossed_threshold", device_class=SensorDeviceClass.PM10, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.n2o_crossed_threshold", device_class=SensorDeviceClass.NITROUS_OXIDE, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), ], ) async def test_air_quality_trigger_sensor_crossed_threshold_behavior_first( hass: HomeAssistant, target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, entities_in_target: int, trigger: str, trigger_options: dict[str, Any], states: list[TriggerStateDescription], ) -> None: """Test air quality crossed_threshold trigger. Fires on the first sensor state change. """ await assert_trigger_behavior_first( hass, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, entities_in_target=entities_in_target, trigger=trigger, trigger_options=trigger_options, states=states, ) @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("sensor"), ) @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ # With unit conversion (µg/m³ base unit) *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.co_crossed_threshold", device_class=SensorDeviceClass.CO, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.ozone_crossed_threshold", device_class=SensorDeviceClass.OZONE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.voc_crossed_threshold", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.no_crossed_threshold", device_class=SensorDeviceClass.NITROGEN_MONOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.no2_crossed_threshold", device_class=SensorDeviceClass.NITROGEN_DIOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.so2_crossed_threshold", device_class=SensorDeviceClass.SULPHUR_DIOXIDE, threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), # With unit conversion (ppb base unit) *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.voc_ratio_crossed_threshold", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, threshold_unit=CONCENTRATION_PARTS_PER_BILLION, unit_attributes=_PPB_UNIT_ATTRIBUTES, ), # Without unit conversion (single-unit device classes) *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.co2_crossed_threshold", device_class=SensorDeviceClass.CO2, unit_attributes=_PPM_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm1_crossed_threshold", device_class=SensorDeviceClass.PM1, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm25_crossed_threshold", device_class=SensorDeviceClass.PM25, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm4_crossed_threshold", device_class=SensorDeviceClass.PM4, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.pm10_crossed_threshold", device_class=SensorDeviceClass.PM10, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( "air_quality.n2o_crossed_threshold", device_class=SensorDeviceClass.NITROUS_OXIDE, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), ], ) async def test_air_quality_trigger_sensor_crossed_threshold_behavior_all( hass: HomeAssistant, target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, entities_in_target: int, trigger: str, trigger_options: dict[str, Any], states: list[TriggerStateDescription], ) -> None: """Test air quality crossed_threshold trigger. Fires when the last sensor changes state. """ await assert_trigger_behavior_all( hass, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, entities_in_target=entities_in_target, trigger=trigger, trigger_options=trigger_options, states=states, ) @pytest.mark.usefixtures("enable_labs_preview_features") async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( hass: HomeAssistant, ) -> None: """Test CO crossed_threshold trigger converts sensor value from ppm to μg/m³.""" calls: list[str] = [] entity_id = "sensor.test_co" # Sensor reports in ppm, trigger threshold is in μg/m³ (fixed unit for CO) # 1 ppm CO ≈ 1164 μg/m³ at 20°C, 1 atm hass.states.async_set( entity_id, "0.5", { ATTR_DEVICE_CLASS: SensorDeviceClass.CO, ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, }, ) await hass.async_block_till_done() await arm_trigger( hass, "air_quality.co_crossed_threshold", { "threshold": { "type": "above", "value": {"number": 1000, "unit_of_measurement": "μg/m³"}, } }, {CONF_ENTITY_ID: [entity_id]}, calls, ) # 0.5 ppm ≈ 582 μg/m³, which is below 1000 μg/m³ - should NOT trigger hass.states.async_set( entity_id, "0.5", { ATTR_DEVICE_CLASS: SensorDeviceClass.CO, ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, }, ) await hass.async_block_till_done() assert len(calls) == 0 # 1 ppm ≈ 1164 μg/m³, which is above 1000 μg/m³ - should trigger hass.states.async_set( entity_id, "1", { ATTR_DEVICE_CLASS: SensorDeviceClass.CO, ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, }, ) await hass.async_block_till_done() assert len(calls) == 1 calls.clear()