diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index b54fe788a3d..2c6e80e5f49 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -481,8 +481,11 @@ async def websocket_device_automation_get_condition_capabilities( @websocket_api.websocket_command( { vol.Required("type"): "device_automation/trigger/capabilities", - vol.Required("trigger"): DEVICE_TRIGGER_BASE_SCHEMA.extend( - {}, extra=vol.ALLOW_EXTRA + # The frontend responds with `trigger` as key, while the + # `DEVICE_TRIGGER_BASE_SCHEMA` expects `platform1` as key. + vol.Required("trigger"): vol.All( + cv._backward_compat_trigger_schema, # noqa: SLF001 + DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), ), } ) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4ce98d7e69c..c5648a9e096 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -282,6 +282,7 @@ CONF_THEN: Final = "then" CONF_TIMEOUT: Final = "timeout" CONF_TIME_ZONE: Final = "time_zone" CONF_TOKEN: Final = "token" +CONF_TRIGGER: Final = "trigger" CONF_TRIGGERS: Final = "triggers" CONF_TRIGGER_TIME: Final = "trigger_time" CONF_TTL: Final = "ttl" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index fd8d54fc6e0..8b190abad92 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -4,7 +4,7 @@ # with PEP 695 syntax. Fixed in Python 3.13. # from __future__ import annotations -from collections.abc import Callable, Hashable +from collections.abc import Callable, Hashable, Mapping import contextlib from contextvars import ContextVar from datetime import ( @@ -81,6 +81,7 @@ from homeassistant.const import ( CONF_TARGET, CONF_THEN, CONF_TIMEOUT, + CONF_TRIGGER, CONF_TRIGGERS, CONF_UNTIL, CONF_VALUE_TEMPLATE, @@ -1769,6 +1770,30 @@ CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema( ) ) + +def _backward_compat_trigger_schema(value: Any | None) -> Any: + """Rewrite trigger `trigger` to `platform`. + + `platform` has been renamed to `trigger` in user documentation and in the automation + editor. The Python trigger implementation still uses `platform`, so we need to + rename `trigger` to `platform. + """ + + if not isinstance(value, Mapping): + # If the value is not a mapping, we let that be handled by the TRIGGER_SCHEMA + return value + + if CONF_TRIGGER in value: + if CONF_PLATFORM in value: + raise vol.Invalid( + "Cannot specify both 'platform' and 'trigger'. Please use 'trigger' only." + ) + value = dict(value) + value[CONF_PLATFORM] = value.pop(CONF_TRIGGER) + + return value + + TRIGGER_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_ALIAS): str, @@ -1804,7 +1829,9 @@ def _base_trigger_validator(value: Any) -> Any: TRIGGER_SCHEMA = vol.All( - ensure_list, _base_trigger_list_flatten, [_base_trigger_validator] + ensure_list, + _base_trigger_list_flatten, + [vol.All(_backward_compat_trigger_schema, _base_trigger_validator)], ) _SCRIPT_DELAY_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b8d8d66615d..8b2e0660687 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -602,7 +602,7 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return {"type": "string", "format": "time"} if isinstance(schema, selector.TriggerSelector): - return convert(cv.TRIGGER_SCHEMA) + return {"type": "array", "items": {"type": "string"}} if schema.config.get("multiple"): return {"type": "array", "items": {"type": "string"}} diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index aaeb4f2e41e..2bdc0f7516b 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1971,37 +1971,37 @@ async def test_extraction_functions( { "alias": "test1", "triggers": [ - {"platform": "state", "entity_id": "sensor.trigger_state"}, + {"trigger": "state", "entity_id": "sensor.trigger_state"}, { - "platform": "numeric_state", + "trigger": "numeric_state", "entity_id": "sensor.trigger_numeric_state", "above": 10, }, { - "platform": "calendar", + "trigger": "calendar", "entity_id": "calendar.trigger_calendar", "event": "start", }, { - "platform": "event", + "trigger": "event", "event_type": "state_changed", "event_data": {"entity_id": "sensor.trigger_event"}, }, # entity_id is a list of strings (not supported) { - "platform": "event", + "trigger": "event", "event_type": "state_changed", "event_data": {"entity_id": ["sensor.trigger_event2"]}, }, # entity_id is not a valid entity ID { - "platform": "event", + "trigger": "event", "event_type": "state_changed", "event_data": {"entity_id": "abc"}, }, # entity_id is not a string { - "platform": "event", + "trigger": "event", "event_type": "state_changed", "event_data": {"entity_id": 123}, }, @@ -2044,36 +2044,36 @@ async def test_extraction_functions( "alias": "test2", "triggers": [ { - "platform": "device", + "trigger": "device", "domain": "light", "type": "turned_on", "entity_id": "light.trigger_2", "device_id": trigger_device_2.id, }, { - "platform": "tag", + "trigger": "tag", "tag_id": "1234", "device_id": "device-trigger-tag1", }, { - "platform": "tag", + "trigger": "tag", "tag_id": "1234", "device_id": ["device-trigger-tag2", "device-trigger-tag3"], }, { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"device_id": "device-trigger-event"}, }, # device_id is a list of strings (not supported) { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"device_id": ["device-trigger-event2"]}, }, # device_id is not a string { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"device_id": 123}, }, @@ -2114,19 +2114,19 @@ async def test_extraction_functions( "alias": "test3", "triggers": [ { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"area_id": "area-trigger-event"}, }, # area_id is a list of strings (not supported) { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"area_id": ["area-trigger-event2"]}, }, # area_id is not a string { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"area_id": 123}, }, @@ -2287,7 +2287,7 @@ async def test_automation_variables( "event_type": "{{ trigger.event.event_type }}", "this_variables": "{{this.entity_id}}", }, - "triggers": {"platform": "event", "event_type": "test_event"}, + "triggers": {"trigger": "event", "event_type": "test_event"}, "actions": { "action": "test.automation", "data": { @@ -2302,7 +2302,7 @@ async def test_automation_variables( "variables": { "test_var": "defined_in_config", }, - "trigger": {"platform": "event", "event_type": "test_event_2"}, + "trigger": {"trigger": "event", "event_type": "test_event_2"}, "conditions": { "condition": "template", "value_template": "{{ trigger.event.data.pass_condition }}", @@ -2315,7 +2315,7 @@ async def test_automation_variables( "variables": { "test_var": "{{ trigger.event.data.break + 1 }}", }, - "triggers": {"platform": "event", "event_type": "test_event_3"}, + "triggers": {"trigger": "event", "event_type": "test_event_3"}, "actions": { "action": "test.automation", }, @@ -2371,7 +2371,7 @@ async def test_automation_trigger_variables( "trigger_variables": { "test_var": "defined_in_config", }, - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "action": "test.automation", "data": { @@ -2389,7 +2389,7 @@ async def test_automation_trigger_variables( "test_var": "defined_in_config", "this_trigger_variables": "{{this.entity_id}}", }, - "trigger": {"platform": "event", "event_type": "test_event_2"}, + "trigger": {"trigger": "event", "event_type": "test_event_2"}, "action": { "action": "test.automation", "data": { @@ -2436,7 +2436,7 @@ async def test_automation_bad_trigger_variables( "trigger_variables": { "test_var": "{{ states('foo.bar') }}", }, - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "action": "test.automation", }, @@ -2463,7 +2463,7 @@ async def test_automation_this_var_always( { automation.DOMAIN: [ { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "action": "test.automation", "data": { @@ -2739,7 +2739,7 @@ async def test_trigger_service(hass: HomeAssistant, calls: list[ServiceCall]) -> { automation.DOMAIN: { "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "action": "test.automation", "data_template": {"trigger": "{{ trigger }}"}, @@ -2771,9 +2771,9 @@ async def test_trigger_condition_implicit_id( { automation.DOMAIN: { "trigger": [ - {"platform": "event", "event_type": "test_event1"}, - {"platform": "event", "event_type": "test_event2"}, - {"platform": "event", "event_type": "test_event3"}, + {"trigger": "event", "event_type": "test_event1"}, + {"trigger": "event", "event_type": "test_event2"}, + {"trigger": "event", "event_type": "test_event3"}, ], "action": { "choose": [ @@ -2823,8 +2823,8 @@ async def test_trigger_condition_explicit_id( { automation.DOMAIN: { "trigger": [ - {"platform": "event", "event_type": "test_event1", "id": "one"}, - {"platform": "event", "event_type": "test_event2", "id": "two"}, + {"trigger": "event", "event_type": "test_event1", "id": "one"}, + {"trigger": "event", "event_type": "test_event2", "id": "two"}, ], "action": { "choose": [ @@ -2938,7 +2938,7 @@ async def test_recursive_automation_starting_script( automation.DOMAIN: { "mode": automation_mode, "trigger": [ - {"platform": "event", "event_type": "trigger_automation"}, + {"trigger": "event", "event_type": "trigger_automation"}, ], "action": [ {"action": "test.automation_started"}, @@ -3020,7 +3020,7 @@ async def test_recursive_automation( automation.DOMAIN: { "mode": automation_mode, "trigger": [ - {"platform": "event", "event_type": "trigger_automation"}, + {"trigger": "event", "event_type": "trigger_automation"}, ], "action": [ {"event": "trigger_automation"}, @@ -3082,7 +3082,7 @@ async def test_recursive_automation_restart_mode( automation.DOMAIN: { "mode": SCRIPT_MODE_RESTART, "trigger": [ - {"platform": "event", "event_type": "trigger_automation"}, + {"trigger": "event", "event_type": "trigger_automation"}, ], "action": [ {"event": "trigger_automation"}, @@ -3121,7 +3121,7 @@ async def test_websocket_config( """Test config command.""" config = { "alias": "hello", - "triggers": {"platform": "event", "event_type": "test_event"}, + "triggers": {"trigger": "event", "event_type": "test_event"}, "actions": {"action": "test.automation", "data": 100}, } assert await async_setup_component( @@ -3191,7 +3191,7 @@ async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> Non automation.DOMAIN: [ { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": "binary_sensor.presence", "from": "on", }, @@ -3209,7 +3209,7 @@ async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> Non }, { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": "binary_sensor.presence", "from": "on", "for": { @@ -3302,7 +3302,7 @@ async def test_two_automations_call_restart_script_same_time( automation.DOMAIN: [ { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": "binary_sensor.presence", "to": "on", }, @@ -3314,7 +3314,7 @@ async def test_two_automations_call_restart_script_same_time( }, { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": "binary_sensor.presence", "to": "on", }, @@ -3360,7 +3360,7 @@ async def test_two_automation_call_restart_script_right_after_each_other( automation.DOMAIN: [ { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": ["input_boolean.test_1", "input_boolean.test_1"], "from": "off", "to": "on", @@ -3419,7 +3419,7 @@ async def test_action_backward_compatibility( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "condition": { "condition": "template", "value_template": "{{ True }}", @@ -3467,6 +3467,17 @@ async def test_action_backward_compatibility( }, "Cannot specify both 'action' and 'actions'. Please use 'actions' only.", ), + ( + { + "trigger": { + "platform": "event", + "trigger": "event", + "event_type": "test_event2", + }, + "action": [], + }, + "Cannot specify both 'platform' and 'trigger'. Please use 'trigger' only.", + ), ], ) async def test_invalid_configuration( @@ -3483,3 +3494,28 @@ async def test_invalid_configuration( ) await hass.async_block_till_done() assert message in caplog.text + + +@pytest.mark.parametrize( + ("trigger_key"), + ["trigger", "platform"], +) +async def test_valid_configuration( + hass: HomeAssistant, + trigger_key: str, +) -> None: + """Test for valid automation configurations.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "triggers": { + trigger_key: "event", + "event_type": "test_event2", + }, + "action": [], + } + }, + ) + await hass.async_block_till_done() diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index 513fee566db..c1defdd0339 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -39,7 +39,7 @@ async def test_exclude_attributes( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "actions": {"action": "test.automation", "entity_id": "hello.world"}, } }, diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index f8ff0fdd540..921088d8ac6 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -224,7 +224,7 @@ async def test_save_blueprint( " service_to_call:\n a_number:\n selector:\n number:\n " " mode: box\n step: 1.0\n source_url:" " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntriggers:\n" - " platform: event\n event_type: !input 'trigger_event'\nactions:\n " + " trigger: event\n event_type: !input 'trigger_event'\nactions:\n " " service: !input 'service_to_call'\n entity_id: light.kitchen\n" # c dumper will not quote the value after !input "blueprint:\n name: Call service based on event\n domain: automation\n " @@ -232,7 +232,7 @@ async def test_save_blueprint( " service_to_call:\n a_number:\n selector:\n number:\n " " mode: box\n step: 1.0\n source_url:" " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntriggers:\n" - " platform: event\n event_type: !input trigger_event\nactions:\n service:" + " trigger: event\n event_type: !input trigger_event\nactions:\n service:" " !input service_to_call\n entity_id: light.kitchen\n" ) # Make sure ita parsable and does not raise @@ -500,7 +500,7 @@ async def test_substituting_blueprint_inputs( }, "triggers": { "event_type": "test_event", - "platform": "event", + "trigger": "event", }, } diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 9cd2a25de3a..40a9c85a8d3 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -110,14 +110,14 @@ async def test_update_automation_config( ), ( { - "trigger": {"platform": "automation"}, + "trigger": {"trigger": "automation"}, "action": [], }, "Integration 'automation' does not provide trigger support", ), ( { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "condition": { "condition": "state", # The UUID will fail being resolved to en entity_id @@ -130,7 +130,7 @@ async def test_update_automation_config( ), ( { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "condition": "state", # The UUID will fail being resolved to en entity_id @@ -336,12 +336,12 @@ async def test_bad_formatted_automations( [ { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": {"service": "test.automation"}, }, { "id": "moon", - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": {"service": "test.automation"}, }, ], diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index cb1abecd6ff..ab8dfcf756f 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -720,12 +720,17 @@ async def test_async_get_device_automations_all_devices_action_exception_throw( assert "KeyError" in caplog.text +@pytest.mark.parametrize( + "trigger_key", + ["trigger", "platform"], +) async def test_websocket_get_trigger_capabilities( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, fake_integration, + trigger_key: str, ) -> None: """Test we get the expected trigger capabilities through websocket.""" await async_setup_component(hass, "device_automation", {}) @@ -767,11 +772,12 @@ async def test_websocket_get_trigger_capabilities( assert msg["id"] == 1 assert msg["type"] == TYPE_RESULT assert msg["success"] - triggers = msg["result"] + triggers: dict = msg["result"] msg_id = 2 assert len(triggers) == 3 # toggled, turned_on, turned_off for trigger in triggers: + trigger[trigger_key] = trigger.pop("platform") await client.send_json( { "id": msg_id, diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 0eae0c88581..4fd87d6d2fe 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1841,7 +1841,7 @@ async def test_nested_trigger_list() -> None: "event_type": "trigger_3", }, { - "platform": "event", + "trigger": "event", "event_type": "trigger_4", }, ], @@ -1891,7 +1891,36 @@ async def test_nested_trigger_list_extra() -> None: validated_triggers = TRIGGER_SCHEMA(trigger_config) - assert validated_triggers == trigger_config + assert validated_triggers == [ + { + "platform": "other", + "triggers": [ + { + "platform": "event", + "event_type": "trigger_1", + }, + { + "platform": "event", + "event_type": "trigger_2", + }, + ], + }, + ] + + +async def test_trigger_backwards_compatibility() -> None: + """Test triggers with backwards compatibility.""" + + assert cv._backward_compat_trigger_schema("str") == "str" + assert cv._backward_compat_trigger_schema({"platform": "abc"}) == { + "platform": "abc" + } + assert cv._backward_compat_trigger_schema({"trigger": "abc"}) == {"platform": "abc"} + with pytest.raises( + vol.Invalid, + match="Cannot specify both 'platform' and 'trigger'. Please use 'trigger' only.", + ): + cv._backward_compat_trigger_schema({"trigger": "abc", "platform": "def"}) async def test_is_entity_service_schema( diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index de8c3555831..f73808a0625 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1,6 +1,7 @@ """Test selectors.""" from enum import Enum +from typing import Any import pytest import voluptuous as vol @@ -1107,6 +1108,13 @@ def test_condition_selector_schema( ( {}, ( + [ + { + "platform": "numeric_state", + "entity_id": ["sensor.temperature"], + "below": 20, + } + ], [ { "platform": "numeric_state", @@ -1122,7 +1130,24 @@ def test_condition_selector_schema( ) def test_trigger_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test trigger sequence selector.""" - _test_selector("trigger", schema, valid_selections, invalid_selections) + + def _custom_trigger_serializer( + triggers: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + res = [] + for trigger in triggers: + if "trigger" in trigger: + trigger["platform"] = trigger.pop("trigger") + res.append(trigger) + return res + + _test_selector( + "trigger", + schema, + valid_selections, + invalid_selections, + _custom_trigger_serializer, + ) @pytest.mark.parametrize( diff --git a/tests/testing_config/blueprints/automation/test_event_service.yaml b/tests/testing_config/blueprints/automation/test_event_service.yaml index 035278df258..ec11f24fc63 100644 --- a/tests/testing_config/blueprints/automation/test_event_service.yaml +++ b/tests/testing_config/blueprints/automation/test_event_service.yaml @@ -11,7 +11,7 @@ blueprint: number: mode: "box" triggers: - platform: event + trigger: event event_type: !input trigger_event actions: service: !input service_to_call