diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 84cb2ecbeae..6fd26b2ea8d 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -43,6 +43,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent +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, @@ -60,7 +65,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task from homeassistant.util.dt import parse_datetime -from .config import ScriptConfig +from .config import ScriptConfig, ValidationStatus from .const import ( ATTR_LAST_ACTION, ATTR_LAST_TRIGGERED, @@ -288,7 +293,8 @@ class ScriptEntityConfig: key: str raw_blueprint_inputs: ConfigType | None raw_config: ConfigType | None - validation_failed: bool + validation_error: str | None + validation_status: ValidationStatus async def _prepare_script_config( @@ -303,11 +309,17 @@ async def _prepare_script_config( for key, config_block in conf.items(): raw_config = cast(ScriptConfig, config_block).raw_config raw_blueprint_inputs = cast(ScriptConfig, config_block).raw_blueprint_inputs - validation_failed = cast(ScriptConfig, config_block).validation_failed + validation_error = cast(ScriptConfig, config_block).validation_error + validation_status = cast(ScriptConfig, config_block).validation_status script_configs.append( ScriptEntityConfig( - config_block, key, raw_blueprint_inputs, raw_config, validation_failed + config_block, + key, + raw_blueprint_inputs, + raw_config, + validation_error, + validation_status, ) ) @@ -321,11 +333,13 @@ async def _create_script_entities( entities: list[BaseScriptEntity] = [] for script_config in script_configs: - if script_config.validation_failed: + if script_config.validation_status != ValidationStatus.OK: entities.append( UnavailableScriptEntity( script_config.key, script_config.raw_config, + cast(str, script_config.validation_error), + script_config.validation_status, ) ) continue @@ -457,11 +471,15 @@ class UnavailableScriptEntity(BaseScriptEntity): self, key: str, raw_config: ConfigType | None, + validation_error: str, + validation_status: ValidationStatus, ) -> None: """Initialize a script entity.""" self._attr_name = raw_config.get(CONF_ALIAS, key) if raw_config else key self._attr_unique_id = key self.raw_config = raw_config + self._validation_error = validation_error + self._validation_status = validation_status @cached_property def referenced_labels(self) -> set[str]: @@ -493,6 +511,31 @@ class UnavailableScriptEntity(BaseScriptEntity): """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/script/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: + """Run when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_validation_{self._validation_status}" + ) + class ScriptEntity(BaseScriptEntity, RestoreEntity): """Representation of a script entity.""" diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index 8efdd72f21b..00141eb9f8e 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -4,6 +4,7 @@ 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 @@ -118,6 +119,12 @@ async def _async_validate_config_item( with suppress(ValueError): # Invalid config raw_config = dict(config) + def _humanize(err: Exception, data: Any) -> str: + """Humanize vol.Invalid, stringify other exceptions.""" + if isinstance(err, vol.Invalid): + return humanize_error(data, err) + return str(err) + def _log_invalid_script( err: Exception, script_name: str, @@ -133,7 +140,7 @@ async def _async_validate_config_item( "Blueprint '%s' generated invalid script with inputs %s: %s", blueprint_inputs.blueprint.name, blueprint_inputs.inputs, - humanize_error(data, err) if isinstance(err, vol.Invalid) else err, + _humanize(err, data), ) return @@ -141,17 +148,35 @@ async def _async_validate_config_item( "%s %s and has been disabled: %s", script_name, problem, - humanize_error(data, err) if isinstance(err, vol.Invalid) else err, + _humanize(err, data), ) return - def _minimal_config() -> ScriptConfig: + def _set_validation_status( + script_config: ScriptConfig, + validation_status: ValidationStatus, + validation_error: Exception, + config: ConfigType, + ) -> None: + """Set validation status.""" + if uses_blueprint: + validation_status = ValidationStatus.FAILED_BLUEPRINT + script_config.validation_status = validation_status + script_config.validation_error = _humanize(validation_error, config) + + def _minimal_config( + validation_status: ValidationStatus, + validation_error: Exception, + config: ConfigType, + ) -> ScriptConfig: """Try validating id, alias and description.""" minimal_config = _MINIMAL_SCRIPT_ENTITY_SCHEMA(config) script_config = ScriptConfig(minimal_config) script_config.raw_blueprint_inputs = raw_blueprint_inputs script_config.raw_config = raw_config - script_config.validation_failed = True + _set_validation_status( + script_config, validation_status, validation_error, config + ) return script_config if is_blueprint_instance_config(config): @@ -167,7 +192,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 @@ -184,7 +209,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) script_name = f"Script with object id '{object_id}'" if isinstance(config, Mapping): @@ -202,7 +227,7 @@ async def _async_validate_config_item( _log_invalid_script(err, script_name, "could not be validated", config) if raise_on_errors: raise - return _minimal_config() + return _minimal_config(ValidationStatus.FAILED_SCHEMA, err, config) script_config = ScriptConfig(validated_config) script_config.raw_blueprint_inputs = raw_blueprint_inputs @@ -217,22 +242,34 @@ async def _async_validate_config_item( HomeAssistantError, ) as err: _log_invalid_script( - err, script_name, "failed to setup actions", validated_config + err, script_name, "failed to setup sequence", validated_config ) if raise_on_errors: raise - script_config.validation_failed = True + _set_validation_status( + script_config, ValidationStatus.FAILED_SEQUENCE, err, validated_config + ) return script_config return script_config +class ValidationStatus(StrEnum): + """What was changed in a config entry.""" + + FAILED_BLUEPRINT = "failed_blueprint" + FAILED_SCHEMA = "failed_schema" + FAILED_SEQUENCE = "failed_sequence" + OK = "ok" + + class ScriptConfig(dict): """Dummy class to allow adding attributes.""" raw_config: ConfigType | None = None raw_blueprint_inputs: ConfigType | 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/script/strings.json b/homeassistant/components/script/strings.json index f2d5997ae9d..d233b0680f8 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -1,4 +1,7 @@ { + "common": { + "validation_failed_title": "Script {name} failed to set up" + }, "title": "Script", "entity_component": { "_": { @@ -32,6 +35,20 @@ } } }, + "issues": { + "validation_failed_blueprint": { + "title": "[%key:component::script::common::validation_failed_title%]", + "description": "The blueprinted script \"{name}\" (`{entity_id}`) failed to set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the script]({edit}) to correct it, then save and reload the script configuration." + }, + "validation_failed_schema": { + "title": "[%key:component::script::common::validation_failed_title%]", + "description": "The script \"{name}\" (`{entity_id}`) is not active because the configuration has errors.\n\nError:`{error}`.\n\nTo fix this error, [edit the script]({edit}) to correct it, then save and reload the script configuration." + }, + "validation_failed_sequence": { + "title": "[%key:component::script::common::validation_failed_title%]", + "description": "The script \"{name}\" (`{entity_id}`) is not active because its sequence could not be set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the script]({edit}) to correct it, then save and reload the script configuration." + } + }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 2352e9c64e6..1d5100916b7 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest @@ -47,11 +47,13 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, + MockUser, async_fire_time_changed, async_mock_service, mock_restore_cache, ) from tests.components.logbook.common import MockRow, mock_humanify +from tests.components.repairs import get_repairs from tests.typing import WebSocketGenerator ENTITY_ID = "script.test" @@ -252,13 +254,14 @@ async def test_bad_config_validation_critical( @pytest.mark.parametrize( - ("object_id", "broken_config", "problem", "details"), + ("object_id", "broken_config", "problem", "details", "issue"), [ ( "bad_script", {}, "could not be validated", "required key not provided @ data['sequence']", + "validation_failed_schema", ), ( "bad_script", @@ -270,18 +273,22 @@ async def test_bad_config_validation_critical( "state": "blah", }, }, - "failed to setup actions", + "failed to setup sequence", "Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.", + "validation_failed_sequence", ), ], ) async def test_bad_config_validation( hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, + hass_admin_user: MockUser, object_id, broken_config, problem, details, + issue, ) -> None: """Test bad script configuration which can be detected during validation.""" assert await async_setup_component( @@ -301,11 +308,22 @@ async def test_bad_config_validation( }, ) - # Check we get the expected error message + # Check we get the expected error message and issue assert ( f"Script with alias 'bad_script' {problem} and has been disabled: {details}" in caplog.text ) + issues = await get_repairs(hass, hass_ws_client) + assert len(issues) == 1 + assert issues[0]["issue_id"] == f"script.bad_script_{issue}" + assert issues[0]["translation_key"] == issue + assert issues[0]["translation_placeholders"] == { + "edit": "/config/script/edit/bad_script", + "entity_id": "script.bad_script", + "error": ANY, + "name": "bad_script", + } + assert issues[0]["translation_placeholders"]["error"].startswith(details) # Make sure both scripts are setup assert set(hass.states.async_entity_ids("script")) == { @@ -315,6 +333,31 @@ async def test_bad_config_validation( # The script failing validation should be unavailable assert hass.states.get("script.bad_script").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={ + script.DOMAIN: { + object_id: { + "alias": "bad_script", + "sequence": { + "service": "test.automation", + "entity_id": "hello.world", + }, + }, + } + }, + ): + await hass.services.async_call( + script.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 + @pytest.mark.parametrize("running", ["no", "same", "different"]) async def test_reload_service(hass: HomeAssistant, running) -> None: @@ -1563,9 +1606,7 @@ async def test_script_service_changed_entity_id( assert calls[1].data["entity_id"] == "script.custom_entity_id_2" -async def test_blueprint_automation( - hass: HomeAssistant, calls: list[ServiceCall] -) -> None: +async def test_blueprint_script(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test blueprint script.""" assert await async_setup_component( hass, @@ -1623,6 +1664,7 @@ async def test_blueprint_automation( ) async def test_blueprint_script_bad_config( hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, blueprint_inputs, problem, @@ -1646,9 +1688,24 @@ async def test_blueprint_script_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"script.test_script_{issue}" + assert issues[0]["translation_key"] == issue + assert issues[0]["translation_placeholders"] == { + "edit": "/config/script/edit/test_script", + "entity_id": "script.test_script", + "error": ANY, + "name": "test_script", + } + assert issues[0]["translation_placeholders"]["error"].startswith(details) + async def test_blueprint_script_fails_substitution( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test blueprint script with bad inputs.""" with patch( @@ -1677,6 +1734,18 @@ async def test_blueprint_script_fails_substitution( 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"script.test_script_{issue}" + assert issues[0]["translation_key"] == issue + assert issues[0]["translation_placeholders"] == { + "edit": "/config/script/edit/test_script", + "entity_id": "script.test_script", + "error": "No substitution found for input blah", + "name": "test_script", + } + @pytest.mark.parametrize("response", [{"value": 5}, '{"value": 5}']) async def test_responses(hass: HomeAssistant, response: Any) -> None: