1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 08:26:41 +01:00

Correct validation of scripts in template entities (#165226)

This commit is contained in:
Petro31
2026-03-23 09:08:11 -04:00
committed by GitHub
parent dab4a72128
commit c53adcb73b
13 changed files with 690 additions and 25 deletions

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)]

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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"),
[