From d2a5683fa0f39cae63a40086491b76cdf214ff8e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 21 Jun 2024 11:07:30 +0200 Subject: [PATCH] Raise repair issues when automations can't be set up (#120010) --- .../components/automation/__init__.py | 49 ++++++++++-- homeassistant/components/automation/config.py | 72 +++++++++++++---- .../components/automation/strings.json | 23 ++++++ tests/components/automation/test_init.py | 78 ++++++++++++++++++- 4 files changed, 199 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index deb3613d668..5a53179cf2c 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -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 diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index 71b4b3c0c6a..676aba946f4 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -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( diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index 31bd812a947..c0750a38ca8 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -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": { diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index b4d9e45b7d3..7619589d52a 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -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."""