diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index a1d4abc74be..8fee85b7024 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -35,6 +35,12 @@ } }, "triggers": { + "brightness_changed": { + "trigger": "mdi:lightbulb-on-50" + }, + "brightness_crossed_threshold": { + "trigger": "mdi:lightbulb-on-50" + }, "turned_off": { "trigger": "mdi:lightbulb-off" }, diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index b4399786878..73807d09deb 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -322,6 +322,12 @@ "short": "Short" } }, + "number_or_entity": { + "choices": { + "entity": "Entity", + "number": "Number" + } + }, "state": { "options": { "off": "[%key:common::state::off%]", @@ -334,6 +340,14 @@ "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": { @@ -509,6 +523,42 @@ }, "title": "Light", "triggers": { + "brightness_changed": { + "description": "Triggers after the brightness of one or more lights changes.", + "fields": { + "above": { + "description": "Trigger when the target brightness is above this value.", + "name": "Above" + }, + "below": { + "description": "Trigger when the target brightness is below this value.", + "name": "Below" + } + }, + "name": "Light brightness changed" + }, + "brightness_crossed_threshold": { + "description": "Triggers after the brightness of one or more lights crosses a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::light::common::trigger_behavior_description%]", + "name": "[%key:component::light::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": "Light brightness crossed threshold" + }, "turned_off": { "description": "Triggers after one or more lights turn off.", "fields": { diff --git a/homeassistant/components/light/trigger.py b/homeassistant/components/light/trigger.py index 3ba7976c71a..5a3550fc58b 100644 --- a/homeassistant/components/light/trigger.py +++ b/homeassistant/components/light/trigger.py @@ -2,11 +2,23 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger +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_trigger, +) +from . import ATTR_BRIGHTNESS from .const import DOMAIN TRIGGERS: dict[str, type[Trigger]] = { + "brightness_changed": make_entity_numerical_state_attribute_changed_trigger( + DOMAIN, ATTR_BRIGHTNESS + ), + "brightness_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger( + DOMAIN, ATTR_BRIGHTNESS + ), "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/light/triggers.yaml b/homeassistant/components/light/triggers.yaml index 35c033875d0..87f720d6404 100644 --- a/homeassistant/components/light/triggers.yaml +++ b/homeassistant/components/light/triggers.yaml @@ -1,9 +1,9 @@ .trigger_common: &trigger_common - target: + target: &trigger_light_target entity: domain: light fields: - behavior: + behavior: &trigger_behavior required: true default: any selector: @@ -14,5 +14,47 @@ - any translation_key: trigger_behavior +.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 + turned_on: *trigger_common turned_off: *trigger_common + +brightness_changed: + target: *trigger_light_target + fields: + above: *number_or_entity + below: *number_or_entity + +brightness_crossed_threshold: + target: *trigger_light_target + fields: + behavior: *trigger_behavior + threshold_type: + required: true + selector: + select: + options: + - above + - below + - between + - outside + translation_key: trigger_threshold_type + lower_limit: *number_or_entity + upper_limit: *number_or_entity diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py index 2be6b4bfac1..cb9cda846db 100644 --- a/tests/components/light/test_trigger.py +++ b/tests/components/light/test_trigger.py @@ -1,12 +1,27 @@ """Test light trigger.""" from collections.abc import Generator +from typing import Any from unittest.mock import patch import pytest -from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.components.light import ATTR_BRIGHTNESS +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, @@ -42,6 +57,8 @@ async def target_lights(hass: HomeAssistant) -> list[str]: @pytest.mark.parametrize( "trigger_key", [ + "light.brightness_changed", + "light.brightness_crossed_threshold", "light.turned_off", "light.turned_on", ], @@ -59,6 +76,147 @@ async def test_light_triggers_gated_by_labs_flag( ) in caplog.text +def parametrize_light_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_light_trigger_states( + trigger=trigger, + 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_light_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_light_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_light_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_light_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_light_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_light_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"), @@ -116,6 +274,60 @@ async def test_light_state_trigger_behavior_any( service_calls.clear() +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("light"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_xxx_changed_trigger_states( + "light.brightness_changed", ATTR_BRIGHTNESS + ), + *parametrize_xxx_crossed_threshold_trigger_states( + "light.brightness_crossed_threshold", ATTR_BRIGHTNESS + ), + ], +) +async def test_light_state_attribute_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_lights: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[StateDescription], +) -> None: + """Test that the light state trigger fires when any light state changes to a specific state.""" + other_entity_ids = set(target_lights) - {entity_id} + + # Set all lights, including the tested light, to the initial state + for eid in target_lights: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, trigger_options, 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 lights 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"), @@ -172,6 +384,58 @@ async def test_light_state_trigger_behavior_first( 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("light"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_xxx_crossed_threshold_trigger_states( + "light.brightness_crossed_threshold", ATTR_BRIGHTNESS + ), + ], +) +async def test_light_state_attribute_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_lights: list[str], + trigger_target_config: dict, + 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 light state trigger fires when the first light state changes to a specific state.""" + other_entity_ids = set(target_lights) - {entity_id} + + # Set all lights, including the tested light, to the initial state + for eid in target_lights: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger( + hass, trigger, {"behavior": "first"} | trigger_options, 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 lights 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"), @@ -225,3 +489,54 @@ async def test_light_state_trigger_behavior_last( 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("light"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_xxx_crossed_threshold_trigger_states( + "light.brightness_crossed_threshold", ATTR_BRIGHTNESS + ), + ], +) +async def test_light_state_attribute_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_lights: list[str], + trigger_target_config: dict, + 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 light state trigger fires when the last light state changes to a specific state.""" + other_entity_ids = set(target_lights) - {entity_id} + + # Set all lights, including the tested light, to the initial state + for eid in target_lights: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger( + hass, trigger, {"behavior": "last"} | trigger_options, 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() diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 95b954a49a8..c5fd2999e59 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1038,7 +1038,17 @@ async def test_subscribe_triggers( @pytest.mark.parametrize( ("new_triggers_conditions_enabled", "expected_events"), [ - (True, [{"light.turned_off", "light.turned_on"}]), + ( + True, + [ + { + "light.brightness_changed", + "light.brightness_crossed_threshold", + "light.turned_off", + "light.turned_on", + } + ], + ), (False, []), ], )