diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index fcce230e386..e6101df2919 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -80,6 +80,16 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } +SCRIPT_FIELDS = ( + CONF_ARM_AWAY_ACTION, + CONF_ARM_CUSTOM_BYPASS_ACTION, + CONF_ARM_HOME_ACTION, + CONF_ARM_NIGHT_ACTION, + CONF_ARM_VACATION_ACTION, + CONF_DISARM_ACTION, + CONF_TRIGGER_ACTION, +) + DEFAULT_NAME = "Template Alarm Control Panel" ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema( @@ -152,6 +162,7 @@ async def async_setup_entry( StateAlarmControlPanelEntity, ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, True, + script_options=SCRIPT_FIELDS, ) @@ -172,6 +183,7 @@ async def async_setup_platform( discovery_info, LEGACY_FIELDS, legacy_key=CONF_ALARM_CONTROL_PANELS, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 0c5c10b2e5f..48f9ed19530 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -36,6 +36,8 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False +SCRIPT_FIELDS = (CONF_PRESS,) + BUTTON_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, @@ -66,6 +68,7 @@ async def async_setup_platform( None, async_add_entities, discovery_info, + script_options=SCRIPT_FIELDS, ) @@ -81,6 +84,7 @@ async def async_setup_entry( async_add_entities, StateButtonEntity, BUTTON_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index fd0979de040..397c777e36f 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -71,6 +71,14 @@ CONF_TILT_OPTIMISTIC = "tilt_optimistic" CONF_OPEN_AND_CLOSE = "open_or_close" +SCRIPT_FIELDS = ( + CLOSE_ACTION, + OPEN_ACTION, + POSITION_ACTION, + STOP_ACTION, + TILT_ACTION, +) + TILT_FEATURES = ( CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT @@ -165,6 +173,7 @@ async def async_setup_platform( discovery_info, LEGACY_FIELDS, legacy_key=CONF_COVERS, + script_options=SCRIPT_FIELDS, ) @@ -181,6 +190,7 @@ async def async_setup_entry( StateCoverEntity, COVER_CONFIG_ENTRY_SCHEMA, True, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index feb31129b3c..bb12499d13a 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -87,6 +87,15 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Fan" +SCRIPT_FIELDS = ( + CONF_OFF_ACTION, + CONF_ON_ACTION, + CONF_SET_DIRECTION_ACTION, + CONF_SET_OSCILLATING_ACTION, + CONF_SET_PERCENTAGE_ACTION, + CONF_SET_PRESET_MODE_ACTION, +) + FAN_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_DIRECTION): cv.template, @@ -159,6 +168,7 @@ async def async_setup_platform( discovery_info, LEGACY_FIELDS, legacy_key=CONF_FANS, + script_options=SCRIPT_FIELDS, ) @@ -174,6 +184,7 @@ async def async_setup_entry( async_add_entities, StateFanEntity, FAN_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index bf408100a00..38465d26813 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -8,6 +8,7 @@ import logging from typing import Any import voluptuous as vol +from voluptuous.humanize import humanize_error from homeassistant.components import blueprint from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import issue_registry as ir, template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import ( @@ -34,6 +35,7 @@ from homeassistant.helpers.entity_platform import ( async_get_platforms, ) from homeassistant.helpers.issue_registry import IssueSeverity +from homeassistant.helpers.script import async_validate_actions_config from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -208,6 +210,21 @@ def _format_template(value: Any, field: str | None = None) -> Any: return str(value) +def _get_config_breadcrumbs(config: ConfigType) -> str: + """Try to coerce entity information from the config.""" + breadcrumb = "Template Entity" + # Default entity id should be in most legacy configuration because + # it's created from the legacy slug. Vacuum and Lock do not have a + # slug, therefore we need to use the name or unique_id. + if (default_entity_id := config.get(CONF_DEFAULT_ENTITY_ID)) is not None: + breadcrumb = default_entity_id.split(".")[-1] + elif (unique_id := config.get(CONF_UNIQUE_ID)) is not None: + breadcrumb = f"unique_id: {unique_id}" + elif (name := config.get(CONF_NAME)) and isinstance(name, template.Template): + breadcrumb = name.template + return breadcrumb + + def format_migration_config( config: ConfigType | list[ConfigType], depth: int = 0 ) -> ConfigType | list[ConfigType]: @@ -252,16 +269,7 @@ def create_legacy_template_issue( if domain not in PLATFORMS: return - breadcrumb = "Template Entity" - # Default entity id should be in most legacy configuration because - # it's created from the legacy slug. Vacuum and Lock do not have a - # slug, therefore we need to use the name or unique_id. - if (default_entity_id := config.get(CONF_DEFAULT_ENTITY_ID)) is not None: - breadcrumb = default_entity_id.split(".")[-1] - elif (unique_id := config.get(CONF_UNIQUE_ID)) is not None: - breadcrumb = f"unique_id: {unique_id}" - elif (name := config.get(CONF_NAME)) and isinstance(name, template.Template): - breadcrumb = name.template + breadcrumb = _get_config_breadcrumbs(config) issue_id = f"{LEGACY_TEMPLATE_DEPRECATION_KEY}_{domain}_{breadcrumb}_{hashlib.md5(','.join(config.keys()).encode()).hexdigest()}" @@ -296,6 +304,39 @@ def create_legacy_template_issue( ) +async def validate_template_scripts( + hass: HomeAssistant, + config: ConfigType, + script_options: tuple[str, ...] | None = None, +) -> None: + """Validate template scripts.""" + if not script_options: + return + + def _humanize(err: Exception, data: Any) -> str: + """Humanize vol.Invalid, stringify other exceptions.""" + if isinstance(err, vol.Invalid): + return humanize_error(data, err) + return str(err) + + breadcrumb: str | None = None + for script_option in script_options: + if (script_config := config.pop(script_option, None)) is not None: + try: + config[script_option] = await async_validate_actions_config( + hass, script_config + ) + except (vol.Invalid, HomeAssistantError) as err: + if not breadcrumb: + breadcrumb = _get_config_breadcrumbs(config) + _LOGGER.error( + "The '%s' actions for %s failed to setup: %s", + script_option, + breadcrumb, + _humanize(err, script_config), + ) + + async def async_setup_template_platform( hass: HomeAssistant, domain: str, @@ -306,6 +347,7 @@ async def async_setup_template_platform( discovery_info: DiscoveryInfoType | None, legacy_fields: dict[str, str] | None = None, legacy_key: str | None = None, + script_options: tuple[str, ...] | None = None, ) -> None: """Set up the Template platform.""" if discovery_info is None: @@ -337,10 +379,14 @@ async def async_setup_template_platform( # Trigger Configuration if "coordinator" in discovery_info: if trigger_entity_cls: - entities = [ - trigger_entity_cls(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ] + entities = [] + for entity_config in discovery_info["entities"]: + await validate_template_scripts(hass, entity_config, script_options) + entities.append( + trigger_entity_cls( + hass, discovery_info["coordinator"], entity_config + ) + ) async_add_entities(entities) else: raise PlatformNotReady( @@ -349,6 +395,9 @@ async def async_setup_template_platform( return # Modern Configuration + for entity_config in discovery_info["entities"]: + await validate_template_scripts(hass, entity_config, script_options) + async_create_template_tracking_entities( state_entity_cls, async_add_entities, @@ -365,6 +414,7 @@ async def async_setup_template_entry( state_entity_cls: type[TemplateEntity], config_schema: vol.Schema | vol.All, replace_value_template: bool = False, + script_options: tuple[str, ...] | None = None, ) -> None: """Setup the Template from a config entry.""" options = dict(config_entry.options) @@ -377,6 +427,7 @@ async def async_setup_template_entry( options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) validated_config = config_schema(options) + await validate_template_scripts(hass, validated_config, script_options) async_add_entities( [state_entity_cls(hass, validated_config, config_entry.entry_id)] diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 2f53b17447f..00d9109e0e2 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -129,6 +129,18 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Light" +SCRIPT_FIELDS = ( + CONF_EFFECT_ACTION, + CONF_HS_ACTION, + CONF_LEVEL_ACTION, + CONF_OFF_ACTION, + CONF_ON_ACTION, + CONF_RGB_ACTION, + CONF_RGBW_ACTION, + CONF_RGBWW_ACTION, + CONF_TEMPERATURE_ACTION, +) + LIGHT_COMMON_SCHEMA = vol.Schema( { vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, @@ -142,8 +154,6 @@ LIGHT_COMMON_SCHEMA = vol.Schema( vol.Optional(CONF_MIN_MIREDS): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB): cv.template, vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, @@ -226,6 +236,7 @@ async def async_setup_platform( discovery_info, LEGACY_FIELDS, legacy_key=CONF_LIGHTS, + script_options=SCRIPT_FIELDS, ) @@ -242,6 +253,7 @@ async def async_setup_entry( StateLightEntity, LIGHT_CONFIG_ENTRY_SCHEMA, True, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 094ae1b2abd..8f122ff8ce1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -64,6 +64,13 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } +SCRIPT_FIELDS = ( + CONF_LOCK, + CONF_OPEN, + CONF_UNLOCK, +) + + LOCK_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_CODE_FORMAT): cv.template, @@ -112,6 +119,7 @@ async def async_setup_platform( async_add_entities, discovery_info, LEGACY_FIELDS, + script_options=SCRIPT_FIELDS, ) @@ -127,6 +135,7 @@ async def async_setup_entry( async_add_entities, StateLockEntity, LOCK_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index dfdc69b8a37..9c49da87021 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -46,6 +46,8 @@ CONF_SET_VALUE = "set_value" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False +SCRIPT_FIELDS = (CONF_SET_VALUE,) + NUMBER_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, @@ -81,6 +83,7 @@ async def async_setup_platform( TriggerNumberEntity, async_add_entities, discovery_info, + script_options=SCRIPT_FIELDS, ) @@ -96,6 +99,7 @@ async def async_setup_entry( async_add_entities, StateNumberEntity, NUMBER_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index a023ee26fe6..019c5f1a6ca 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -47,6 +47,8 @@ CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" +SCRIPT_FIELDS = (CONF_SELECT_OPTION,) + SELECT_COMMON_SCHEMA = vol.Schema( { vol.Required(ATTR_OPTIONS): cv.template, @@ -79,6 +81,7 @@ async def async_setup_platform( TriggerSelectEntity, async_add_entities, discovery_info, + script_options=SCRIPT_FIELDS, ) @@ -94,6 +97,7 @@ async def async_setup_entry( async_add_entities, TemplateSelect, SELECT_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index acc8c7451a4..cf2ed080421 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -57,11 +57,16 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Switch" +SCRIPT_FIELDS = ( + CONF_TURN_OFF, + CONF_TURN_ON, +) + SWITCH_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, } ) @@ -109,6 +114,7 @@ async def async_setup_platform( discovery_info, LEGACY_FIELDS, legacy_key=CONF_SWITCHES, + script_options=SCRIPT_FIELDS, ) @@ -125,6 +131,7 @@ async def async_setup_entry( StateSwitchEntity, SWITCH_CONFIG_ENTRY_SCHEMA, True, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py index b3231191a34..e06c6ccfb0d 100644 --- a/homeassistant/components/template/update.py +++ b/homeassistant/components/template/update.py @@ -65,6 +65,8 @@ CONF_SPECIFIC_VERSION = "specific_version" CONF_TITLE = "title" CONF_UPDATE_PERCENTAGE = "update_percentage" +SCRIPT_FIELDS = (CONF_INSTALL,) + UPDATE_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_BACKUP, default=False): cv.boolean, @@ -105,6 +107,7 @@ async def async_setup_platform( TriggerUpdateEntity, async_add_entities, discovery_info, + script_options=SCRIPT_FIELDS, ) @@ -120,6 +123,7 @@ async def async_setup_entry( async_add_entities, StateUpdateEntity, UPDATE_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, ) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 7d5b2404c15..c04c294aca3 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -76,6 +76,16 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } +SCRIPT_FIELDS = ( + SERVICE_CLEAN_SPOT, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, +) + VACUUM_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, @@ -150,6 +160,7 @@ async def async_setup_platform( discovery_info, LEGACY_FIELDS, legacy_key=CONF_VACUUMS, + script_options=SCRIPT_FIELDS, ) @@ -165,6 +176,7 @@ async def async_setup_entry( async_add_entities, TemplateStateVacuumEntity, VACUUM_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, ) diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py index ebe1ed6822c..e4303af31f8 100644 --- a/tests/components/template/test_helpers.py +++ b/tests/components/template/test_helpers.py @@ -1,16 +1,30 @@ """The tests for template helpers.""" -import pytest +from unittest.mock import AsyncMock, Mock +import pytest +import voluptuous as vol + +from homeassistant.components.device_automation import toggle_entity from homeassistant.components.template.alarm_control_panel import ( LEGACY_FIELDS as ALARM_CONTROL_PANEL_LEGACY_FIELDS, + SCRIPT_FIELDS as ALARM_CONTROL_PANEL_SCRIPT_FIELDS, ) from homeassistant.components.template.binary_sensor import ( LEGACY_FIELDS as BINARY_SENSOR_LEGACY_FIELDS, ) -from homeassistant.components.template.button import StateButtonEntity -from homeassistant.components.template.cover import LEGACY_FIELDS as COVER_LEGACY_FIELDS -from homeassistant.components.template.fan import LEGACY_FIELDS as FAN_LEGACY_FIELDS +from homeassistant.components.template.button import ( + SCRIPT_FIELDS as BUTTON_SCRIPT_FIELDS, + StateButtonEntity, +) +from homeassistant.components.template.cover import ( + LEGACY_FIELDS as COVER_LEGACY_FIELDS, + SCRIPT_FIELDS as COVER_SCRIPT_FIELDS, +) +from homeassistant.components.template.fan import ( + LEGACY_FIELDS as FAN_LEGACY_FIELDS, + SCRIPT_FIELDS as FAN_SCRIPT_FIELDS, +) from homeassistant.components.template.helpers import ( async_setup_template_platform, create_legacy_template_issue, @@ -18,23 +32,56 @@ from homeassistant.components.template.helpers import ( rewrite_legacy_to_modern_config, rewrite_legacy_to_modern_configs, ) -from homeassistant.components.template.light import LEGACY_FIELDS as LIGHT_LEGACY_FIELDS -from homeassistant.components.template.lock import LEGACY_FIELDS as LOCK_LEGACY_FIELDS +from homeassistant.components.template.light import ( + LEGACY_FIELDS as LIGHT_LEGACY_FIELDS, + SCRIPT_FIELDS as LIGHT_SCRIPT_FIELDS, +) +from homeassistant.components.template.lock import ( + LEGACY_FIELDS as LOCK_LEGACY_FIELDS, + SCRIPT_FIELDS as LOCK_SCRIPT_FIELDS, +) +from homeassistant.components.template.number import ( + SCRIPT_FIELDS as NUMBER_SCRIPT_FIELDS, +) +from homeassistant.components.template.select import ( + SCRIPT_FIELDS as SELECT_SCRIPT_FIELDS, +) from homeassistant.components.template.sensor import ( LEGACY_FIELDS as SENSOR_LEGACY_FIELDS, ) from homeassistant.components.template.switch import ( LEGACY_FIELDS as SWITCH_LEGACY_FIELDS, + SCRIPT_FIELDS as SWITCH_SCRIPT_FIELDS, +) +from homeassistant.components.template.update import ( + SCRIPT_FIELDS as UPDATE_SCRIPT_FIELDS, ) from homeassistant.components.template.vacuum import ( LEGACY_FIELDS as VACUUM_LEGACY_FIELDS, + SCRIPT_FIELDS as VACUUM_SCRIPT_FIELDS, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + assert_action, + async_trigger, + make_test_trigger, + setup_entity, +) + +from tests.common import MockConfigEntry, mock_platform + @pytest.mark.parametrize( ("legacy_fields", "old_attr", "new_attr", "attr_template"), @@ -327,6 +374,484 @@ async def test_legacy_to_modern_configs( ] == altered_configs +async def _setup_mock_devices( + hass: HomeAssistant, + domain: str, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> tuple[TemplatePlatformSetup, dr.DeviceEntry, er.RegistryEntry]: + FAKE_DOMAIN = "fake_integration" + + hass.config.components.add(FAKE_DOMAIN) + + async def _async_get_actions( + hass: HomeAssistant, device_id: str + ) -> list[dict[str, str]]: + """List device actions.""" + return await toggle_entity.async_get_actions(hass, device_id, FAKE_DOMAIN) + + mock_platform( + hass, + f"{FAKE_DOMAIN}.device_action", + Mock( + ACTION_SCHEMA=toggle_entity.ACTION_SCHEMA.extend( + {vol.Required("domain"): FAKE_DOMAIN} + ), + async_get_actions=_async_get_actions, + async_call_action_from_config=AsyncMock(), + spec=[ + "ACTION_SCHEMA", + "async_get_actions", + "async_call_action_from_config", + ], + ), + ) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + await hass.async_block_till_done() + + platform_setup = TemplatePlatformSetup( + domain, None, "test_entity", make_test_trigger("sensor.trigger") + ) + return (platform_setup, device_entry, entity_entry) + + +async def _setup_and_test_yaml_device_action( + hass: HomeAssistant, + style: ConfigurationStyle, + domain: str, + script_fields, + extra_config: ConfigType, + test_actions: tuple[tuple[str, dict], ...], + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list, +) -> None: + + platform_setup, device_entry, entity_entry = await _setup_mock_devices( + hass, domain, device_registry, entity_registry + ) + + actions = { + action: [ + { + "action": "test.automation", + "data": { + "action": "fake_action", + "caller": platform_setup.entity_id, + }, + }, + { + "domain": "fake_integration", + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": entity_entry.id, + "metadata": {"secondary": False}, + }, + ] + for action in script_fields + } + + await setup_entity(hass, platform_setup, style, 1, {**actions, **extra_config}) + await async_trigger(hass, "sensor.trigger", "anything") + + for test_action, action_data in test_actions: + call_count = len(calls) + await hass.services.async_call( + domain, + test_action, + {"entity_id": platform_setup.entity_id, **action_data}, + blocking=True, + ) + assert_action(platform_setup, calls, call_count + 1, "fake_action") + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("domain", "script_fields", "extra_config", "test_actions"), + [ + ( + "alarm_control_panel", + ALARM_CONTROL_PANEL_SCRIPT_FIELDS, + {}, + ( + ("alarm_arm_home", {"code": "1234"}), + ("alarm_arm_away", {"code": "1234"}), + ("alarm_arm_night", {"code": "1234"}), + ("alarm_arm_vacation", {"code": "1234"}), + ("alarm_arm_custom_bypass", {"code": "1234"}), + ("alarm_disarm", {"code": "1234"}), + ("alarm_trigger", {"code": "1234"}), + ), + ), + ( + "cover", + COVER_SCRIPT_FIELDS, + {}, + ( + ("open_cover", {}), + ("close_cover", {}), + ("stop_cover", {}), + ("set_cover_position", {"position": 25}), + ("set_cover_tilt_position", {"tilt_position": 25}), + ), + ), + ( + "fan", + FAN_SCRIPT_FIELDS, + { + "preset_modes": ["auto", "low", "medium", "high"], + }, + ( + ("turn_on", {}), + ("turn_off", {}), + ("set_percentage", {"percentage": 25}), + ("set_preset_mode", {"preset_mode": "auto"}), + ("oscillate", {"oscillating": True}), + ("set_direction", {"direction": "forward"}), + ), + ), + ( + "light", + LIGHT_SCRIPT_FIELDS, + {"effect_list": "{{ ['foo', 'bar'] }}", "effect": "{{ 'foo' }}"}, + ( + ("turn_on", {"brightness": 1}), + ("turn_off", {}), + ("turn_on", {"color_temp_kelvin": 8130}), + ("turn_on", {"hs_color": (360, 100)}), + ("turn_on", {"rgb_color": (160, 78, 192)}), + ("turn_on", {"rgbw_color": (160, 78, 192, 25)}), + ("turn_on", {"rgbww_color": (160, 78, 192, 25, 55)}), + ("turn_on", {"effect": "foo"}), + ), + ), + ( + "lock", + LOCK_SCRIPT_FIELDS, + {}, + ( + ("lock", {}), + ("unlock", {}), + ("open", {}), + ), + ), + ( + "number", + NUMBER_SCRIPT_FIELDS, + {"step": "1"}, + (("set_value", {"value": 4}),), + ), + ( + "select", + SELECT_SCRIPT_FIELDS, + { + "options": "{{ ['test', 'yes', 'no'] }}", + }, + (("select_option", {"option": "test"}),), + ), + ( + "switch", + SWITCH_SCRIPT_FIELDS, + {}, + ( + ("turn_on", {}), + ("turn_off", {}), + ), + ), + ( + "update", + UPDATE_SCRIPT_FIELDS, + {"installed_version": "{{ '2.0.0' }}", "latest_version": "{{ '3.0.0' }}"}, + (("install", {}),), + ), + ( + "vacuum", + VACUUM_SCRIPT_FIELDS, + {"fan_speeds": ["low", "medium", "high"]}, + ( + ("start", {}), + ("pause", {}), + ("stop", {}), + ("return_to_base", {}), + ("clean_spot", {}), + ("locate", {}), + ("set_fan_speed", {"fan_speed": "medium"}), + ), + ), + ], +) +async def test_yaml_device_actions( + hass: HomeAssistant, + style: ConfigurationStyle, + domain: str, + script_fields, + extra_config: ConfigType, + test_actions: tuple[tuple[str, dict], ...], + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list, +) -> None: + """Test device actions in platforms that support both trigger and modern configurations.""" + await _setup_and_test_yaml_device_action( + hass, + style, + domain, + script_fields, + extra_config, + test_actions, + device_registry, + entity_registry, + calls, + ) + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN], +) +@pytest.mark.parametrize( + ("domain", "script_fields", "extra_config", "test_actions"), + [ + ( + "button", + BUTTON_SCRIPT_FIELDS, + {}, + (("press", {}),), + ), + ], +) +async def test_yaml_device_actions_modern_config( + hass: HomeAssistant, + style: ConfigurationStyle, + domain: str, + script_fields, + extra_config: str, + test_actions: tuple[tuple[str, dict], ...], + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list, +) -> None: + """Test device actions in platforms that supports modern configuration only.""" + await _setup_and_test_yaml_device_action( + hass, + style, + domain, + script_fields, + extra_config, + test_actions, + device_registry, + entity_registry, + calls, + ) + + +@pytest.mark.parametrize( + ("domain", "script_fields", "extra_config", "test_actions"), + [ + ( + "alarm_control_panel", + ALARM_CONTROL_PANEL_SCRIPT_FIELDS, + {"state": "{{ 'armed' }}"}, + ( + ("alarm_arm_home", {"code": "1234"}), + ("alarm_arm_away", {"code": "1234"}), + ("alarm_arm_night", {"code": "1234"}), + ("alarm_arm_vacation", {"code": "1234"}), + ("alarm_arm_custom_bypass", {"code": "1234"}), + ("alarm_disarm", {"code": "1234"}), + ("alarm_trigger", {"code": "1234"}), + ), + ), + ( + "button", + BUTTON_SCRIPT_FIELDS, + {}, + (("press", {}),), + ), + ( + "cover", + COVER_SCRIPT_FIELDS, + {"state": "{{ 'open' }}"}, + ( + ("open_cover", {}), + ("close_cover", {}), + ("stop_cover", {}), + ("set_cover_position", {"position": 25}), + ("set_cover_tilt_position", {"tilt_position": 25}), + ), + ), + ( + "fan", + FAN_SCRIPT_FIELDS, + { + "preset_modes": ["auto", "low", "medium", "high"], + "state": "{{ 'on' }}", + }, + ( + ("turn_on", {}), + ("turn_off", {}), + ("set_percentage", {"percentage": 25}), + ("set_preset_mode", {"preset_mode": "auto"}), + ("oscillate", {"oscillating": True}), + ("set_direction", {"direction": "forward"}), + ), + ), + ( + "light", + LIGHT_SCRIPT_FIELDS, + { + "effect_list": "{{ ['foo', 'bar'] }}", + "effect": "{{ 'foo' }}", + "state": "{{ 'on' }}", + }, + ( + ("turn_on", {"brightness": 1}), + ("turn_off", {}), + ("turn_on", {"color_temp_kelvin": 8130}), + ("turn_on", {"hs_color": (360, 100)}), + ("turn_on", {"rgb_color": (160, 78, 192)}), + ("turn_on", {"rgbw_color": (160, 78, 192, 25)}), + ("turn_on", {"rgbww_color": (160, 78, 192, 25, 55)}), + ("turn_on", {"effect": "foo"}), + ), + ), + ( + "lock", + LOCK_SCRIPT_FIELDS, + { + "state": "{{ 'on' }}", + }, + ( + ("lock", {}), + ("unlock", {}), + ("open", {}), + ), + ), + ( + "number", + NUMBER_SCRIPT_FIELDS, + {"step": 1}, + (("set_value", {"value": 4}),), + ), + ( + "select", + SELECT_SCRIPT_FIELDS, + { + "state": "{{ 'yes' }}", + "options": "{{ ['test', 'yes', 'no'] }}", + }, + (("select_option", {"option": "test"}),), + ), + ( + "switch", + SWITCH_SCRIPT_FIELDS, + { + "state": "{{ 'on' }}", + }, + ( + ("turn_on", {}), + ("turn_off", {}), + ), + ), + ( + "update", + UPDATE_SCRIPT_FIELDS, + {"installed_version": "{{ '2.0.0' }}", "latest_version": "{{ '3.0.0' }}"}, + (("install", {}),), + ), + ( + "vacuum", + VACUUM_SCRIPT_FIELDS, + { + "fan_speeds": ["low", "medium", "high"], + "state": "{{ 'on' }}", + }, + ( + ("start", {}), + ("pause", {}), + ("stop", {}), + ("return_to_base", {}), + ("clean_spot", {}), + ("locate", {}), + ("set_fan_speed", {"fan_speed": "medium"}), + ), + ), + ], +) +async def test_config_entry_device_actions( + hass: HomeAssistant, + domain: str, + script_fields, + extra_config: str, + test_actions: tuple[tuple[str, dict], ...], + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list, +) -> None: + """Test device actions in config flow.""" + + platform_setup, device_entry, entity_entry = await _setup_mock_devices( + hass, domain, device_registry, entity_registry + ) + + actions = { + action: [ + { + "action": "test.automation", + "data": { + "action": "fake_action", + "caller": platform_setup.entity_id, + }, + }, + { + "domain": "fake_integration", + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": entity_entry.id, + "metadata": {"secondary": False}, + }, + ] + for action in script_fields + } + + template_config_entry = MockConfigEntry( + data={}, + domain="template", + options={ + "name": platform_setup.object_id, + "template_type": domain, + **actions, + **extra_config, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + for test_action, action_data in test_actions: + call_count = len(calls) + await hass.services.async_call( + domain, + test_action, + {"entity_id": platform_setup.entity_id, **action_data}, + blocking=True, + ) + assert_action(platform_setup, calls, call_count + 1, "fake_action") + + @pytest.mark.parametrize( ("domain", "legacy_fields"), [