From 319f9fda92fa0d36d31582867c06b4a0dd916f1b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Mar 2026 19:31:29 +0100 Subject: [PATCH] Add air quality triggers (#166248) Co-authored-by: Martin Hjelmare Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/bootstrap.py | 1 + .../components/air_quality/icons.json | 98 +++ .../components/air_quality/strings.json | 626 ++++++++++++++ .../components/air_quality/trigger.py | 241 ++++++ .../components/air_quality/triggers.yaml | 692 +++++++++++++++ .../components/automation/__init__.py | 1 + homeassistant/helpers/trigger.py | 36 +- tests/components/air_quality/test_trigger.py | 799 ++++++++++++++++++ 8 files changed, 2493 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/air_quality/strings.json create mode 100644 homeassistant/components/air_quality/trigger.py create mode 100644 homeassistant/components/air_quality/triggers.yaml create mode 100644 tests/components/air_quality/test_trigger.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 28a5ab31019..b77f354426d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -241,6 +241,7 @@ DEFAULT_INTEGRATIONS = { *BASE_PLATFORMS, # # Integrations providing triggers and conditions for base platforms: + "air_quality", "door", "garage_door", "gate", diff --git a/homeassistant/components/air_quality/icons.json b/homeassistant/components/air_quality/icons.json index f15cb508ba8..b64343987a0 100644 --- a/homeassistant/components/air_quality/icons.json +++ b/homeassistant/components/air_quality/icons.json @@ -3,5 +3,103 @@ "_": { "default": "mdi:air-filter" } + }, + "triggers": { + "co2_changed": { + "trigger": "mdi:molecule-co2" + }, + "co2_crossed_threshold": { + "trigger": "mdi:molecule-co2" + }, + "co_changed": { + "trigger": "mdi:molecule-co" + }, + "co_cleared": { + "trigger": "mdi:check-circle" + }, + "co_crossed_threshold": { + "trigger": "mdi:molecule-co" + }, + "co_detected": { + "trigger": "mdi:molecule-co" + }, + "gas_cleared": { + "trigger": "mdi:check-circle" + }, + "gas_detected": { + "trigger": "mdi:gas-cylinder" + }, + "n2o_changed": { + "trigger": "mdi:factory" + }, + "n2o_crossed_threshold": { + "trigger": "mdi:factory" + }, + "no2_changed": { + "trigger": "mdi:factory" + }, + "no2_crossed_threshold": { + "trigger": "mdi:factory" + }, + "no_changed": { + "trigger": "mdi:factory" + }, + "no_crossed_threshold": { + "trigger": "mdi:factory" + }, + "ozone_changed": { + "trigger": "mdi:weather-sunny-alert" + }, + "ozone_crossed_threshold": { + "trigger": "mdi:weather-sunny-alert" + }, + "pm10_changed": { + "trigger": "mdi:blur" + }, + "pm10_crossed_threshold": { + "trigger": "mdi:blur" + }, + "pm1_changed": { + "trigger": "mdi:blur" + }, + "pm1_crossed_threshold": { + "trigger": "mdi:blur" + }, + "pm25_changed": { + "trigger": "mdi:blur" + }, + "pm25_crossed_threshold": { + "trigger": "mdi:blur" + }, + "pm4_changed": { + "trigger": "mdi:blur" + }, + "pm4_crossed_threshold": { + "trigger": "mdi:blur" + }, + "smoke_cleared": { + "trigger": "mdi:check-circle" + }, + "smoke_detected": { + "trigger": "mdi:smoke-detector-variant" + }, + "so2_changed": { + "trigger": "mdi:factory" + }, + "so2_crossed_threshold": { + "trigger": "mdi:factory" + }, + "voc_changed": { + "trigger": "mdi:air-filter" + }, + "voc_crossed_threshold": { + "trigger": "mdi:air-filter" + }, + "voc_ratio_changed": { + "trigger": "mdi:air-filter" + }, + "voc_ratio_crossed_threshold": { + "trigger": "mdi:air-filter" + } } } diff --git a/homeassistant/components/air_quality/strings.json b/homeassistant/components/air_quality/strings.json new file mode 100644 index 00000000000..3c06dfeb42b --- /dev/null +++ b/homeassistant/components/air_quality/strings.json @@ -0,0 +1,626 @@ +{ + "common": { + "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", + "trigger_behavior_name": "Behavior", + "trigger_changed_above_name": "Above", + "trigger_changed_below_name": "Below", + "trigger_threshold_lower_limit_description": "The lower limit of the threshold.", + "trigger_threshold_lower_limit_name": "Lower limit", + "trigger_threshold_type_description": "The type of threshold to use.", + "trigger_threshold_type_name": "Threshold type", + "trigger_threshold_upper_limit_description": "The upper limit of the threshold.", + "trigger_threshold_upper_limit_name": "Upper limit", + "trigger_unit_description": "All values will be converted to this unit when evaluating the trigger.", + "trigger_unit_name": "Unit of measurement" + }, + "selector": { + "number_or_entity": { + "choices": { + "entity": "Entity", + "number": "Number" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + }, + "trigger_threshold_type": { + "options": { + "above": "Above", + "below": "Below", + "between": "Between", + "outside": "Outside" + } + } + }, + "title": "Air Quality", + "triggers": { + "co2_changed": { + "description": "Triggers after one or more carbon dioxide levels change.", + "fields": { + "above": { + "description": "Only trigger when carbon dioxide level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when carbon dioxide level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + } + }, + "name": "Carbon dioxide level changed" + }, + "co2_crossed_threshold": { + "description": "Triggers after one or more carbon dioxide levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Carbon dioxide level crossed threshold" + }, + "co_changed": { + "description": "Triggers after one or more carbon monoxide levels change.", + "fields": { + "above": { + "description": "Only trigger when carbon monoxide level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when carbon monoxide level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + } + }, + "name": "Carbon monoxide level changed" + }, + "co_cleared": { + "description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + } + }, + "name": "Carbon monoxide cleared" + }, + "co_crossed_threshold": { + "description": "Triggers after one or more carbon monoxide levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Carbon monoxide level crossed threshold" + }, + "co_detected": { + "description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + } + }, + "name": "Carbon monoxide detected" + }, + "gas_cleared": { + "description": "Triggers after one or more gas sensors stop detecting gas.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + } + }, + "name": "Gas cleared" + }, + "gas_detected": { + "description": "Triggers after one or more gas sensors start detecting gas.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + } + }, + "name": "Gas detected" + }, + "n2o_changed": { + "description": "Triggers after one or more nitrous oxide levels change.", + "fields": { + "above": { + "description": "Only trigger when nitrous oxide level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when nitrous oxide level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + } + }, + "name": "Nitrous oxide level changed" + }, + "n2o_crossed_threshold": { + "description": "Triggers after one or more nitrous oxide levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Nitrous oxide level crossed threshold" + }, + "no2_changed": { + "description": "Triggers after one or more nitrogen dioxide levels change.", + "fields": { + "above": { + "description": "Only trigger when nitrogen dioxide level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when nitrogen dioxide level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + } + }, + "name": "Nitrogen dioxide level changed" + }, + "no2_crossed_threshold": { + "description": "Triggers after one or more nitrogen dioxide levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Nitrogen dioxide level crossed threshold" + }, + "no_changed": { + "description": "Triggers after one or more nitrogen monoxide levels change.", + "fields": { + "above": { + "description": "Only trigger when nitrogen monoxide level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when nitrogen monoxide level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + } + }, + "name": "Nitrogen monoxide level changed" + }, + "no_crossed_threshold": { + "description": "Triggers after one or more nitrogen monoxide levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Nitrogen monoxide level crossed threshold" + }, + "ozone_changed": { + "description": "Triggers after one or more ozone levels change.", + "fields": { + "above": { + "description": "Only trigger when ozone level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when ozone level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + } + }, + "name": "Ozone level changed" + }, + "ozone_crossed_threshold": { + "description": "Triggers after one or more ozone levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Ozone level crossed threshold" + }, + "pm10_changed": { + "description": "Triggers after one or more PM10 levels change.", + "fields": { + "above": { + "description": "Only trigger when PM10 level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when PM10 level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + } + }, + "name": "PM10 level changed" + }, + "pm10_crossed_threshold": { + "description": "Triggers after one or more PM10 levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "PM10 level crossed threshold" + }, + "pm1_changed": { + "description": "Triggers after one or more PM1 levels change.", + "fields": { + "above": { + "description": "Only trigger when PM1 level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when PM1 level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + } + }, + "name": "PM1 level changed" + }, + "pm1_crossed_threshold": { + "description": "Triggers after one or more PM1 levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "PM1 level crossed threshold" + }, + "pm25_changed": { + "description": "Triggers after one or more PM2.5 levels change.", + "fields": { + "above": { + "description": "Only trigger when PM2.5 level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when PM2.5 level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + } + }, + "name": "PM2.5 level changed" + }, + "pm25_crossed_threshold": { + "description": "Triggers after one or more PM2.5 levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "PM2.5 level crossed threshold" + }, + "pm4_changed": { + "description": "Triggers after one or more PM4 levels change.", + "fields": { + "above": { + "description": "Only trigger when PM4 level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when PM4 level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + } + }, + "name": "PM4 level changed" + }, + "pm4_crossed_threshold": { + "description": "Triggers after one or more PM4 levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "PM4 level crossed threshold" + }, + "smoke_cleared": { + "description": "Triggers after one or more smoke sensors stop detecting smoke.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + } + }, + "name": "Smoke cleared" + }, + "smoke_detected": { + "description": "Triggers after one or more smoke sensors start detecting smoke.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + } + }, + "name": "Smoke detected" + }, + "so2_changed": { + "description": "Triggers after one or more sulphur dioxide levels change.", + "fields": { + "above": { + "description": "Only trigger when sulphur dioxide level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when sulphur dioxide level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + } + }, + "name": "Sulphur dioxide level changed" + }, + "so2_crossed_threshold": { + "description": "Triggers after one or more sulphur dioxide levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Sulphur dioxide level crossed threshold" + }, + "voc_changed": { + "description": "Triggers after one or more volatile organic compound levels change.", + "fields": { + "above": { + "description": "Only trigger when volatile organic compounds level is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when volatile organic compounds level is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + } + }, + "name": "Volatile organic compounds level changed" + }, + "voc_crossed_threshold": { + "description": "Triggers after one or more volatile organic compounds levels cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Volatile organic compounds level crossed threshold" + }, + "voc_ratio_changed": { + "description": "Triggers after one or more volatile organic compound ratios change.", + "fields": { + "above": { + "description": "Only trigger when volatile organic compounds ratio is above this value.", + "name": "[%key:component::air_quality::common::trigger_changed_above_name%]" + }, + "below": { + "description": "Only trigger when volatile organic compounds ratio is below this value.", + "name": "[%key:component::air_quality::common::trigger_changed_below_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + } + }, + "name": "Volatile organic compounds ratio changed" + }, + "voc_ratio_crossed_threshold": { + "description": "Triggers after one or more volatile organic compounds ratios cross a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::air_quality::common::trigger_behavior_description%]", + "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]" + }, + "threshold_type": { + "description": "[%key:component::air_quality::common::trigger_threshold_type_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_type_name%]" + }, + "unit": { + "description": "[%key:component::air_quality::common::trigger_unit_description%]", + "name": "[%key:component::air_quality::common::trigger_unit_name%]" + }, + "upper_limit": { + "description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]", + "name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]" + } + }, + "name": "Volatile organic compounds ratio crossed threshold" + } + } +} diff --git a/homeassistant/components/air_quality/trigger.py b/homeassistant/components/air_quality/trigger.py new file mode 100644 index 00000000000..40ecbef19b4 --- /dev/null +++ b/homeassistant/components/air_quality/trigger.py @@ -0,0 +1,241 @@ +"""Provides triggers 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.trigger import ( + EntityTargetStateTriggerBase, + Trigger, + make_entity_numerical_state_changed_trigger, + make_entity_numerical_state_changed_with_unit_trigger, + make_entity_numerical_state_crossed_threshold_trigger, + make_entity_numerical_state_crossed_threshold_with_unit_trigger, +) +from homeassistant.util.unit_conversion import ( + CarbonMonoxideConcentrationConverter, + MassVolumeConcentrationConverter, + NitrogenDioxideConcentrationConverter, + NitrogenMonoxideConcentrationConverter, + OzoneConcentrationConverter, + SulphurDioxideConcentrationConverter, + UnitlessRatioConverter, +) + + +def _make_detected_trigger( + device_class: BinarySensorDeviceClass, +) -> type[EntityTargetStateTriggerBase]: + """Create a detected trigger for a binary sensor device class.""" + + class DetectedTrigger(EntityTargetStateTriggerBase): + _domain_specs = {BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)} + _to_states = {STATE_ON} + + return DetectedTrigger + + +def _make_cleared_trigger( + device_class: BinarySensorDeviceClass, +) -> type[EntityTargetStateTriggerBase]: + """Create a cleared trigger for a binary sensor device class.""" + + class ClearedTrigger(EntityTargetStateTriggerBase): + _domain_specs = {BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)} + _to_states = {STATE_OFF} + + return ClearedTrigger + + +TRIGGERS: dict[str, type[Trigger]] = { + # Binary sensor triggers (detected/cleared) + "gas_detected": _make_detected_trigger(BinarySensorDeviceClass.GAS), + "gas_cleared": _make_cleared_trigger(BinarySensorDeviceClass.GAS), + "co_detected": _make_detected_trigger(BinarySensorDeviceClass.CO), + "co_cleared": _make_cleared_trigger(BinarySensorDeviceClass.CO), + "smoke_detected": _make_detected_trigger(BinarySensorDeviceClass.SMOKE), + "smoke_cleared": _make_cleared_trigger(BinarySensorDeviceClass.SMOKE), + # Numerical sensor triggers with unit conversion + "co_changed": make_entity_numerical_state_changed_with_unit_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CarbonMonoxideConcentrationConverter, + ), + "co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CarbonMonoxideConcentrationConverter, + ), + "ozone_changed": make_entity_numerical_state_changed_with_unit_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + OzoneConcentrationConverter, + ), + "ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + OzoneConcentrationConverter, + ), + "voc_changed": make_entity_numerical_state_changed_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + MassVolumeConcentrationConverter, + ), + "voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + MassVolumeConcentrationConverter, + ), + "voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS + ) + }, + CONCENTRATION_PARTS_PER_BILLION, + UnitlessRatioConverter, + ), + "voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS + ) + }, + CONCENTRATION_PARTS_PER_BILLION, + UnitlessRatioConverter, + ), + "no_changed": make_entity_numerical_state_changed_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROGEN_MONOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + NitrogenMonoxideConcentrationConverter, + ), + "no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROGEN_MONOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + NitrogenMonoxideConcentrationConverter, + ), + "no2_changed": make_entity_numerical_state_changed_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROGEN_DIOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + NitrogenDioxideConcentrationConverter, + ), + "no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROGEN_DIOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + NitrogenDioxideConcentrationConverter, + ), + "so2_changed": make_entity_numerical_state_changed_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.SULPHUR_DIOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SulphurDioxideConcentrationConverter, + ), + "so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.SULPHUR_DIOXIDE + ) + }, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SulphurDioxideConcentrationConverter, + ), + # Numerical sensor triggers without unit conversion (single-unit device classes) + "co2_changed": make_entity_numerical_state_changed_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + valid_unit=CONCENTRATION_PARTS_PER_MILLION, + ), + "co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + valid_unit=CONCENTRATION_PARTS_PER_MILLION, + ), + "pm1_changed": make_entity_numerical_state_changed_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "pm25_changed": make_entity_numerical_state_changed_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "pm4_changed": make_entity_numerical_state_changed_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "pm10_changed": make_entity_numerical_state_changed_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "n2o_changed": make_entity_numerical_state_changed_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROUS_OXIDE + ) + }, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + "n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + { + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.NITROUS_OXIDE + ) + }, + valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for air quality.""" + return TRIGGERS diff --git a/homeassistant/components/air_quality/triggers.yaml b/homeassistant/components/air_quality/triggers.yaml new file mode 100644 index 00000000000..4266ed6d21e --- /dev/null +++ b/homeassistant/components/air_quality/triggers.yaml @@ -0,0 +1,692 @@ +.trigger_common_fields: + behavior: &trigger_behavior + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +.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_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" + +.trigger_threshold_type: &trigger_threshold_type + required: true + default: above + selector: + select: + options: + - above + - below + - between + - outside + translation_key: trigger_threshold_type + +# Binary sensor detected/cleared trigger fields +.trigger_binary_fields: &trigger_binary_fields + behavior: *trigger_behavior + +# --- 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 triggers --- + +gas_detected: + fields: *trigger_binary_fields + target: *target_gas + +gas_cleared: + fields: *trigger_binary_fields + target: *target_gas + +co_detected: + fields: *trigger_binary_fields + target: *target_co_binary + +co_cleared: + fields: *trigger_binary_fields + target: *target_co_binary + +smoke_detected: + fields: *trigger_binary_fields + target: *target_smoke + +smoke_cleared: + fields: *trigger_binary_fields + target: *target_smoke + +# --- Numerical sensor triggers --- + +co_changed: + target: *target_co_sensor + fields: + above: *number_or_entity_co + below: *number_or_entity_co + unit: *unit_co + +co_crossed_threshold: + target: *target_co_sensor + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_co + upper_limit: *number_or_entity_co + unit: *unit_co + +co2_changed: + target: *target_co2 + fields: + above: *number_or_entity_co2 + below: *number_or_entity_co2 + +co2_crossed_threshold: + target: *target_co2 + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_co2 + upper_limit: *number_or_entity_co2 + +pm1_changed: + target: *target_pm1 + fields: + above: *number_or_entity_pm1 + below: *number_or_entity_pm1 + +pm1_crossed_threshold: + target: *target_pm1 + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_pm1 + upper_limit: *number_or_entity_pm1 + +pm25_changed: + target: *target_pm25 + fields: + above: *number_or_entity_pm25 + below: *number_or_entity_pm25 + +pm25_crossed_threshold: + target: *target_pm25 + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_pm25 + upper_limit: *number_or_entity_pm25 + +pm4_changed: + target: *target_pm4 + fields: + above: *number_or_entity_pm4 + below: *number_or_entity_pm4 + +pm4_crossed_threshold: + target: *target_pm4 + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_pm4 + upper_limit: *number_or_entity_pm4 + +pm10_changed: + target: *target_pm10 + fields: + above: *number_or_entity_pm10 + below: *number_or_entity_pm10 + +pm10_crossed_threshold: + target: *target_pm10 + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_pm10 + upper_limit: *number_or_entity_pm10 + +ozone_changed: + target: *target_ozone + fields: + above: *number_or_entity_ozone + below: *number_or_entity_ozone + unit: *unit_ozone + +ozone_crossed_threshold: + target: *target_ozone + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_ozone + upper_limit: *number_or_entity_ozone + unit: *unit_ozone + +voc_changed: + target: *target_voc + fields: + above: *number_or_entity_voc + below: *number_or_entity_voc + unit: *unit_voc + +voc_crossed_threshold: + target: *target_voc + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_voc + upper_limit: *number_or_entity_voc + unit: *unit_voc + +voc_ratio_changed: + target: *target_voc_ratio + fields: + above: *number_or_entity_voc_ratio + below: *number_or_entity_voc_ratio + unit: *unit_voc_ratio + +voc_ratio_crossed_threshold: + target: *target_voc_ratio + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_voc_ratio + upper_limit: *number_or_entity_voc_ratio + unit: *unit_voc_ratio + +no_changed: + target: *target_no + fields: + above: *number_or_entity_no + below: *number_or_entity_no + unit: *unit_no + +no_crossed_threshold: + target: *target_no + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_no + upper_limit: *number_or_entity_no + unit: *unit_no + +no2_changed: + target: *target_no2 + fields: + above: *number_or_entity_no2 + below: *number_or_entity_no2 + unit: *unit_no2 + +no2_crossed_threshold: + target: *target_no2 + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_no2 + upper_limit: *number_or_entity_no2 + unit: *unit_no2 + +n2o_changed: + target: *target_n2o + fields: + above: *number_or_entity_n2o + below: *number_or_entity_n2o + +n2o_crossed_threshold: + target: *target_n2o + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_n2o + upper_limit: *number_or_entity_n2o + +so2_changed: + target: *target_so2 + fields: + above: *number_or_entity_so2 + below: *number_or_entity_so2 + unit: *unit_so2 + +so2_crossed_threshold: + target: *target_so2 + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_so2 + upper_limit: *number_or_entity_so2 + unit: *unit_so2 diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f8367044e30..639aa1dc1ef 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -143,6 +143,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { } _EXPERIMENTAL_TRIGGER_PLATFORMS = { + "air_quality", "alarm_control_panel", "assist_satellite", "button", diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index c669b3da859..56aa34d32cf 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -654,7 +654,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]): class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase): """Base class for numerical state and state attribute triggers.""" - _base_unit: str # Base unit for the tracked value + _base_unit: str | None # Base unit for the tracked value _manual_limit_unit: str | None # Unit of above/below limits when numbers _unit_converter: type[BaseUnitConverter] @@ -1057,6 +1057,40 @@ def make_entity_numerical_state_crossed_threshold_trigger( return CustomTrigger +def make_entity_numerical_state_changed_with_unit_trigger( + domain_specs: Mapping[str, NumericalDomainSpec], + base_unit: str, + unit_converter: type[BaseUnitConverter], +) -> type[EntityNumericalStateChangedTriggerWithUnitBase]: + """Create a trigger for numerical state value change.""" + + class CustomTrigger(EntityNumericalStateChangedTriggerWithUnitBase): + """Trigger for numerical state value changes.""" + + _domain_specs = domain_specs + _base_unit = base_unit + _unit_converter = unit_converter + + return CustomTrigger + + +def make_entity_numerical_state_crossed_threshold_with_unit_trigger( + domain_specs: Mapping[str, NumericalDomainSpec], + base_unit: str, + unit_converter: type[BaseUnitConverter], +) -> type[EntityNumericalStateCrossedThresholdTriggerWithUnitBase]: + """Create a trigger for numerical state value crossing a threshold.""" + + class CustomTrigger(EntityNumericalStateCrossedThresholdTriggerWithUnitBase): + """Trigger for numerical state value crossing a threshold.""" + + _domain_specs = domain_specs + _base_unit = base_unit + _unit_converter = unit_converter + + return CustomTrigger + + class TriggerProtocol(Protocol): """Define the format of trigger modules. diff --git a/tests/components/air_quality/test_trigger.py b/tests/components/air_quality/test_trigger.py new file mode 100644 index 00000000000..87978a003ec --- /dev/null +++ b/tests/components/air_quality/test_trigger.py @@ -0,0 +1,799 @@ +"""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, ServiceCall + +from tests.components.common import ( + TriggerStateDescription, + arm_trigger, + assert_trigger_behavior_any, + assert_trigger_behavior_first, + assert_trigger_behavior_last, + assert_trigger_gated_by_labs_flag, + parametrize_numerical_state_value_changed_trigger_states, + parametrize_numerical_state_value_crossed_threshold_trigger_states, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + +_UGM3_TRIGGER_OPTIONS = {"unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER} +_UGM3_UNIT_ATTRIBUTES = { + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +} +_PPB_TRIGGER_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( + "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) + + +@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_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + 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 with gas, CO, and smoke device classes.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + 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, + service_calls: list[ServiceCall], + 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, + service_calls=service_calls, + 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_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + 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 the last binary_sensor changes state.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + 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, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.co_crossed_threshold", + device_class=SensorDeviceClass.CO, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_changed_trigger_states( + "air_quality.ozone_changed", + device_class=SensorDeviceClass.OZONE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.ozone_crossed_threshold", + device_class=SensorDeviceClass.OZONE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_changed_trigger_states( + "air_quality.voc_changed", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.voc_crossed_threshold", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_changed_trigger_states( + "air_quality.no_changed", + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.no_crossed_threshold", + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_changed_trigger_states( + "air_quality.no2_changed", + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.no2_crossed_threshold", + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_changed_trigger_states( + "air_quality.so2_changed", + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.so2_crossed_threshold", + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + 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, + trigger_options=_PPB_TRIGGER_OPTIONS, + 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, + trigger_options=_PPB_TRIGGER_OPTIONS, + 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_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + 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_any( + hass, + service_calls=service_calls, + 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, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.ozone_crossed_threshold", + device_class=SensorDeviceClass.OZONE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.voc_crossed_threshold", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.no_crossed_threshold", + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.no2_crossed_threshold", + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.so2_crossed_threshold", + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + 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, + trigger_options=_PPB_TRIGGER_OPTIONS, + 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, + service_calls: list[ServiceCall], + 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, + service_calls=service_calls, + 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, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.ozone_crossed_threshold", + device_class=SensorDeviceClass.OZONE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.voc_crossed_threshold", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.no_crossed_threshold", + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.no2_crossed_threshold", + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + unit_attributes=_UGM3_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "air_quality.so2_crossed_threshold", + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + trigger_options=_UGM3_TRIGGER_OPTIONS, + 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, + trigger_options=_PPB_TRIGGER_OPTIONS, + 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_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + 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_last( + hass, + service_calls=service_calls, + 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, + service_calls: list[ServiceCall], +) -> None: + """Test CO crossed_threshold trigger converts sensor value from ppm to μg/m³.""" + 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", + "lower_limit": 1000, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + {CONF_ENTITY_ID: [entity_id]}, + ) + + # 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(service_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(service_calls) == 1 + service_calls.clear()