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 import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent 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.restore_state import RestoreEntity
from homeassistant.helpers.script import ( from homeassistant.helpers.script import (
ATTR_CUR, ATTR_CUR,
@ -98,7 +102,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.util.dt import parse_datetime from homeassistant.util.dt import parse_datetime
from .config import AutomationConfig from .config import AutomationConfig, ValidationStatus
from .const import ( from .const import (
CONF_ACTION, CONF_ACTION,
CONF_INITIAL_STATE, CONF_INITIAL_STATE,
@ -426,11 +430,15 @@ class UnavailableAutomationEntity(BaseAutomationEntity):
automation_id: str | None, automation_id: str | None,
name: str, name: str,
raw_config: ConfigType | None, raw_config: ConfigType | None,
validation_error: str,
validation_status: ValidationStatus,
) -> None: ) -> None:
"""Initialize an automation entity.""" """Initialize an automation entity."""
self._attr_name = name self._attr_name = name
self._attr_unique_id = automation_id self._attr_unique_id = automation_id
self.raw_config = raw_config self.raw_config = raw_config
self._validation_error = validation_error
self._validation_status = validation_status
@cached_property @cached_property
def referenced_labels(self) -> set[str]: def referenced_labels(self) -> set[str]:
@ -462,6 +470,30 @@ class UnavailableAutomationEntity(BaseAutomationEntity):
"""Return a set of referenced entities.""" """Return a set of referenced entities."""
return set() 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( async def async_trigger(
self, self,
run_variables: dict[str, Any], run_variables: dict[str, Any],
@ -864,7 +896,8 @@ class AutomationEntityConfig:
list_no: int list_no: int
raw_blueprint_inputs: ConfigType | None raw_blueprint_inputs: ConfigType | None
raw_config: ConfigType | None raw_config: ConfigType | None
validation_failed: bool validation_error: str | None
validation_status: ValidationStatus
async def _prepare_automation_config( async def _prepare_automation_config(
@ -884,14 +917,16 @@ async def _prepare_automation_config(
raw_config = cast(AutomationConfig, config_block).raw_config raw_config = cast(AutomationConfig, config_block).raw_config
raw_blueprint_inputs = cast(AutomationConfig, config_block).raw_blueprint_inputs 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( automation_configs.append(
AutomationEntityConfig( AutomationEntityConfig(
config_block, config_block,
list_no, list_no,
raw_blueprint_inputs, raw_blueprint_inputs,
raw_config, 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) automation_id: str | None = config_block.get(CONF_ID)
name = _automation_name(automation_config) name = _automation_name(automation_config)
if automation_config.validation_failed: if automation_config.validation_status != ValidationStatus.OK:
entities.append( entities.append(
UnavailableAutomationEntity( UnavailableAutomationEntity(
automation_id, automation_id,
name, name,
automation_config.raw_config, automation_config.raw_config,
cast(str, automation_config.validation_error),
automation_config.validation_status,
) )
) )
continue continue

View File

@ -4,7 +4,8 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import suppress from contextlib import suppress
from typing import Any from enum import StrEnum
from typing import Any, cast
import voluptuous as vol import voluptuous as vol
from voluptuous.humanize import humanize_error 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, hass: HomeAssistant,
config: ConfigType, config: ConfigType,
raise_on_errors: bool, raise_on_errors: bool,
@ -86,6 +87,12 @@ async def _async_validate_config_item(
with suppress(ValueError): with suppress(ValueError):
raw_config = dict(config) 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( def _log_invalid_automation(
err: Exception, err: Exception,
automation_name: str, automation_name: str,
@ -101,7 +108,7 @@ async def _async_validate_config_item(
"Blueprint '%s' generated invalid automation with inputs %s: %s", "Blueprint '%s' generated invalid automation with inputs %s: %s",
blueprint_inputs.blueprint.name, blueprint_inputs.blueprint.name,
blueprint_inputs.inputs, blueprint_inputs.inputs,
humanize_error(config, err) if isinstance(err, vol.Invalid) else err, _humanize(err, config),
) )
return return
@ -109,17 +116,35 @@ async def _async_validate_config_item(
"%s %s and has been disabled: %s", "%s %s and has been disabled: %s",
automation_name, automation_name,
problem, problem,
humanize_error(config, err) if isinstance(err, vol.Invalid) else err, _humanize(err, config),
) )
return 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.""" """Try validating id, alias and description."""
minimal_config = _MINIMAL_PLATFORM_SCHEMA(config) minimal_config = _MINIMAL_PLATFORM_SCHEMA(config)
automation_config = AutomationConfig(minimal_config) automation_config = AutomationConfig(minimal_config)
automation_config.raw_blueprint_inputs = raw_blueprint_inputs automation_config.raw_blueprint_inputs = raw_blueprint_inputs
automation_config.raw_config = raw_config automation_config.raw_config = raw_config
automation_config.validation_failed = True _set_validation_status(
automation_config, validation_status, validation_error, config
)
return automation_config return automation_config
if blueprint.is_blueprint_instance_config(config): if blueprint.is_blueprint_instance_config(config):
@ -135,7 +160,7 @@ async def _async_validate_config_item(
) )
if raise_on_errors: if raise_on_errors:
raise raise
return _minimal_config() return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
raw_blueprint_inputs = blueprint_inputs.config_with_inputs raw_blueprint_inputs = blueprint_inputs.config_with_inputs
@ -152,7 +177,7 @@ async def _async_validate_config_item(
) )
if raise_on_errors: if raise_on_errors:
raise HomeAssistantError(err) from err raise HomeAssistantError(err) from err
return _minimal_config() return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
automation_name = "Unnamed automation" automation_name = "Unnamed automation"
if isinstance(config, Mapping): 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) _log_invalid_automation(err, automation_name, "could not be validated", config)
if raise_on_errors: if raise_on_errors:
raise raise
return _minimal_config() return _minimal_config(ValidationStatus.FAILED_SCHEMA, err, config)
automation_config = AutomationConfig(validated_config) automation_config = AutomationConfig(validated_config)
automation_config.raw_blueprint_inputs = raw_blueprint_inputs automation_config.raw_blueprint_inputs = raw_blueprint_inputs
@ -186,7 +211,9 @@ async def _async_validate_config_item(
) )
if raise_on_errors: if raise_on_errors:
raise raise
automation_config.validation_failed = True _set_validation_status(
automation_config, ValidationStatus.FAILED_TRIGGERS, err, validated_config
)
return automation_config return automation_config
if CONF_CONDITION in validated_config: if CONF_CONDITION in validated_config:
@ -203,7 +230,12 @@ async def _async_validate_config_item(
) )
if raise_on_errors: if raise_on_errors:
raise raise
automation_config.validation_failed = True _set_validation_status(
automation_config,
ValidationStatus.FAILED_CONDITIONS,
err,
validated_config,
)
return automation_config return automation_config
try: try:
@ -219,18 +251,32 @@ async def _async_validate_config_item(
) )
if raise_on_errors: if raise_on_errors:
raise raise
automation_config.validation_failed = True _set_validation_status(
automation_config, ValidationStatus.FAILED_ACTIONS, err, validated_config
)
return automation_config return automation_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): class AutomationConfig(dict):
"""Dummy class to allow adding attributes.""" """Dummy class to allow adding attributes."""
raw_config: dict[str, Any] | None = None raw_config: dict[str, Any] | None = None
raw_blueprint_inputs: 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( 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", "title": "Automation",
"entity_component": { "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": { "services": {

View File

@ -4,7 +4,7 @@ import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from unittest.mock import Mock, patch from unittest.mock import ANY, Mock, patch
import pytest import pytest
@ -1645,12 +1645,13 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("broken_config", "problem", "details"), ("broken_config", "problem", "details", "issue"),
[ [
( (
{}, {},
"could not be validated", "could not be validated",
"required key not provided @ data['action']", "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", "failed to setup triggers",
"Integration 'automation' does not provide trigger support.", "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", "failed to setup conditions",
"Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.", "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", "failed to setup actions",
"Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.", "Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.",
"validation_failed_actions",
), ),
], ],
) )
async def test_automation_bad_config_validation( async def test_automation_bad_config_validation(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
hass_admin_user,
broken_config, broken_config,
problem, problem,
details, details,
issue,
) -> None: ) -> None:
"""Test bad automation configuration which can be detected during validation.""" """Test bad automation configuration which can be detected during validation."""
assert await async_setup_component( 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 ( assert (
f"Automation with alias 'bad_automation' {problem} and has been disabled:" f"Automation with alias 'bad_automation' {problem} and has been disabled:"
f" {details}" f" {details}"
) in caplog.text ) 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 # Make sure both automations are setup
assert set(hass.states.async_entity_ids("automation")) == { 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 # The automation failing validation should be unavailable
assert hass.states.get("automation.bad_automation").state == STATE_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( async def test_automation_with_error_in_script(
hass: HomeAssistant, hass: HomeAssistant,
@ -2507,6 +2549,7 @@ async def test_blueprint_automation(
) )
async def test_blueprint_automation_bad_config( async def test_blueprint_automation_bad_config(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
blueprint_inputs, blueprint_inputs,
problem, problem,
@ -2528,9 +2571,24 @@ async def test_blueprint_automation_bad_config(
assert problem in caplog.text assert problem in caplog.text
assert details 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( async def test_blueprint_automation_fails_substitution(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test blueprint automation with bad inputs.""" """Test blueprint automation with bad inputs."""
with patch( with patch(
@ -2559,6 +2617,18 @@ async def test_blueprint_automation_fails_substitution(
" 'a_number': 5}: No substitution found for input blah" " 'a_number': 5}: No substitution found for input blah"
) in caplog.text ) 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: async def test_trigger_service(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
"""Test the automation trigger service.""" """Test the automation trigger service."""