diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 99225a004265..c29feb5c786f 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -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 diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 82dd9f6bf543..e8986de8e73c 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -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], diff --git a/tests/components/template/test_config.py b/tests/components/template/test_config.py index e0bf6baa8f04..ba5e60824658 100644 --- a/tests/components/template/test_config.py +++ b/tests/components/template/test_config.py @@ -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"), [ diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py index 92377df9bd0b..0e637f3e6e2c 100644 --- a/tests/components/template/test_helpers.py +++ b/tests/components/template/test_helpers.py @@ -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={},