Improve validation of device condition config (#27131)

* Improve validation of device condition config

* Fix typing
pull/27140/head
Erik Montnemery 2019-10-03 00:58:14 +02:00 committed by Paulus Schoutsen
parent 363873dfcb
commit c43eeee62f
8 changed files with 269 additions and 21 deletions

View File

@ -7,10 +7,10 @@ import voluptuous as vol
from homeassistant.const import CONF_PLATFORM
from homeassistant.config import async_log_exception, config_without_domain
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_per_platform, script
from homeassistant.helpers import condition, config_per_platform, script
from homeassistant.loader import IntegrationNotFound
from . import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORM_SCHEMA
from . import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORM_SCHEMA
# mypy: allow-untyped-calls, allow-untyped-defs
# mypy: no-check-untyped-defs, no-warn-return-any
@ -33,6 +33,13 @@ async def async_validate_config_item(hass, config, full_config=None):
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)

View File

@ -232,7 +232,8 @@ def async_condition_from_config(
config: ConfigType, config_validation: bool
) -> condition.ConditionCheckerType:
"""Evaluate state based on configuration."""
config = CONDITION_SCHEMA(config)
if config_validation:
config = CONDITION_SCHEMA(config)
condition_type = config[CONF_TYPE]
if condition_type in IS_ON:
stat = "on"

View File

