diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index 04a91d552ec..1788a9395d3 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -50,6 +50,12 @@ } }, "triggers": { + "current_humidity_changed": { + "trigger": "mdi:water-percent" + }, + "current_humidity_crossed_threshold": { + "trigger": "mdi:water-percent" + }, "started_drying": { "trigger": "mdi:arrow-down-bold" }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 9ae2b5223f9..14371037a74 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -91,12 +91,26 @@ } }, "selector": { + "number_or_entity": { + "choices": { + "entity": "Entity", + "number": "Number" + } + }, "trigger_behavior": { "options": { "any": "Any", "first": "First", "last": "Last" } + }, + "trigger_threshold_type": { + "options": { + "above": "Above a value", + "below": "Below a value", + "between": "In a range", + "outside": "Outside a range" + } } }, "services": { @@ -135,6 +149,42 @@ }, "title": "Humidifier", "triggers": { + "current_humidity_changed": { + "description": "Triggers after the humidity measured by one or more humidifiers changes.", + "fields": { + "above": { + "description": "Trigger when the humidity is above this value.", + "name": "Above" + }, + "below": { + "description": "Trigger when the humidity is below this value.", + "name": "Below" + } + }, + "name": "Humidifier current humidity changed" + }, + "current_humidity_crossed_threshold": { + "description": "Triggers after the humidity measured by one or more humidifiers crosses a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::trigger_behavior_description%]", + "name": "[%key:component::climate::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "Lower threshold limit.", + "name": "Lower threshold" + }, + "threshold_type": { + "description": "Type of threshold crossing to trigger on.", + "name": "Threshold type" + }, + "upper_limit": { + "description": "Upper threshold limit.", + "name": "Upper threshold" + } + }, + "name": "Humidifier current humidity crossed threshold" + }, "started_drying": { "description": "Triggers after one or more humidifiers start drying.", "fields": { diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py index c9dcf5426cc..bb720e08e06 100644 --- a/homeassistant/components/humidifier/trigger.py +++ b/homeassistant/components/humidifier/trigger.py @@ -4,13 +4,21 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import ( Trigger, + make_entity_numerical_state_attribute_changed_trigger, + make_entity_numerical_state_attribute_crossed_threshold_trigger, make_entity_target_state_attribute_trigger, make_entity_target_state_trigger, ) -from .const import ATTR_ACTION, DOMAIN, HumidifierAction +from .const import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, DOMAIN, HumidifierAction TRIGGERS: dict[str, type[Trigger]] = { + "current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger( + DOMAIN, ATTR_CURRENT_HUMIDITY + ), + "current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger( + DOMAIN, ATTR_CURRENT_HUMIDITY + ), "started_drying": make_entity_target_state_attribute_trigger( DOMAIN, ATTR_ACTION, HumidifierAction.DRYING ), diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index 5773f999c88..ea58ece9698 100644 --- a/homeassistant/components/humidifier/triggers.yaml +++ b/homeassistant/components/humidifier/triggers.yaml @@ -1,9 +1,9 @@ .trigger_common: &trigger_common - target: + target: &trigger_humidifier_target entity: domain: humidifier fields: - behavior: + behavior: &trigger_behavior required: true default: any selector: @@ -14,7 +14,51 @@ - last - any +.number_or_entity: &number_or_entity + required: false + selector: + choose: + choices: + entity: + selector: + entity: + filter: + domain: + - input_number + - number + - sensor + number: + selector: + number: + mode: box + translation_key: number_or_entity + +.trigger_threshold_type: &trigger_threshold_type + required: true + selector: + select: + options: + - above + - below + - between + - outside + translation_key: trigger_threshold_type + started_drying: *trigger_common started_humidifying: *trigger_common turned_on: *trigger_common turned_off: *trigger_common + +current_humidity_changed: + target: *trigger_humidifier_target + fields: + above: *number_or_entity + below: *number_or_entity + +current_humidity_crossed_threshold: + target: *trigger_humidifier_target + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity + upper_limit: *number_or_entity diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py index b409a43d3f5..88d12164a3d 100644 --- a/tests/components/humidifier/test_trigger.py +++ b/tests/components/humidifier/test_trigger.py @@ -1,13 +1,31 @@ """Test humidifier trigger.""" from collections.abc import Generator +from typing import Any from unittest.mock import patch import pytest -from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction -from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.components.humidifier.const import ( + ATTR_ACTION, + ATTR_CURRENT_HUMIDITY, + HumidifierAction, +) +from homeassistant.const import ( + ATTR_LABEL_ID, + CONF_ABOVE, + CONF_BELOW, + CONF_ENTITY_ID, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers.trigger import ( + CONF_LOWER_LIMIT, + CONF_THRESHOLD_TYPE, + CONF_UPPER_LIMIT, + ThresholdType, +) from tests.components import ( StateDescription, @@ -43,6 +61,8 @@ async def target_humidifiers(hass: HomeAssistant) -> list[str]: @pytest.mark.parametrize( "trigger_key", [ + "humidifier.current_humidity_changed", + "humidifier.current_humidity_crossed_threshold", "humidifier.started_drying", "humidifier.started_humidifying", "humidifier.turned_off", @@ -62,6 +82,148 @@ async def test_humidifier_triggers_gated_by_labs_flag( ) in caplog.text +def parametrize_humidifier_trigger_states( + *, + trigger: str, + trigger_options: dict | None = None, + target_states: list[str | None | tuple[str | None, dict]], + other_states: list[str | None | tuple[str | None, dict]], + additional_attributes: dict | None = None, + trigger_from_none: bool = True, + retrigger_on_target_state: bool = False, +) -> list[tuple[str, dict[str, Any], list[StateDescription]]]: + """Parametrize states and expected service call counts.""" + trigger_options = trigger_options or {} + return [ + (s[0], trigger_options, *s[1:]) + for s in parametrize_trigger_states( + trigger=trigger, + target_states=target_states, + other_states=other_states, + additional_attributes=additional_attributes, + trigger_from_none=trigger_from_none, + retrigger_on_target_state=retrigger_on_target_state, + ) + ] + + +def parametrize_xxx_changed_trigger_states( + trigger: str, attribute: str +) -> list[tuple[str, dict[str, Any], list[StateDescription]]]: + """Parametrize states and expected service call counts for xxx_changed triggers.""" + return [ + *parametrize_humidifier_trigger_states( + trigger=trigger, + trigger_options={}, + target_states=[ + (STATE_ON, {attribute: 0}), + (STATE_ON, {attribute: 50}), + (STATE_ON, {attribute: 100}), + ], + other_states=[(STATE_ON, {attribute: None})], + retrigger_on_target_state=True, + ), + *parametrize_humidifier_trigger_states( + trigger=trigger, + trigger_options={CONF_ABOVE: 10}, + target_states=[ + (STATE_ON, {attribute: 50}), + (STATE_ON, {attribute: 100}), + ], + other_states=[ + (STATE_ON, {attribute: None}), + (STATE_ON, {attribute: 0}), + ], + retrigger_on_target_state=True, + ), + *parametrize_humidifier_trigger_states( + trigger=trigger, + trigger_options={CONF_BELOW: 90}, + target_states=[ + (STATE_ON, {attribute: 0}), + (STATE_ON, {attribute: 50}), + ], + other_states=[ + (STATE_ON, {attribute: None}), + (STATE_ON, {attribute: 100}), + ], + retrigger_on_target_state=True, + ), + ] + + +def parametrize_xxx_crossed_threshold_trigger_states( + trigger: str, attribute: str +) -> list[tuple[str, dict[str, Any], list[StateDescription]]]: + """Parametrize states and expected service call counts for xxx_crossed_threshold triggers.""" + return [ + *parametrize_humidifier_trigger_states( + trigger=trigger, + trigger_options={ + CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, + CONF_LOWER_LIMIT: 10, + CONF_UPPER_LIMIT: 90, + }, + target_states=[ + (STATE_ON, {attribute: 50}), + (STATE_ON, {attribute: 60}), + ], + other_states=[ + (STATE_ON, {attribute: None}), + (STATE_ON, {attribute: 0}), + (STATE_ON, {attribute: 100}), + ], + ), + *parametrize_humidifier_trigger_states( + trigger=trigger, + trigger_options={ + CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, + CONF_LOWER_LIMIT: 10, + CONF_UPPER_LIMIT: 90, + }, + target_states=[ + (STATE_ON, {attribute: 0}), + (STATE_ON, {attribute: 100}), + ], + other_states=[ + (STATE_ON, {attribute: None}), + (STATE_ON, {attribute: 50}), + (STATE_ON, {attribute: 60}), + ], + ), + *parametrize_humidifier_trigger_states( + trigger=trigger, + trigger_options={ + CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, + CONF_LOWER_LIMIT: 10, + }, + target_states=[ + (STATE_ON, {attribute: 50}), + (STATE_ON, {attribute: 100}), + ], + other_states=[ + (STATE_ON, {attribute: None}), + (STATE_ON, {attribute: 0}), + ], + ), + *parametrize_humidifier_trigger_states( + trigger=trigger, + trigger_options={ + CONF_THRESHOLD_TYPE: ThresholdType.BELOW, + CONF_UPPER_LIMIT: 90, + }, + target_states=[ + (STATE_ON, {attribute: 0}), + (STATE_ON, {attribute: 50}), + ], + other_states=[ + (STATE_ON, {attribute: None}), + (STATE_ON, {attribute: 100}), + ], + ), + ] + + @pytest.mark.usefixtures("enable_experimental_triggers_conditions") @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), @@ -125,14 +287,20 @@ async def test_humidifier_state_trigger_behavior_any( parametrize_target_entities("humidifier"), ) @pytest.mark.parametrize( - ("trigger", "states"), + ("trigger", "trigger_options", "states"), [ - *parametrize_trigger_states( + *parametrize_xxx_changed_trigger_states( + "humidifier.current_humidity_changed", ATTR_CURRENT_HUMIDITY + ), + *parametrize_xxx_crossed_threshold_trigger_states( + "humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY + ), + *parametrize_humidifier_trigger_states( trigger="humidifier.started_drying", target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), - *parametrize_trigger_states( + *parametrize_humidifier_trigger_states( trigger="humidifier.started_humidifying", target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], @@ -147,6 +315,7 @@ async def test_humidifier_state_attribute_trigger_behavior_any( entity_id: str, entities_in_target: int, trigger: str, + trigger_options: dict[str, Any], states: list[StateDescription], ) -> None: """Test that the humidifier state trigger fires when any humidifier state changes to a specific state.""" @@ -157,7 +326,7 @@ async def test_humidifier_state_attribute_trigger_behavior_any( set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, {}, trigger_target_config) + await arm_trigger(hass, trigger, trigger_options, trigger_target_config) for state in states[1:]: included_state = state["included"] @@ -238,14 +407,17 @@ async def test_humidifier_state_trigger_behavior_first( parametrize_target_entities("humidifier"), ) @pytest.mark.parametrize( - ("trigger", "states"), + ("trigger", "trigger_options", "states"), [ - *parametrize_trigger_states( + *parametrize_xxx_crossed_threshold_trigger_states( + "humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY + ), + *parametrize_humidifier_trigger_states( trigger="humidifier.started_drying", target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), - *parametrize_trigger_states( + *parametrize_humidifier_trigger_states( trigger="humidifier.started_humidifying", target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], @@ -260,6 +432,7 @@ async def test_humidifier_state_attribute_trigger_behavior_first( entity_id: str, entities_in_target: int, trigger: str, + trigger_options: dict[str, Any], states: list[tuple[tuple[str, dict], int]], ) -> None: """Test that the humidifier state trigger fires when the first humidifier state changes to a specific state.""" @@ -270,7 +443,9 @@ async def test_humidifier_state_attribute_trigger_behavior_first( set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) + await arm_trigger( + hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config + ) for state in states[1:]: included_state = state["included"] @@ -349,14 +524,17 @@ async def test_humidifier_state_trigger_behavior_last( parametrize_target_entities("humidifier"), ) @pytest.mark.parametrize( - ("trigger", "states"), + ("trigger", "trigger_options", "states"), [ - *parametrize_trigger_states( + *parametrize_xxx_crossed_threshold_trigger_states( + "humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY + ), + *parametrize_humidifier_trigger_states( trigger="humidifier.started_drying", target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), - *parametrize_trigger_states( + *parametrize_humidifier_trigger_states( trigger="humidifier.started_humidifying", target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], @@ -371,6 +549,7 @@ async def test_humidifier_state_attribute_trigger_behavior_last( entity_id: str, entities_in_target: int, trigger: str, + trigger_options: dict[str, Any], states: list[tuple[tuple[str, dict], int]], ) -> None: """Test that the humidifier state trigger fires when the last humidifier state changes to a specific state.""" @@ -381,7 +560,9 @@ async def test_humidifier_state_attribute_trigger_behavior_last( set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) + await arm_trigger( + hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config + ) for state in states[1:]: included_state = state["included"]