Raise repair issues when automations can't be set up (#120010)

This commit is contained in:
Erik Montnemery 2024-06-21 11:07:30 +02:00 committed by GitHub
parent 5138c3de0a
commit d2a5683fa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 199 additions and 23 deletions

View File

@ -65,7 +65,11 @@ from homeassistant.helpers.deprecation import (
)
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import (
ATTR_CUR,
@ -98,7 +102,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.dt import parse_datetime
from .config import AutomationConfig
from .config import AutomationConfig, ValidationStatus
from .const import (
CONF_ACTION,
CONF_INITIAL_STATE,
@ -426,11 +430,15 @@ class UnavailableAutomationEntity(BaseAutomationEntity):
automation_id: str | None,
name: str,
raw_config: ConfigType | None,
validation_error: str,
validation_status: ValidationStatus,
) -> None:
"""Initialize an automation entity."""
self._attr_name = name
self._attr_unique_id = automation_id
self.raw_config = raw_config
self._validation_error = validation_error
self._validation_status = validation_status
@cached_property
def referenced_labels(self) -> set[str]:
@ -462,6 +470,30 @@ class UnavailableAutomationEntity(BaseAutomationEntity):
"""Return a set of referenced entities."""
return set()
async def async_added_to_hass(self) -> None:
"""Create a repair issue to notify the user the automation has errors."""
await super().async_added_to_hass()
async_create_issue(
self.hass,
DOMAIN,
f"{self.entity_id}_validation_{self._validation_status}",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key=f"validation_{self._validation_status}",
translation_placeholders={
"edit": f"/config/automation/edit/{self.unique_id}",
"entity_id": self.entity_id,
"error": self._validation_error,
"name": self._attr_name or self.entity_id,
},
)
async def async_will_remove_from_hass(self) -> None:
await super().async_will_remove_from_hass()
async_delete_issue(
self.hass, DOMAIN, f"{self.entity_id}_validation_{self._validation_status}"
)
async def async_trigger(
self,
run_variables: dict[str, Any],
@ -864,7 +896,8 @@ class AutomationEntityConfig:
list_no: int
raw_blueprint_inputs: ConfigType | None
raw_config: ConfigType | None
validation_failed: bool
validation_error: str | None
validation_status: ValidationStatus
async def _prepare_automation_config(
@ -884,14 +917,16 @@ async def _prepare_automation_config(
raw_config = cast(AutomationConfig, config_block).raw_config
raw_blueprint_inputs = cast(AutomationConfig, config_block).raw_blueprint_inputs
validation_failed = cast(AutomationConfig, config_block).validation_failed
validation_error = cast(AutomationConfig, config_block).validation_error
validation_status = cast(AutomationConfig, config_block).validation_status
automation_configs.append(
AutomationEntityConfig(
config_block,
list_no,
raw_blueprint_inputs,
raw_config,
validation_failed,
validation_error,
validation_status,
)
)
@ -917,12 +952,14 @@ async def _create_automation_entities(
automation_id: str | None = config_block.get(CONF_ID)
name = _automation_name(automation_config)
if automation_config.validation_failed:
if automation_config.validation_status != ValidationStatus.OK:
entities.append(
UnavailableAutomationEntity(
automation_id,
name,
automation_config.raw_config,
cast(str, automation_config.validation_error),
automation_config.validation_status,
)
)
continue

View File

@ -4,7 +4,8 @@ from __future__ import annotations
from collections.abc import Mapping
from contextlib import suppress
from typing import Any
from enum import StrEnum
from typing import Any, cast
import voluptuous as vol
from voluptuous.humanize import humanize_error
@ -73,7 +74,7 @@ PLATFORM_SCHEMA = vol.All(
)
async def _async_validate_config_item(
async def _async_validate_config_item( # noqa: C901
hass: HomeAssistant,
config: ConfigType,
raise_on_errors: bool,
@ -86,6 +87,12 @@ async def _async_validate_config_item(
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 cast(str, humanize_error(config, err))
return str(err)
def _log_invalid_automation(
err: Exception,
automation_name: str,
@ -101,7 +108,7 @@ async def _async_validate_config_item(
"Blueprint '%s' generated invalid automation with inputs %s: %s",
blueprint_inputs.blueprint.name,
blueprint_inputs.inputs,
humanize_error(config, err) if isinstance(err, vol.Invalid) else err,
_humanize(err, config),
)
return
@ -109,17 +116,35 @@ async def _async_validate_config_item(
"%s %s and has been disabled: %s",
automation_name,
problem,
humanize_error(config, err) if isinstance(err, vol.Invalid) else err,
_humanize(err, config),
)
return
def _minimal_config() -> AutomationConfig:
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
automation_config.validation_failed = True
_set_validation_status(
automation_config, validation_status, validation_error, config
)
return automation_config
if blueprint.is_blueprint_instance_config(config):
@ -135,7 +160,7 @@ async def _async_validate_config_item(
)
if raise_on_errors:
raise
return _minimal_config()
return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
raw_blueprint_inputs = blueprint_inputs.config_with_inputs
@ -152,7 +177,7 @@ async def _async_validate_config_item(
)
if raise_on_errors:
raise HomeAssistantError(err) from err
return _minimal_config()
return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
automation_name = "Unnamed automation"
if isinstance(config, Mapping):
@ -167,7 +192,7 @@ async def _async_validate_config_item(
_log_invalid_automation(err, automation_name, "could not be validated", config)
if raise_on_errors:
raise
return _minimal_config()
return _minimal_config(ValidationStatus.FAILED_SCHEMA, err, config)
automation_config = AutomationConfig(validated_config)
automation_config.raw_blueprint_inputs = raw_blueprint_inputs
@ -186,7 +211,9 @@ async def _async_validate_config_item(
)
if raise_on_errors:
raise
automation_config.validation_failed = True
_set_validation_status(
automation_config, ValidationStatus.FAILED_TRIGGERS, err, validated_config
)
return automation_config
if CONF_CONDITION in validated_config:
@ -203,7 +230,12 @@ async def _async_validate_config_item(
)
if raise_on_errors:
raise
automation_config.validation_failed = True
_set_validation_status(
automation_config,
ValidationStatus.FAILED_CONDITIONS,
err,
validated_config,
)
return automation_config
try:
@ -219,18 +251,32 @@ async def _async_validate_config_item(
)
if raise_on_errors:
raise
automation_config.validation_failed = True
_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_failed: bool = False
validation_status: ValidationStatus = ValidationStatus.OK
validation_error: str | None = None
async def _try_async_validate_config_item(

View File

@ -1,4 +1,7 @@
{
"common": {
"validation_failed_title": "Automation {name} failed to set up"
},
"title": "Automation",
"entity_component": {
"_": {
@ -43,6 +46,26 @@
}
}
}
},
"validation_failed_actions": {
"title": "[%key:component::automation::common::validation_failed_title%]",
"description": "The automation \"{name}\" (`{entity_id}`) is not active because its actions could not be set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration."
},
"validation_failed_blueprint": {
"title": "[%key:component::automation::common::validation_failed_title%]",
"description": "The blueprinted automation \"{name}\" (`{entity_id}`) failed to set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration."
},
"validation_failed_conditions": {
"title": "[%key:component::automation::common::validation_failed_title%]",
"description": "The automation \"{name}\" (`{entity_id}`) is not active because its conditions could not be set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration."
},
"validation_failed_schema": {
"title": "[%key:component::automation::common::validation_failed_title%]",
"description": "The automation \"{name}\" (`{entity_id}`) is not active because the configuration has errors.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration."
},
"validation_failed_triggers": {
"title": "[%key:component::automation::common::validation_failed_title%]",
"description": "The automation \"{name}\" (`{entity_id}`) is not active because its triggers could not be set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration."
}
},
"services": {

View File

@ -4,7 +4,7 @@ import asyncio
from datetime import timedelta
import logging
from typing import Any
from unittest.mock import Mock, patch
from unittest.mock import ANY, Mock, patch
import pytest
@ -1645,12 +1645,13 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("broken_config", "problem", "details"),
("broken_config", "problem", "details", "issue"),
[
(
{},
"could not be validated",
"required key not provided @ data['action']",
"validation_failed_schema",
),
(
{
@ -1659,6 +1660,7 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None:
},
"failed to setup triggers",
"Integration 'automation' does not provide trigger support.",
"validation_failed_triggers",
),
(
{
@ -1673,6 +1675,7 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None:
},
"failed to setup conditions",
"Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.",
"validation_failed_conditions",
),
(
{
@ -1686,15 +1689,19 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None:
},
"failed to setup actions",
"Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.",
"validation_failed_actions",
),
],
)
async def test_automation_bad_config_validation(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
hass_admin_user,
broken_config,
problem,
details,
issue,
) -> None:
"""Test bad automation configuration which can be detected during validation."""
assert await async_setup_component(
@ -1715,11 +1722,22 @@ async def test_automation_bad_config_validation(
},
)
# Check we get the expected error message
# Check we get the expected error message and issue
assert (
f"Automation with alias 'bad_automation' {problem} and has been disabled:"
f" {details}"
) in caplog.text
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 1
assert issues[0]["issue_id"] == f"automation.bad_automation_{issue}"
assert issues[0]["translation_key"] == issue
assert issues[0]["translation_placeholders"] == {
"edit": "/config/automation/edit/None",
"entity_id": "automation.bad_automation",
"error": ANY,
"name": "bad_automation",
}
assert issues[0]["translation_placeholders"]["error"].startswith(details)
# Make sure both automations are setup
assert set(hass.states.async_entity_ids("automation")) == {
@ -1729,6 +1747,30 @@ async def test_automation_bad_config_validation(
# The automation failing validation should be unavailable
assert hass.states.get("automation.bad_automation").state == STATE_UNAVAILABLE
# Reloading the automation with fixed config should clear the issue
with patch(
"homeassistant.config.load_yaml_config_file",
autospec=True,
return_value={
automation.DOMAIN: {
"alias": "bad_automation",
"trigger": {"platform": "event", "event_type": "test_event2"},
"action": {
"service": "test.automation",
"data_template": {"event": "{{ trigger.event.event_type }}"},
},
}
},
):
await hass.services.async_call(
automation.DOMAIN,
SERVICE_RELOAD,
context=Context(user_id=hass_admin_user.id),
blocking=True,
)
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 0
async def test_automation_with_error_in_script(
hass: HomeAssistant,
@ -2507,6 +2549,7 @@ async def test_blueprint_automation(
)
async def test_blueprint_automation_bad_config(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
blueprint_inputs,
problem,
@ -2528,9 +2571,24 @@ async def test_blueprint_automation_bad_config(
assert problem in caplog.text
assert details in caplog.text
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 1
issue = "validation_failed_blueprint"
assert issues[0]["issue_id"] == f"automation.automation_0_{issue}"
assert issues[0]["translation_key"] == issue
assert issues[0]["translation_placeholders"] == {
"edit": "/config/automation/edit/None",
"entity_id": "automation.automation_0",
"error": ANY,
"name": "automation 0",
}
assert issues[0]["translation_placeholders"]["error"].startswith(details)
async def test_blueprint_automation_fails_substitution(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test blueprint automation with bad inputs."""
with patch(
@ -2559,6 +2617,18 @@ async def test_blueprint_automation_fails_substitution(
" 'a_number': 5}: No substitution found for input blah"
) in caplog.text
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 1
issue = "validation_failed_blueprint"
assert issues[0]["issue_id"] == f"automation.automation_0_{issue}"
assert issues[0]["translation_key"] == issue
assert issues[0]["translation_placeholders"] == {
"edit": "/config/automation/edit/None",
"entity_id": "automation.automation_0",
"error": "No substitution found for input blah",
"name": "automation 0",
}
async def test_trigger_service(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
"""Test the automation trigger service."""