@ -2,12 +2,14 @@
import asyncio
import logging
from typing import Any, List, MutableMapping
from types import ModuleType
import voluptuous as vol
import voluptuous_serialize
from homeassistant.const import CONF_PLATFORM, CONF_DOMAIN, CONF_DEVICE_ID
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_registry import async_entries_for_device
from homeassistant.loader import async_get_integration, IntegrationNotFound
@ -63,7 +65,9 @@ async def async_setup(hass, config):
return True
async def async_get_device_automation_platform(hass, domain, automation_type):
async def async_get_device_automation_platform(
hass: HomeAssistant, domain: str, automation_type: str
) -> ModuleType:
"""Load device automation platform for integration.
Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation.

View File

@ -19,7 +19,8 @@ def async_condition_from_config(
config: ConfigType, config_validation: bool
) -> ConditionCheckerType:
"""Evaluate state based on configuration."""
config = CONDITION_SCHEMA(config)
if config_validation:
config = CONDITION_SCHEMA(config)
return toggle_entity.async_condition_from_config(config, config_validation)

View File

@ -19,7 +19,8 @@ def async_condition_from_config(
config: ConfigType, config_validation: bool
) -> ConditionCheckerType:
"""Evaluate state based on configuration."""
config = CONDITION_SCHEMA(config)
if config_validation:
config = CONDITION_SCHEMA(config)
return toggle_entity.async_condition_from_config(config, config_validation)

View File

@ -8,29 +8,31 @@ from typing import Callable, Container, Optional, Union, cast
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from homeassistant.loader import async_get_integration
from homeassistant.core import HomeAssistant, State
from homeassistant.components import zone as zone_cmp
from homeassistant.components.device_automation import (
async_get_device_automation_platform,
)
from homeassistant.const import (
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_ABOVE,
CONF_AFTER,
CONF_BEFORE,
CONF_BELOW,
CONF_CONDITION,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_VALUE_TEMPLATE,
CONF_CONDITION,
WEEKDAYS,
CONF_STATE,
CONF_ZONE,
CONF_BEFORE,
CONF_AFTER,
CONF_VALUE_TEMPLATE,
CONF_WEEKDAY,
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
CONF_BELOW,
CONF_ABOVE,
CONF_ZONE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
WEEKDAYS,
)
from homeassistant.exceptions import TemplateError, HomeAssistantError
import homeassistant.helpers.config_validation as cv
@ -498,9 +500,32 @@ async def async_device_from_config(
"""Test a device condition."""
if config_validation:
config = cv.DEVICE_CONDITION_SCHEMA(config)
integration = await async_get_integration(hass, config[CONF_DOMAIN])
platform = integration.get_platform("device_condition")
platform = await async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "condition"
)
return cast(
ConditionCheckerType,
platform.async_condition_from_config(config, config_validation), # type: ignore
)
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)
platform = await async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "condition"
)
return cast(ConfigType, platform.CONDITION_SCHEMA(config)) # type: ignore
return config

View File

@ -96,7 +96,12 @@ async def async_validate_action_config(
platform = await device_automation.async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "action"
)
config = platform.ACTION_SCHEMA(config)
config = platform.ACTION_SCHEMA(config) # type: ignore
if action_type == ACTION_CHECK_CONDITION and config[CONF_CONDITION] == "device":
platform = await device_automation.async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "condition"
)
config = platform.CONDITION_SCHEMA(config) # type: ignore
return config

View File

@ -4,10 +4,16 @@ import pytest
from homeassistant.setup import async_setup_component
import homeassistant.components.automation as automation
from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM
from homeassistant.helpers import device_registry
from tests.common import MockConfigEntry, mock_device_registry, mock_registry
from tests.common import (
MockConfigEntry,
async_mock_service,
mock_device_registry,
mock_registry,
)
@pytest.fixture
@ -301,6 +307,31 @@ async def test_automation_with_integration_without_device_action(hass, caplog):
)
async def test_automation_with_integration_without_device_condition(hass, caplog):
"""Test automation with integration without device condition support."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event1"},
"condition": {
"condition": "device",
"device_id": "none",
"domain": "test",
},
"action": {"service": "test.automation", "entity_id": "hello.world"},
}
},
)
assert (
"Integration 'test' does not support device automation conditions"
in caplog.text
)
async def test_automation_with_integration_without_device_trigger(hass, caplog):
"""Test automation with integration without device trigger support."""
assert await async_setup_component(
@ -341,6 +372,179 @@ async def test_automation_with_bad_action(hass, caplog):
assert "required key not provided" in caplog.text
async def test_automation_with_bad_condition_action(hass, caplog):
"""Test automation with bad device action."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event1"},
"action": {"condition": "device", "device_id": "", "domain": "light"},
}
},
)
assert "required key not provided" in caplog.text
async def test_automation_with_bad_condition(hass, caplog):
"""Test automation with bad device condition."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event1"},
"condition": {"condition": "device", "domain": "light"},
"action": {"service": "test.automation", "entity_id": "hello.world"},
}
},
)
assert "required key not provided" in caplog.text
@pytest.fixture
def calls(hass):
"""Track calls to a mock serivce."""
return async_mock_service(hass, "test", "automation")
async def test_automation_with_sub_condition(hass, calls):
"""Test automation with device condition under and/or conditions."""
DOMAIN = "light"
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
ent1, ent2, ent3 = platform.ENTITIES
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {"platform": "event", "event_type": "test_event1"},
"condition": [
{
"condition": "and",
"conditions": [
{
"condition": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": ent1.entity_id,
"type": "is_on",
},
{
"condition": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": ent2.entity_id,
"type": "is_on",
},
],
}
],
"action": {
"service": "test.automation",
"data_template": {
"some": "and {{ trigger.%s }}"
% "}} - {{ trigger.".join(("platform", "event.event_type"))
},
},
},
{
"trigger": {"platform": "event", "event_type": "test_event1"},
"condition": [
{
"condition": "or",
"conditions": [
{
"condition": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": ent1.entity_id,
"type": "is_on",
},
{
"condition": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": ent2.entity_id,
"type": "is_on",
},
],
}
],
"action": {
"service": "test.automation",
"data_template": {
"some": "or {{ trigger.%s }}"
% "}} - {{ trigger.".join(("platform", "event.event_type"))
},
},
},
]
},
)
await hass.async_block_till_done()
assert hass.states.get(ent1.entity_id).state == STATE_ON
assert hass.states.get(ent2.entity_id).state == STATE_OFF
assert len(calls) == 0
hass.bus.async_fire("test_event1")
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data["some"] == "or event - test_event1"
hass.states.async_set(ent1.entity_id, STATE_OFF)
hass.bus.async_fire("test_event1")
await hass.async_block_till_done()
assert len(calls) == 1
hass.states.async_set(ent2.entity_id, STATE_ON)
hass.bus.async_fire("test_event1")
await hass.async_block_till_done()
assert len(calls) == 2
assert calls[1].data["some"] == "or event - test_event1"
hass.states.async_set(ent1.entity_id, STATE_ON)
hass.bus.async_fire("test_event1")
await hass.async_block_till_done()
assert len(calls) == 4
assert _same_lists(
[calls[2].data["some"], calls[3].data["some"]],
["or event - test_event1", "and event - test_event1"],
)
async def test_automation_with_bad_sub_condition(hass, caplog):
"""Test automation with bad device condition under and/or conditions."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event1"},
"condition": {
"condition": "and",
"conditions": [{"condition": "device", "domain": "light"}],
},
"action": {"service": "test.automation", "entity_id": "hello.world"},
}
},
)
assert "required key not provided" in caplog.text
async def test_automation_with_bad_trigger(hass, caplog):
"""Test automation with bad device trigger."""
assert await async_setup_component(