mirror of
https://github.com/home-assistant/core.git
synced 2025-07-13 16:27:08 +00:00
Raise repair issues when automations can't be set up (#120010)
This commit is contained in:
parent
5138c3de0a
commit
d2a5683fa0
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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": {
|
||||
|
@ -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."""
|
||||
|
Loading…
x
Reference in New Issue
Block a user