diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index c0b478e895de5e..fc56445cd1b2ea 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -25,5 +25,10 @@ "turn_on": { "service": "mdi:lightbulb-on" } + }, + "triggers": { + "state": { + "trigger": "mdi:state-machine" + } } } diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index a17d6793b83e2a..ba78fc567a383a 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -133,6 +133,13 @@ } }, "selector": { + "behavior": { + "options": { + "first": "First", + "last": "Last", + "any": "Any" + } + }, "color_name": { "options": { "homeassistant": "Home Assistant", @@ -290,6 +297,12 @@ "short": "Short", "long": "Long" } + }, + "state": { + "options": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "services": { @@ -462,5 +475,22 @@ } } } + }, + "triggers": { + "state": { + "name": "State", + "description": "When the state of a light changes, such as turning on or off.", + "description_configured": "When the state of a light changes", + "fields": { + "state": { + "name": "State", + "description": "The state to trigger on." + }, + "behavior": { + "name": "Behavior", + "description": "The behavior of the targeted entities to trigger on." + } + } + } } } diff --git a/homeassistant/components/light/trigger.py b/homeassistant/components/light/trigger.py new file mode 100644 index 00000000000000..d32042926a5632 --- /dev/null +++ b/homeassistant/components/light/trigger.py @@ -0,0 +1,165 @@ +"""Provides triggers for lights.""" + +from typing import Final, cast, override + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PLATFORM, + CONF_STATE, + CONF_TARGET, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import ( + CALLBACK_TYPE, + HassJob, + HomeAssistant, + callback, + split_entity_id, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import process_state_match +from homeassistant.helpers.target import ( + TargetStateChangedData, + async_track_target_selector_state_change_event, +) +from homeassistant.helpers.trigger import Trigger, TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +# remove when #151314 is merged +CONF_OPTIONS: Final = "options" + +ATTR_BEHAVIOR: Final = "behavior" +BEHAVIOR_FIRST: Final = "first" +BEHAVIOR_LAST: Final = "last" +BEHAVIOR_ANY: Final = "any" + +STATE_PLATFORM_TYPE: Final = "state" +STATE_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_STATE): vol.In([STATE_ON, STATE_OFF]), + vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( + [BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY] + ), + }, + vol.Required(CONF_TARGET): cv.TARGET_FIELDS, + } +) + + +class StateTrigger(Trigger): + """Trigger for state changes.""" + + @override + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, STATE_TRIGGER_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: dict) -> None: + """Initialize the state trigger.""" + self.hass = hass + self.config = config + + @override + async def async_attach( + self, action: TriggerActionType, trigger_info: TriggerInfo + ) -> CALLBACK_TYPE: + """Attach the trigger.""" + job = HassJob(action, f"light state trigger {trigger_info}") + trigger_data = trigger_info["trigger_data"] + config_options = self.config[CONF_OPTIONS] + + match_config_state = process_state_match(config_options.get(CONF_STATE)) + + def check_all_match(entity_ids: set[str]) -> bool: + """Check if all entity states match.""" + return all( + match_config_state(state.state) + for entity_id in entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ) + + def check_one_match(entity_ids: set[str]) -> bool: + """Check that only one entity state matches.""" + return ( + sum( + match_config_state(state.state) + for entity_id in entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ) + == 1 + ) + + behavior = config_options.get(ATTR_BEHAVIOR) + + @callback + def state_change_listener( + target_state_change_data: TargetStateChangedData, + ) -> None: + """Listen for state changes and call action.""" + event = target_state_change_data.state_change_event + entity_id = event.data["entity_id"] + from_state = event.data["old_state"] + to_state = event.data["new_state"] + + if to_state is None: + return + + # This check is required for "first" behavior, to check that it went from zero + # entities matching the state to one. Otherwise, if previously there were two + # entities on CONF_STATE and one changed, this would trigger. + # For "last" behavior it is not required, but serves as a quicker fail check. + if not match_config_state(to_state.state): + return + if behavior == BEHAVIOR_LAST: + if not check_all_match(target_state_change_data.targeted_entity_ids): + return + elif behavior == BEHAVIOR_FIRST: + if not check_one_match(target_state_change_data.targeted_entity_ids): + return + + self.hass.async_run_hass_job( + job, + { + "trigger": { + **trigger_data, + CONF_PLATFORM: self.config[CONF_PLATFORM], + ATTR_ENTITY_ID: entity_id, + "from_state": from_state, + "to_state": to_state, + "description": f"state of {entity_id}", + } + }, + event.context, + ) + + def entity_filter(entities: set[str]) -> set[str]: + """Filter entities of this domain.""" + return { + entity_id + for entity_id in entities + if split_entity_id(entity_id)[0] == DOMAIN + } + + target_config = self.config[CONF_TARGET] + return async_track_target_selector_state_change_event( + self.hass, target_config, state_change_listener, entity_filter + ) + + +TRIGGERS: dict[str, type[Trigger]] = { + STATE_PLATFORM_TYPE: StateTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for lights.""" + return TRIGGERS diff --git a/homeassistant/components/light/triggers.yaml b/homeassistant/components/light/triggers.yaml new file mode 100644 index 00000000000000..78b6f41a1e407f --- /dev/null +++ b/homeassistant/components/light/triggers.yaml @@ -0,0 +1,22 @@ +state: + target: + entity: + domain: light + fields: + state: + required: true + default: "on" + selector: + select: + options: + - "off" + - "on" + behavior: + required: true + default: any + selector: + select: + options: + - first + - last + - any diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py new file mode 100644 index 00000000000000..4a8c8778b2af54 --- /dev/null +++ b/tests/components/light/test_trigger.py @@ -0,0 +1,283 @@ +"""Test light trigger.""" + +import pytest + +from homeassistant.components import automation +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_DEVICE_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_STATE, + CONF_TARGET, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, + label_registry as lr, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_device_registry + +# remove when #151314 is merged +CONF_OPTIONS = "options" + + +@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 +async def target_lights(hass: HomeAssistant) -> None: + """Create multiple light entities associated with different targets.""" + await async_setup_component(hass, "light", {}) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + floor_reg = fr.async_get(hass) + floor = floor_reg.async_create("Test Floor") + + area_reg = ar.async_get(hass) + area = area_reg.async_create("Test Area", floor_id=floor.floor_id) + + label_reg = lr.async_get(hass) + label = label_reg.async_create("Test Label") + + device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id}) + mock_device_registry(hass, {device.id: device}) + + entity_reg = er.async_get(hass) + # Light associated with area + light_area = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="light_area", + suggested_object_id="area_light", + ) + entity_reg.async_update_entity(light_area.entity_id, area_id=area.id) + + # Light associated with device + entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="light_device", + suggested_object_id="device_light", + device_id=device.id, + ) + + # Light associated with label + light_label = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="light_label", + suggested_object_id="label_light", + ) + entity_reg.async_update_entity(light_label.entity_id, labels={label.label_id}) + + # Return all available light entities + return [ + "light.standalone_light", + "light.label_light", + "light.area_light", + "light.device_light", + ] + + +@pytest.mark.usefixtures("target_lights") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id"), + [ + ({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"), + ({ATTR_LABEL_ID: "test_label"}, "light.label_light"), + ({ATTR_AREA_ID: "test_area"}, "light.area_light"), + ({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"), + ({ATTR_LABEL_ID: "test_label"}, "light.device_light"), + ({ATTR_AREA_ID: "test_area"}, "light.device_light"), + ({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"), + ({ATTR_DEVICE_ID: "test_device"}, "light.device_light"), + ], +) +@pytest.mark.parametrize( + ("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)] +) +async def test_light_state_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_target_config: dict, + entity_id: str, + state: str, + reverse_state: str, +) -> None: + """Test that the light state trigger fires when any light state changes to a specific state.""" + await async_setup_component(hass, "light", {}) + + hass.states.async_set(entity_id, reverse_state) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: "light.state", + CONF_TARGET: {**trigger_target_config}, + CONF_OPTIONS: {CONF_STATE: state}, + }, + "action": { + "service": "test.automation", + "data_template": {CONF_ENTITY_ID: f"{entity_id}"}, + }, + } + }, + ) + + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + hass.states.async_set(entity_id, reverse_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id"), + [ + ({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"), + ({ATTR_LABEL_ID: "test_label"}, "light.label_light"), + ({ATTR_AREA_ID: "test_area"}, "light.area_light"), + ({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"), + ({ATTR_LABEL_ID: "test_label"}, "light.device_light"), + ({ATTR_AREA_ID: "test_area"}, "light.device_light"), + ({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"), + ({ATTR_DEVICE_ID: "test_device"}, "light.device_light"), + ], +) +@pytest.mark.parametrize( + ("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)] +) +async def test_light_state_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_lights: list[str], + trigger_target_config: dict, + entity_id: str, + state: str, + reverse_state: str, +) -> None: + """Test that the light state trigger fires when the first light changes to a specific state.""" + await async_setup_component(hass, "light", {}) + + for other_entity_id in target_lights: + hass.states.async_set(other_entity_id, reverse_state) + await hass.async_block_till_done() + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: "light.state", + CONF_TARGET: {**trigger_target_config}, + CONF_OPTIONS: {CONF_STATE: state, "behavior": "first"}, + }, + "action": { + "service": "test.automation", + "data_template": {CONF_ENTITY_ID: f"{entity_id}"}, + }, + } + }, + ) + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Triggering other lights should not cause any service calls after the first one + for other_entity_id in target_lights: + hass.states.async_set(other_entity_id, state) + await hass.async_block_till_done() + for other_entity_id in target_lights: + hass.states.async_set(other_entity_id, reverse_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id"), + [ + ({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"), + ({ATTR_LABEL_ID: "test_label"}, "light.label_light"), + ({ATTR_AREA_ID: "test_area"}, "light.area_light"), + ({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"), + ({ATTR_LABEL_ID: "test_label"}, "light.device_light"), + ({ATTR_AREA_ID: "test_area"}, "light.device_light"), + ({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"), + ({ATTR_DEVICE_ID: "test_device"}, "light.device_light"), + ], +) +@pytest.mark.parametrize( + ("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)] +) +async def test_light_state_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_lights: list[str], + trigger_target_config: dict, + entity_id: str, + state: str, + reverse_state: str, +) -> None: + """Test that the light state trigger fires when the last light changes to a specific state.""" + await async_setup_component(hass, "light", {}) + + for other_entity_id in target_lights: + hass.states.async_set(other_entity_id, reverse_state) + await hass.async_block_till_done() + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: "light.state", + CONF_TARGET: {**trigger_target_config}, + CONF_OPTIONS: {CONF_STATE: state, "behavior": "last"}, + }, + "action": { + "service": "test.automation", + "data_template": {CONF_ENTITY_ID: f"{entity_id}"}, + }, + } + }, + ) + + target_lights.remove(entity_id) + for other_entity_id in target_lights: + hass.states.async_set(other_entity_id, state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + assert len(service_calls) == 1 diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 3593db9cf8784a..e20e0dd9233589 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -450,10 +450,10 @@ async def test_caching(hass: HomeAssistant) -> None: side_effect=translation.build_resources, ) as mock_build_resources: load1 = await translation.async_get_translations(hass, "en", "entity_component") - assert len(mock_build_resources.mock_calls) == 7 + assert len(mock_build_resources.mock_calls) == 8 load2 = await translation.async_get_translations(hass, "en", "entity_component") - assert len(mock_build_resources.mock_calls) == 7 + assert len(mock_build_resources.mock_calls) == 8 assert load1 == load2