From ad522d723cc624dbb839677037754c727da659bc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 10:03:49 +0100 Subject: [PATCH] Add trigger humidifier.mode_changed (#166241) Co-authored-by: Norbert Rittel --- .../components/humidifier/icons.json | 3 + .../components/humidifier/strings.json | 14 +++ .../components/humidifier/trigger.py | 62 +++++++++- .../components/humidifier/triggers.yaml | 17 ++- tests/components/humidifier/test_trigger.py | 116 +++++++++++++++++- 5 files changed, 203 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index fde5c3c9598..778aa6d0f47 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -67,6 +67,9 @@ } }, "triggers": { + "mode_changed": { + "trigger": "mdi:air-humidifier" + }, "started_drying": { "trigger": "mdi:arrow-down-bold" }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index beee0502bc0..6acd851b3de 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -201,6 +201,20 @@ }, "title": "Humidifier", "triggers": { + "mode_changed": { + "description": "Triggers after the operation mode of one or more humidifiers changes.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::trigger_behavior_description%]", + "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "mode": { + "description": "The operation modes to trigger on.", + "name": "Mode" + } + }, + "name": "Humidifier mode changed" + }, "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 44179856f27..b0df9126733 100644 --- a/homeassistant/components/humidifier/trigger.py +++ b/homeassistant/components/humidifier/trigger.py @@ -1,13 +1,65 @@ """Provides triggers for humidifiers.""" -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger +import voluptuous as vol + +from homeassistant.const import ATTR_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.entity import get_supported_features +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, + EntityTargetStateTriggerBase, + Trigger, + TriggerConfig, + make_entity_target_state_trigger, +) + +from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature + +CONF_MODE = "mode" + +MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]), + }, + } +) + + +def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool: + """Test if an entity supports the specified features.""" + try: + return bool(get_supported_features(hass, entity_id) & features) + except HomeAssistantError: + return False + + +class ModeChangedTrigger(EntityTargetStateTriggerBase): + """Trigger for humidifier mode changes.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _schema = MODE_CHANGED_TRIGGER_SCHEMA + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the mode trigger.""" + super().__init__(hass, config) + self._to_states = set(self._options[CONF_MODE]) + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities of this domain.""" + entities = super().entity_filter(entities) + return { + entity_id + for entity_id in entities + if _supports_feature(self._hass, entity_id, HumidifierEntityFeature.MODES) + } -from .const import ATTR_ACTION, DOMAIN, HumidifierAction TRIGGERS: dict[str, type[Trigger]] = { + "mode_changed": ModeChangedTrigger, "started_drying": make_entity_target_state_trigger( {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING ), diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index 5773f999c88..12072ab71eb 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: @@ -18,3 +18,16 @@ started_drying: *trigger_common started_humidifying: *trigger_common turned_on: *trigger_common turned_off: *trigger_common + +mode_changed: + target: *trigger_humidifier_target + fields: + behavior: *trigger_behavior + mode: + context: + filter_target: target + required: true + selector: + state: + attribute: available_modes + multiple: true diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py index fde5ed83a63..81af5d51afa 100644 --- a/tests/components/humidifier/test_trigger.py +++ b/tests/components/humidifier/test_trigger.py @@ -1,12 +1,28 @@ """Test humidifier trigger.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from typing import Any import pytest +import voluptuous as vol -from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.humidifier.const import ( + ATTR_ACTION, + HumidifierAction, + HumidifierEntityFeature, +) +from homeassistant.components.humidifier.trigger import CONF_MODE +from homeassistant.const import ( + ATTR_MODE, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import async_validate_trigger_config from tests.components.common import ( TriggerStateDescription, @@ -29,6 +45,7 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "trigger_key", [ + "humidifier.mode_changed", "humidifier.started_drying", "humidifier.started_humidifying", "humidifier.turned_off", @@ -103,6 +120,21 @@ async def test_humidifier_state_trigger_behavior_any( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_any( @@ -189,6 +221,21 @@ async def test_humidifier_state_trigger_behavior_first( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_first( @@ -275,6 +322,21 @@ async def test_humidifier_state_trigger_behavior_last( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_last( @@ -298,3 +360,53 @@ async def test_humidifier_state_attribute_trigger_behavior_last( trigger_options=trigger_options, states=states, ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger", "trigger_options", "expected_result"), + [ + # Valid configurations + ( + "humidifier.mode_changed", + {CONF_MODE: ["eco", "sleep"]}, + does_not_raise(), + ), + ( + "humidifier.mode_changed", + {CONF_MODE: "eco"}, + does_not_raise(), + ), + # Invalid configurations + ( + "humidifier.mode_changed", + # Empty mode list + {CONF_MODE: []}, + pytest.raises(vol.Invalid), + ), + ( + "humidifier.mode_changed", + # Missing CONF_MODE + {}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_humidifier_mode_changed_trigger_validation( + hass: HomeAssistant, + trigger: str, + trigger_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test humidifier mode_changed trigger config validation.""" + with expected_result: + await async_validate_trigger_config( + hass, + [ + { + "platform": trigger, + CONF_TARGET: {CONF_ENTITY_ID: "humidifier.test"}, + CONF_OPTIONS: trigger_options, + } + ], + )