1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add air quality triggers (#166248)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Erik Montnemery
2026-03-23 19:31:29 +01:00
committed by GitHub
parent f9525ebda7
commit 319f9fda92
8 changed files with 2493 additions and 1 deletions

View File

@@ -241,6 +241,7 @@ DEFAULT_INTEGRATIONS = {
*BASE_PLATFORMS,
#
# Integrations providing triggers and conditions for base platforms:
"air_quality",
"door",
"garage_door",
"gate",

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -143,6 +143,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"air_quality",
"alarm_control_panel",
"assist_satellite",
"button",

View File

@@ -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.

View File

@@ -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()