Validate template actions: field using async_validate_actions_config (#180018)

This commit is contained in:
Petro31
2026-08-26 05:39:02 +02:00
committed by GitHub
parent c7fb5271f1
commit 5ce46de8d1
4 changed files with 473 additions and 99 deletions
@@ -5,9 +5,13 @@ from collections.abc import Coroutine
import logging
from typing import Any
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant import config as conf_util
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ACTIONS,
CONF_DEVICE_ID,
CONF_NAME,
CONF_TRIGGERS,
@@ -19,6 +23,7 @@ from homeassistant.exceptions import ConfigEntryError, HomeAssistantError
from homeassistant.helpers import device_registry as dr, discovery, issue_registry as ir
from homeassistant.helpers.helper_integration import async_remove_helper_devices
from homeassistant.helpers.reload import async_reload_integration_platforms
from homeassistant.helpers.script import async_validate_actions_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
@@ -192,6 +197,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
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)
async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None:
"""Process config."""
coordinators = hass.data.pop(DATA_COORDINATORS, None)
@@ -212,6 +224,24 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None:
for conf_section in hass_config[DOMAIN]:
if CONF_TRIGGERS in conf_section:
if actions_config := conf_section.get(CONF_ACTIONS):
try:
conf_section[CONF_ACTIONS] = await async_validate_actions_config(
hass, actions_config
)
except (vol.Invalid, HomeAssistantError) as err:
breadcrumb = "template section"
if (unique_id := conf_section.get(CONF_UNIQUE_ID)) is not None:
breadcrumb = f"template section with unique_id: {unique_id}"
_LOGGER.error(
"The 'actions' for %s failed to setup: %s",
breadcrumb,
_humanize(err, actions_config),
)
continue
coordinator_tasks.append(init_coordinator(hass, conf_section))
continue
+84 -1
View File
@@ -3,14 +3,17 @@
from dataclasses import dataclass
from enum import Enum, StrEnum
from itertools import chain
from unittest.mock import AsyncMock, Mock
import pytest
import voluptuous as vol
from homeassistant.components import template
from homeassistant.components.device_automation import toggle_entity
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant, ServiceCall, State
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
@@ -18,6 +21,7 @@ from tests.common import (
MockConfigEntry,
assert_setup_component,
async_mock_service,
mock_platform,
mock_restore_cache,
mock_restore_cache_with_extra_data,
)
@@ -70,6 +74,85 @@ def make_test_action(action: str, extra_data: ConfigType | None = None) -> Confi
}
def make_mock_device_actions(
actions: list[str],
platform_setup: TemplatePlatformSetup,
device_entry: dr.DeviceEntry,
entity_entry: er.RegistryEntry,
) -> ConfigType:
"""Make actions for device testing."""
return {
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 actions
}
async def setup_mock_devices(
hass: HomeAssistant,
domain: str,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> tuple[TemplatePlatformSetup, dr.DeviceEntry, er.RegistryEntry]:
"""Setup mock devices for testing."""
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, "test_entity", make_test_trigger("sensor.trigger")
)
return (platform_setup, device_entry, entity_entry)
def assert_action(
platform_setup: TemplatePlatformSetup,
calls: list[ServiceCall],
+348 -1
View File
@@ -9,12 +9,27 @@ from homeassistant.components.template.config import (
async_validate_config_section,
)
from homeassistant.core import HomeAssistant
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.discovery import Platform
from homeassistant.helpers.script_variables import ScriptVariables
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_mock_device_actions,
setup_entity,
setup_mock_devices,
)
from tests.common import assert_platform_setup_creates_issue, assert_setup_component
@@ -443,6 +458,338 @@ async def test_setup_component_bad_config_logs_error(
assert expected_error in caplog.text
@pytest.mark.parametrize(
("platform", "config"),
[
(
Platform.ALARM_CONTROL_PANEL,
{
"state": "{{ 'disarmed' }}",
},
),
(
Platform.BINARY_SENSOR,
{
"state": "{{ 'on' }}",
},
),
(
Platform.COVER,
{
"state": "{{ 'open' }}",
"open_cover": [],
"close_cover": [],
},
),
(
Platform.DEVICE_TRACKER,
{
"in_zones": "{{ ['zone.home'] }}",
},
),
(
Platform.EVENT,
{
"event_type": "{{ 'single' }}",
"event_types": "{{ ['single'] }}",
},
),
(
Platform.FAN,
{
"state": "{{ 'on' }}",
"turn_on": [],
"turn_off": [],
},
),
(
Platform.IMAGE,
{
"url": "{{ 'http://www.test.com' }}",
},
),
(
Platform.LIGHT,
{
"state": "{{ 'on' }}",
"turn_on": [],
"turn_off": [],
},
),
(
Platform.LOCK,
{
"state": "{{ 'on' }}",
"lock": [],
"unlock": [],
},
),
(
Platform.NUMBER,
{
"state": "{{ 4 }}",
"min": "0",
"max": "100",
"step": "0.1",
"unit_of_measurement": "cm",
"set_value": [],
},
),
(
Platform.SELECT,
{
"state": "{{ 'on' }}",
"options": "{{ ['off', 'on', 'auto'] }}",
"select_option": [],
},
),
(
Platform.SENSOR,
{
"state": "{{ 'yes' }}",
},
),
(
Platform.SWITCH,
{
"state": "{{ 'on' }}",
"turn_on": [],
"turn_off": [],
},
),
(
Platform.UPDATE,
{
"installed_version": "{{ '1.0' }}",
"latest_version": "{{ '2.0' }}",
},
),
(
Platform.VACUUM,
{
"state": "{{ 'docked' }}",
"start": [],
},
),
(
Platform.WEATHER,
{
"condition": "{{ 'cloudy' }}",
"temperature": "{{ 20 }}",
"humidity": "{{ 50 }}",
},
),
],
)
@pytest.mark.parametrize(
("extra_section_config", "breadcrumb"),
[
({}, "template section"),
({"unique_id": "foo"}, "template section with unique_id: foo"),
],
)
async def test_trigger_schema_with_invalid_actions(
hass: HomeAssistant,
platform: Platform,
config: ConfigType,
extra_section_config: ConfigType,
breadcrumb: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test trigger schema with invalid actions."""
await setup_entity(
hass,
TemplatePlatformSetup(
platform,
"test_entity",
{
**extra_section_config,
"trigger": [
{"trigger": "state", "entity_id": ["sensor.test_state"]},
],
"action": [
{
"type": "turn_off",
"device_id": "70c5f67ec2f82f9ba128fe6e99eb7dfa",
"entity_id": "c7e6f3753cb18937f2147bbbdccdd949",
"domain": "light",
},
],
},
),
ConfigurationStyle.TRIGGER,
1,
config,
)
assert len(hass.states.async_entity_ids(platform)) == 0
assert (
f"The 'actions' for {breadcrumb} failed to setup: Unknown device '70c5f67ec2f82f9ba128fe6e99eb7dfa'"
in caplog.text
)
@pytest.mark.parametrize(
("platform", "config"),
[
(
Platform.ALARM_CONTROL_PANEL,
{
"state": "{{ 'disarmed' }}",
},
),
(
Platform.BINARY_SENSOR,
{
"state": "{{ 'on' }}",
},
),
(
Platform.COVER,
{
"state": "{{ 'open' }}",
"open_cover": [],
"close_cover": [],
},
),
(
Platform.DEVICE_TRACKER,
{
"in_zones": "{{ ['zone.home'] }}",
},
),
(
Platform.EVENT,
{
"event_type": "{{ 'single' }}",
"event_types": "{{ ['single'] }}",
},
),
(
Platform.FAN,
{
"state": "{{ 'on' }}",
"turn_on": [],
"turn_off": [],
},
),
(
Platform.IMAGE,
{
"url": "{{ 'http://www.test.com' }}",
},
),
(
Platform.LIGHT,
{
"state": "{{ 'on' }}",
"turn_on": [],
"turn_off": [],
},
),
(
Platform.LOCK,
{
"state": "{{ 'on' }}",
"lock": [],
"unlock": [],
},
),
(
Platform.NUMBER,
{
"state": "{{ 4 }}",
"min": "0",
"max": "100",
"step": "0.1",
"unit_of_measurement": "cm",
"set_value": [],
},
),
(
Platform.SELECT,
{
"state": "{{ 'on' }}",
"options": "{{ ['off', 'on', 'auto'] }}",
"select_option": [],
},
),
(
Platform.SENSOR,
{
"state": "{{ 'yes' }}",
},
),
(
Platform.SWITCH,
{
"state": "{{ 'on' }}",
"turn_on": [],
"turn_off": [],
},
),
(
Platform.UPDATE,
{
"installed_version": "{{ '1.0' }}",
"latest_version": "{{ '2.0' }}",
},
),
(
Platform.VACUUM,
{
"state": "{{ 'docked' }}",
"start": [],
},
),
(
Platform.WEATHER,
{
"condition": "{{ 'cloudy' }}",
"temperature": "{{ 20 }}",
"humidity": "{{ 50 }}",
},
),
],
)
async def test_trigger_schema_with_valid_actions(
hass: HomeAssistant,
platform: Platform,
config: ConfigType,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
calls: list,
) -> None:
"""Test trigger schema with valid actions configurations."""
platform_setup, device_entry, entity_entry = await setup_mock_devices(
hass, platform, device_registry, entity_registry
)
await setup_entity(
hass,
TemplatePlatformSetup(
platform,
"test_entity",
{
"trigger": [
{"trigger": "state", "entity_id": ["sensor.test_state"]},
],
**make_mock_device_actions(
["actions"], platform_setup, device_entry, entity_entry
),
},
),
ConfigurationStyle.TRIGGER,
1,
config,
)
await async_trigger(hass, "sensor.test_state", "anything")
assert_action(platform_setup, calls, 1, "fake_action")
@pytest.mark.parametrize(
("config", "expected_root", "expected_entity"),
[
+11 -97
View File
@@ -1,11 +1,7 @@
"""The tests for template helpers."""
from unittest.mock import AsyncMock, Mock
import pytest
import voluptuous as vol
from homeassistant.components.device_automation import toggle_entity
from homeassistant.components.template import DOMAIN
from homeassistant.components.template.alarm_control_panel import (
SCRIPT_FIELDS as ALARM_CONTROL_PANEL_SCRIPT_FIELDS,
@@ -42,64 +38,14 @@ from homeassistant.helpers.typing import ConfigType
from .conftest import (
ConfigurationStyle,
TemplatePlatformSetup,
assert_action,
async_trigger,
make_test_trigger,
make_mock_device_actions,
setup_entity,
setup_mock_devices,
)
from tests.common import MockConfigEntry, mock_platform
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, "test_entity", make_test_trigger("sensor.trigger")
)
return (platform_setup, device_entry, entity_entry)
from tests.common import MockConfigEntry
async def _setup_and_test_yaml_device_action(
@@ -114,29 +60,13 @@ async def _setup_and_test_yaml_device_action(
calls: list,
) -> None:
platform_setup, device_entry, entity_entry = await _setup_mock_devices(
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
}
actions = make_mock_device_actions(
script_fields, platform_setup, device_entry, entity_entry
)
await setup_entity(hass, platform_setup, style, 1, {**actions, **extra_config})
await async_trigger(hass, "sensor.trigger", "anything")
@@ -490,29 +420,13 @@ async def test_config_entry_device_actions(
) -> None:
"""Test device actions in config flow."""
platform_setup, device_entry, entity_entry = await _setup_mock_devices(
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
}
actions = make_mock_device_actions(
script_fields, platform_setup, device_entry, entity_entry
)
template_config_entry = MockConfigEntry(
data={},