core/homeassistant/components/automation/config.py

365 lines
12 KiB
Python

"""Config validation helper for the automation integration."""
from __future__ import annotations
from collections.abc import Mapping
from contextlib import suppress
from enum import StrEnum
from typing import Any
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.components import blueprint
from homeassistant.components.trace import TRACE_CONFIG_SCHEMA
from homeassistant.config import config_per_platform, config_without_domain
from homeassistant.const import (
CONF_ALIAS,
CONF_CONDITION,
CONF_CONDITIONS,
CONF_DESCRIPTION,
CONF_ID,
CONF_VARIABLES,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, script
from homeassistant.helpers.condition import async_validate_conditions_config
from homeassistant.helpers.trigger import async_validate_trigger_config
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.yaml.input import UndefinedSubstitution
from .const import (
CONF_ACTION,
CONF_ACTIONS,
CONF_HIDE_ENTITY,
CONF_INITIAL_STATE,
CONF_TRACE,
CONF_TRIGGER,
CONF_TRIGGER_VARIABLES,
CONF_TRIGGERS,
DOMAIN,
LOGGER,
)
from .helpers import async_get_blueprints
PACKAGE_MERGE_HINT = "list"
_MINIMAL_PLATFORM_SCHEMA = vol.Schema(
{
CONF_ID: str,
CONF_ALIAS: cv.string,
vol.Optional(CONF_DESCRIPTION): cv.string,
},
extra=vol.ALLOW_EXTRA,
)
def _backward_compat_schema(value: Any | None) -> Any:
"""Backward compatibility for automations."""
if not isinstance(value, dict):
return value
# `trigger` has been renamed to `triggers`
if CONF_TRIGGER in value:
if CONF_TRIGGERS in value:
raise vol.Invalid(
"Cannot specify both 'trigger' and 'triggers'. Please use 'triggers' only."
)
value[CONF_TRIGGERS] = value.pop(CONF_TRIGGER)
# `condition` has been renamed to `conditions`
if CONF_CONDITION in value:
if CONF_CONDITIONS in value:
raise vol.Invalid(
"Cannot specify both 'condition' and 'conditions'. Please use 'conditions' only."
)
value[CONF_CONDITIONS] = value.pop(CONF_CONDITION)
# `action` has been renamed to `actions`
if CONF_ACTION in value:
if CONF_ACTIONS in value:
raise vol.Invalid(
"Cannot specify both 'action' and 'actions'. Please use 'actions' only."
)
value[CONF_ACTIONS] = value.pop(CONF_ACTION)
return value
PLATFORM_SCHEMA = vol.All(
_backward_compat_schema,
cv.deprecated(CONF_HIDE_ENTITY),
script.make_script_schema(
{
# str on purpose
CONF_ID: str,
CONF_ALIAS: cv.string,
vol.Optional(CONF_DESCRIPTION): cv.string,
vol.Optional(CONF_TRACE, default={}): TRACE_CONFIG_SCHEMA,
vol.Optional(CONF_INITIAL_STATE): cv.boolean,
vol.Optional(CONF_HIDE_ENTITY): cv.boolean,
vol.Required(CONF_TRIGGERS): cv.TRIGGER_SCHEMA,
vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA,
vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
vol.Optional(CONF_TRIGGER_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
vol.Required(CONF_ACTIONS): cv.SCRIPT_SCHEMA,
},
script.SCRIPT_MODE_SINGLE,
),
)
AUTOMATION_BLUEPRINT_SCHEMA = vol.All(
_backward_compat_schema, blueprint.schemas.BLUEPRINT_SCHEMA
)
async def _async_validate_config_item( # noqa: C901
hass: HomeAssistant,
config: ConfigType,
raise_on_errors: bool,
warn_on_errors: bool,
) -> AutomationConfig:
"""Validate config item."""
raw_config = None
raw_blueprint_inputs = None
uses_blueprint = False
with suppress(ValueError):
raw_config = dict(config)
def _humanize(err: Exception, config: ConfigType) -> str:
"""Humanize vol.Invalid, stringify other exceptions."""
if isinstance(err, vol.Invalid):
return humanize_error(config, err)
return str(err)
def _log_invalid_automation(
err: Exception,
automation_name: str,
problem: str,
config: ConfigType,
) -> None:
"""Log an error about invalid automation."""
if not warn_on_errors:
return
if uses_blueprint:
LOGGER.error(
"Blueprint '%s' generated invalid automation with inputs %s: %s",
blueprint_inputs.blueprint.name,
blueprint_inputs.inputs,
_humanize(err, config),
)
return
LOGGER.error(
"%s %s and has been disabled: %s",
automation_name,
problem,
_humanize(err, config),
)
return
def _set_validation_status(
automation_config: AutomationConfig,
validation_status: ValidationStatus,
validation_error: Exception,
config: ConfigType,
) -> None:
"""Set validation status."""
if uses_blueprint:
validation_status = ValidationStatus.FAILED_BLUEPRINT
automation_config.validation_status = validation_status
automation_config.validation_error = _humanize(validation_error, config)
def _minimal_config(
validation_status: ValidationStatus,
validation_error: Exception,
config: ConfigType,
) -> AutomationConfig:
"""Try validating id, alias and description."""
minimal_config = _MINIMAL_PLATFORM_SCHEMA(config)
automation_config = AutomationConfig(minimal_config)
automation_config.raw_blueprint_inputs = raw_blueprint_inputs
automation_config.raw_config = raw_config
_set_validation_status(
automation_config, validation_status, validation_error, config
)
return automation_config
if blueprint.is_blueprint_instance_config(config):
uses_blueprint = True
blueprints = async_get_blueprints(hass)
try:
blueprint_inputs = await blueprints.async_inputs_from_config(
_backward_compat_schema(config)
)
except blueprint.BlueprintException as err:
if warn_on_errors:
LOGGER.error(
"Failed to generate automation from blueprint: %s",
err,
)
if raise_on_errors:
raise
return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
raw_blueprint_inputs = blueprint_inputs.config_with_inputs
try:
config = blueprint_inputs.async_substitute()
raw_config = dict(config)
except UndefinedSubstitution as err:
if warn_on_errors:
LOGGER.error(
"Blueprint '%s' failed to generate automation with inputs %s: %s",
blueprint_inputs.blueprint.name,
blueprint_inputs.inputs,
err,
)
if raise_on_errors:
raise HomeAssistantError(err) from err
return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
automation_name = "Unnamed automation"
if isinstance(config, Mapping):
if CONF_ALIAS in config:
automation_name = f"Automation with alias '{config[CONF_ALIAS]}'"
elif CONF_ID in config:
automation_name = f"Automation with ID '{config[CONF_ID]}'"
try:
validated_config = PLATFORM_SCHEMA(config)
except vol.Invalid as err:
_log_invalid_automation(err, automation_name, "could not be validated", config)
if raise_on_errors:
raise
return _minimal_config(ValidationStatus.FAILED_SCHEMA, err, config)
automation_config = AutomationConfig(validated_config)
automation_config.raw_blueprint_inputs = raw_blueprint_inputs
automation_config.raw_config = raw_config
try:
automation_config[CONF_TRIGGERS] = await async_validate_trigger_config(
hass, validated_config[CONF_TRIGGERS]
)
except (
vol.Invalid,
HomeAssistantError,
) as err:
_log_invalid_automation(
err, automation_name, "failed to setup triggers", validated_config
)
if raise_on_errors:
raise
_set_validation_status(
automation_config, ValidationStatus.FAILED_TRIGGERS, err, validated_config
)
return automation_config
if CONF_CONDITIONS in validated_config:
try:
automation_config[CONF_CONDITIONS] = await async_validate_conditions_config(
hass, validated_config[CONF_CONDITIONS]
)
except (
vol.Invalid,
HomeAssistantError,
) as err:
_log_invalid_automation(
err, automation_name, "failed to setup conditions", validated_config
)
if raise_on_errors:
raise
_set_validation_status(
automation_config,
ValidationStatus.FAILED_CONDITIONS,
err,
validated_config,
)
return automation_config
try:
automation_config[CONF_ACTIONS] = await script.async_validate_actions_config(
hass, validated_config[CONF_ACTIONS]
)
except (
vol.Invalid,
HomeAssistantError,
) as err:
_log_invalid_automation(
err, automation_name, "failed to setup actions", validated_config
)
if raise_on_errors:
raise
_set_validation_status(
automation_config, ValidationStatus.FAILED_ACTIONS, err, validated_config
)
return automation_config
return automation_config
class ValidationStatus(StrEnum):
"""What was changed in a config entry."""
FAILED_ACTIONS = "failed_actions"
FAILED_BLUEPRINT = "failed_blueprint"
FAILED_CONDITIONS = "failed_conditions"
FAILED_SCHEMA = "failed_schema"
FAILED_TRIGGERS = "failed_triggers"
OK = "ok"
class AutomationConfig(dict):
"""Dummy class to allow adding attributes."""
raw_config: dict[str, Any] | None = None
raw_blueprint_inputs: dict[str, Any] | None = None
validation_status: ValidationStatus = ValidationStatus.OK
validation_error: str | None = None
async def _try_async_validate_config_item(
hass: HomeAssistant,
config: dict[str, Any],
) -> AutomationConfig | None:
"""Validate config item."""
try:
return await _async_validate_config_item(hass, config, False, True)
except (vol.Invalid, HomeAssistantError):
return None
async def async_validate_config_item(
hass: HomeAssistant,
config_key: str,
config: dict[str, Any],
) -> AutomationConfig | None:
"""Validate config item, called by EditAutomationConfigView."""
return await _async_validate_config_item(hass, config, True, False)
async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType:
"""Validate config."""
# No gather here since _try_async_validate_config_item is unlikely to suspend
# and the cost of creating many tasks is not worth the benefit.
automations = list(
filter(
lambda x: x is not None,
[
await _try_async_validate_config_item(hass, p_config)
for _, p_config in config_per_platform(config, DOMAIN)
],
)
)
# Create a copy of the configuration with all config for current
# component removed and add validated config back in.
config = config_without_domain(config, DOMAIN)
config[DOMAIN] = automations
return config