Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions homeassistant/components/automation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,21 @@
SERVICE_TURN_ON,
STATE_ON,
)
from homeassistant.config import async_log_exception, config_without_domain
from homeassistant.core import Context, CoreState
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import condition, extract_domain_configs, script
from homeassistant.helpers import (
condition,
config_per_platform,
extract_domain_configs,
script,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ENTITY_SERVICE_SCHEMA
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.loader import bind_hass
from homeassistant.loader import bind_hass, IntegrationNotFound
from homeassistant.util.dt import parse_datetime, utcnow


Expand Down Expand Up @@ -372,6 +378,58 @@ def device_state_attributes(self):
return {CONF_ID: self._id}


async def async_validate_config_item(hass, config):
"""Validate config item."""
config = PLATFORM_SCHEMA(config)

triggers = []
for trigger in config[CONF_TRIGGER]:
trigger_platform = importlib.import_module(
".{}".format(trigger[CONF_PLATFORM]), __name__
)
if hasattr(trigger_platform, "async_validate_trigger_config"):
trigger = await trigger_platform.async_validate_trigger_config(
hass, trigger
)
triggers.append(trigger)
config[CONF_TRIGGER] = triggers

if CONF_CONDITION in config:
conditions = []
for cond in config[CONF_CONDITION]:
cond = await condition.async_validate_condition_config(hass, cond)
conditions.append(cond)
config[CONF_CONDITION] = conditions

actions = []
for action in config[CONF_ACTION]:
action = await script.async_validate_action_config(hass, action)
actions.append(action)
config[CONF_ACTION] = actions

return config


async def async_validate_config(hass, config):
"""Validate config."""
automations = []
for _, p_config in config_per_platform(config, DOMAIN):
try:
p_validated = await async_validate_config_item(hass, p_config)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use asyncio.gather to run in parallel.

except (vol.Invalid, HomeAssistantError, IntegrationNotFound) as ex:
async_log_exception(ex, DOMAIN, p_config, hass)
continue

automations.append(p_validated)

# Create a copy of the configuration with all config for current
# component removed and add validated config back in.
config = config_without_domain(config, DOMAIN)
config[DOMAIN] = automations

return config


async def _async_process_config(hass, config, component):
"""Process config and add automations.

Expand Down
11 changes: 7 additions & 4 deletions homeassistant/components/automation/device.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Offer device oriented automation."""
import voluptuous as vol

import homeassistant.components.device_automation as device_automation
from homeassistant.const import CONF_DOMAIN, CONF_PLATFORM
from homeassistant.loader import async_get_integration


# mypy: allow-untyped-defs, no-check-untyped-defs
Expand All @@ -13,8 +13,11 @@
)


async def async_validate_trigger_config(hass, config):
"""Validate config."""
return await device_automation.async_validate_trigger_config(hass, config)


async def async_trigger(hass, config, action, automation_info):
"""Listen for trigger."""
integration = await async_get_integration(hass, config[CONF_DOMAIN])
platform = integration.get_platform("device_automation")
return await platform.async_trigger(hass, config, action, automation_info)
return await device_automation.async_trigger(hass, config, action, automation_info)
15 changes: 11 additions & 4 deletions homeassistant/components/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
import voluptuous as vol

from homeassistant.core import callback
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import ATTR_COMPONENT
from homeassistant.components.http import HomeAssistantView
from homeassistant.util.yaml import load_yaml, dump

DOMAIN = "config"
Expand Down Expand Up @@ -80,6 +81,7 @@ def __init__(
data_schema,
*,
post_write_hook=None,
data_validator=None,
):
"""Initialize a config view."""
self.url = f"/api/config/{component}/{config_type}/{{config_key}}"
Expand All @@ -88,6 +90,7 @@ def __init__(
self.key_schema = key_schema
self.data_schema = data_schema
self.post_write_hook = post_write_hook
self.data_validator = data_validator

def _empty_config(self):
"""Empty config if file not found."""
Expand Down Expand Up @@ -128,14 +131,18 @@ async def post(self, request, config_key):
except vol.Invalid as err:
return self.json_message(f"Key malformed: {err}", 400)

hass = request.app["hass"]

try:
# We just validate, we don't store that data because
# we don't want to store the defaults.
self.data_schema(data)
except vol.Invalid as err:
if self.data_validator:
await self.data_validator(hass, data)
else:
self.data_schema(data)
except (vol.Invalid, HomeAssistantError) as err:
return self.json_message(f"Message malformed: {err}", 400)

hass = request.app["hass"]
path = hass.config.path(self.path)

current = await self.read_config(hass)
Expand Down
7 changes: 6 additions & 1 deletion homeassistant/components/config/automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
from collections import OrderedDict
import uuid

from homeassistant.components.automation import DOMAIN, PLATFORM_SCHEMA
from homeassistant.components.automation import (
DOMAIN,
PLATFORM_SCHEMA,
async_validate_config_item,
)
from homeassistant.const import CONF_ID, SERVICE_RELOAD
import homeassistant.helpers.config_validation as cv

Expand All @@ -26,6 +30,7 @@ async def hook(hass):
cv.string,
PLATFORM_SCHEMA,
post_write_hook=hook,
data_validator=async_validate_config_item,
)
)
return True
Expand Down
71 changes: 71 additions & 0 deletions homeassistant/components/device_automation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration, IntegrationNotFound

from .exceptions import InvalidDeviceAutomationConfig

DOMAIN = "device_automation"

_LOGGER = logging.getLogger(__name__)
Expand All @@ -32,6 +34,68 @@ async def async_setup(hass, config):
return True


async def async_get_device_automation_platform(hass, config):
"""Load device automation platform for integration.

Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation.
"""
try:
integration = await async_get_integration(hass, config[CONF_DOMAIN])
platform = integration.get_platform("device_automation")
except IntegrationNotFound:
raise InvalidDeviceAutomationConfig(
f"Integration '{config[CONF_DOMAIN]}' not found"
)
except ImportError:
raise InvalidDeviceAutomationConfig(
f"Integration '{config[CONF_DOMAIN]}' does not support device automations"
)

return platform


async def async_validate_action_config(hass, config):
"""Validate config."""
platform = await async_get_device_automation_platform(hass, config)
if not hasattr(platform, "async_get_actions"):
raise InvalidDeviceAutomationConfig(
f"Integration '{config[CONF_DOMAIN]}' does not support device automation actions"
)

return platform.ACTION_SCHEMA(config)


async def async_validate_condition_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
platform = await async_get_device_automation_platform(hass, config)
if not hasattr(platform, "async_get_conditions"):
raise InvalidDeviceAutomationConfig(
f"Integration '{config[CONF_DOMAIN]}' does not support device automation conditions"
)

return platform.CONDITION_SCHEMA(config)


async def async_validate_trigger_config(hass, config):
"""Validate config."""
platform = await async_get_device_automation_platform(hass, config)
if not hasattr(platform, "async_get_triggers"):
raise InvalidDeviceAutomationConfig(
f"Integration '{config[CONF_DOMAIN]}' does not support device automation triggers"
)

return platform.TRIGGER_SCHEMA(config)


async def async_handle_action(hass, action, variables, context):
"""Perform the device automation specified in the action."""
integration = await async_get_integration(hass, action[CONF_DOMAIN])
platform = integration.get_platform("device_automation")
await platform.async_call_action_from_config(hass, action, variables, context)


async def async_device_condition_from_config(
hass: HomeAssistant, config: ConfigType, config_validation: bool = True
) -> Callable[..., bool]:
Expand All @@ -46,6 +110,13 @@ async def async_device_condition_from_config(
)


async def async_trigger(hass, config, action, automation_info):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am renaming this in #26871 to async_attach_trigger

"""Listen for trigger."""
integration = await async_get_integration(hass, config[CONF_DOMAIN])
platform = integration.get_platform("device_automation")
return await platform.async_trigger(hass, config, action, automation_info)


async def _async_get_device_automations_from_domain(hass, domain, fname, device_id):
"""List device automations."""
integration = None
Expand Down
32 changes: 21 additions & 11 deletions homeassistant/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None:

@callback
def async_log_exception(
ex: vol.Invalid, domain: str, config: Dict, hass: HomeAssistant
ex: Exception, domain: str, config: Dict, hass: HomeAssistant
) -> None:
"""Log an error for configuration validation.

Expand All @@ -428,23 +428,26 @@ def async_log_exception(


@callback
def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str:
def _format_config_error(ex: Exception, domain: str, config: Dict) -> str:
"""Generate log exception for configuration validation.

This method must be run in the event loop.
"""
message = f"Invalid config for [{domain}]: "
if "extra keys not allowed" in ex.error_message:
message += (
"[{option}] is an invalid option for [{domain}]. "
"Check: {domain}->{path}.".format(
option=ex.path[-1],
domain=domain,
path="->".join(str(m) for m in ex.path),
if isinstance(ex, vol.Invalid):
if "extra keys not allowed" in ex.error_message:
message += (
"[{option}] is an invalid option for [{domain}]. "
"Check: {domain}->{path}.".format(
option=ex.path[-1],
domain=domain,
path="->".join(str(m) for m in ex.path),
)
)
)
else:
message += "{}.".format(humanize_error(config, ex))
else:
message += "{}.".format(humanize_error(config, ex))
message += str(ex)

try:
domain_config = config.get(domain, config)
Expand Down Expand Up @@ -717,6 +720,13 @@ async def async_process_component_config(
_LOGGER.error("Unable to import %s: %s", domain, ex)
return None

if hasattr(component, "async_validate_config"):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the test for this?

try:
return await component.async_validate_config(hass, config) # type: ignore
except (vol.Invalid, HomeAssistantError) as ex:
async_log_exception(ex, domain, config, hass)
return None

if hasattr(component, "CONFIG_SCHEMA"):
try:
return component.CONFIG_SCHEMA(config) # type: ignore
Expand Down
20 changes: 20 additions & 0 deletions homeassistant/helpers/condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from homeassistant.components import zone as zone_cmp
from homeassistant.components.device_automation import ( # noqa: F401 pylint: disable=unused-import
async_device_condition_from_config as async_device_from_config,
async_validate_condition_config as async_validate_device_condition_config,
)
from homeassistant.const import (
ATTR_GPS_ACCURACY,
Expand Down Expand Up @@ -488,3 +489,22 @@ def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
return zone(hass, zone_entity_id, entity_id)

return if_in_zone


async def async_validate_condition_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
condition = config[CONF_CONDITION]
if condition in ("and", "or"):
conditions = []
for sub_cond in config["conditions"]:
sub_cond = await async_validate_condition_config(hass, sub_cond)
conditions.append(sub_cond)
config["conditions"] = conditions

if condition == "device":
config = cv.DEVICE_CONDITION_SCHEMA(config)
return await async_validate_device_condition_config(hass, config)

return config
2 changes: 1 addition & 1 deletion homeassistant/helpers/config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def validate(obj: Dict) -> Dict:
for k in obj.keys():
if k in keys:
return obj
raise vol.Invalid("must contain one of {}.".format(", ".join(keys)))
raise vol.Invalid("must contain at least one of {}.".format(", ".join(keys)))

return validate

Expand Down
Loading