mirror of https://github.com/home-assistant/core
365 lines
12 KiB
Python
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
|