diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index c87d8836cfe..2727992e1e2 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -130,6 +130,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "cover", "device_tracker", "fan", + "humidifier", "lawn_mower", "light", "media_player", diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index 15951df432d..04a91d552ec 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -48,5 +48,19 @@ "turn_on": { "service": "mdi:air-humidifier" } + }, + "triggers": { + "started_drying": { + "trigger": "mdi:arrow-down-bold" + }, + "started_humidifying": { + "trigger": "mdi:arrow-up-bold" + }, + "turned_off": { + "trigger": "mdi:air-humidifier-off" + }, + "turned_on": { + "trigger": "mdi:air-humidifier-on" + } } } diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 1e986f44434..9ae2b5223f9 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "action_type": { "set_humidity": "Set humidity for {entity_name}", @@ -86,6 +90,15 @@ "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." } }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, "services": { "set_humidity": { "description": "Sets the target humidity.", @@ -120,5 +133,47 @@ "name": "[%key:common::action::turn_on%]" } }, - "title": "Humidifier" + "title": "Humidifier", + "triggers": { + "started_drying": { + "description": "Triggers after one or more humidifiers start drying.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::trigger_behavior_description%]", + "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + } + }, + "name": "Humidifier started drying" + }, + "started_humidifying": { + "description": "Triggers after one or more humidifiers start humidifying.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::trigger_behavior_description%]", + "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + } + }, + "name": "Humidifier started humidifying" + }, + "turned_off": { + "description": "Triggers after one or more humidifiers turn off.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::trigger_behavior_description%]", + "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + } + }, + "name": "Humidifier turned off" + }, + "turned_on": { + "description": "Triggers after one or more humidifiers turn on.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::trigger_behavior_description%]", + "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + } + }, + "name": "Humidifier turned on" + } + } } diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py new file mode 100644 index 00000000000..c9dcf5426cc --- /dev/null +++ b/homeassistant/components/humidifier/trigger.py @@ -0,0 +1,27 @@ +"""Provides triggers for humidifiers.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import ( + Trigger, + make_entity_target_state_attribute_trigger, + make_entity_target_state_trigger, +) + +from .const import ATTR_ACTION, DOMAIN, HumidifierAction + +TRIGGERS: dict[str, type[Trigger]] = { + "started_drying": make_entity_target_state_attribute_trigger( + DOMAIN, ATTR_ACTION, HumidifierAction.DRYING + ), + "started_humidifying": make_entity_target_state_attribute_trigger( + DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING + ), + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), + "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for humidifiers.""" + return TRIGGERS diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml new file mode 100644 index 00000000000..5773f999c88 --- /dev/null +++ b/homeassistant/components/humidifier/triggers.yaml @@ -0,0 +1,20 @@ +.trigger_common: &trigger_common + target: + entity: + domain: humidifier + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +started_drying: *trigger_common +started_humidifying: *trigger_common +turned_on: *trigger_common +turned_off: *trigger_common diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py new file mode 100644 index 00000000000..b409a43d3f5 --- /dev/null +++ b/tests/components/humidifier/test_trigger.py @@ -0,0 +1,398 @@ +"""Test humidifier trigger.""" + +from collections.abc import Generator +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.core import HomeAssistant, ServiceCall + +from tests.components import ( + StateDescription, + arm_trigger, + parametrize_target_entities, + parametrize_trigger_states, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture(name="enable_experimental_triggers_conditions") +def enable_experimental_triggers_conditions() -> Generator[None]: + """Enable experimental triggers and conditions.""" + with patch( + "homeassistant.components.labs.async_is_preview_feature_enabled", + return_value=True, + ): + yield + + +@pytest.fixture +async def target_humidifiers(hass: HomeAssistant) -> list[str]: + """Create multiple humidifier entities associated with different targets.""" + return (await target_entities(hass, "humidifier"))["included"] + + +@pytest.mark.parametrize( + "trigger_key", + [ + "humidifier.started_drying", + "humidifier.started_humidifying", + "humidifier.turned_off", + "humidifier.turned_on", + ], +) +async def test_humidifier_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the humidifier triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("humidifier"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="humidifier.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_trigger_states( + trigger="humidifier.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + ], +) +async def test_humidifier_state_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_humidifiers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the humidifier state trigger fires when any humidifier state changes to a specific state.""" + other_entity_ids = set(target_humidifiers) - {entity_id} + + # Set all humidifiers, including the tested humidifier, to the initial state + for eid in target_humidifiers: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Check if changing other humidifiers also triggers + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("humidifier"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_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( + trigger="humidifier.started_humidifying", + target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], + other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], + ), + ], +) +async def test_humidifier_state_attribute_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_humidifiers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the humidifier state trigger fires when any humidifier state changes to a specific state.""" + other_entity_ids = set(target_humidifiers) - {entity_id} + + # Set all humidifiers, including the tested humidifier, to the initial state + for eid in target_humidifiers: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Check if changing other humidifiers also triggers + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("humidifier"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="humidifier.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_trigger_states( + trigger="humidifier.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + ], +) +async def test_humidifier_state_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_humidifiers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the humidifier state trigger fires when the first humidifier changes to a specific state.""" + other_entity_ids = set(target_humidifiers) - {entity_id} + + # Set all humidifiers, including the tested humidifier, to the initial state + for eid in target_humidifiers: + 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) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Triggering other humidifiers should not cause the trigger to fire again + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("humidifier"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_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( + trigger="humidifier.started_humidifying", + target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], + other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], + ), + ], +) +async def test_humidifier_state_attribute_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_humidifiers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + 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.""" + other_entity_ids = set(target_humidifiers) - {entity_id} + + # Set all humidifiers, including the tested humidifier, to the initial state + for eid in target_humidifiers: + 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) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Triggering other humidifiers should not cause the trigger to fire again + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("humidifier"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="humidifier.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_trigger_states( + trigger="humidifier.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + ], +) +async def test_humidifier_state_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_humidifiers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the humidifier state trigger fires when the last humidifier changes to a specific state.""" + other_entity_ids = set(target_humidifiers) - {entity_id} + + # Set all humidifiers, including the tested humidifier, to the initial state + for eid in target_humidifiers: + 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) + + for state in states[1:]: + included_state = state["included"] + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("humidifier"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_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( + trigger="humidifier.started_humidifying", + target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], + other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], + ), + ], +) +async def test_humidifier_state_attribute_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_humidifiers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + 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.""" + other_entity_ids = set(target_humidifiers) - {entity_id} + + # Set all humidifiers, including the tested humidifier, to the initial state + for eid in target_humidifiers: + 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) + + for state in states[1:]: + included_state = state["included"] + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear()