Improve validation of device condition config (#27131)

* Improve validation of device condition config

* Fix typing
This commit is contained in:
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.const import CONF_PLATFORM
from homeassistant.config import async_log_exception, config_without_domain from homeassistant.config import async_log_exception, config_without_domain
from homeassistant.exceptions import HomeAssistantError 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 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: allow-untyped-calls, allow-untyped-defs
# mypy: no-check-untyped-defs, no-warn-return-any # 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) triggers.append(trigger)
config[CONF_TRIGGER] = triggers 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 = [] actions = []
for action in config[CONF_ACTION]: for action in config[CONF_ACTION]:
action = await script.async_validate_action_config(hass, action) action = await script.async_validate_action_config(hass, action)

View file

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

View file

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

View file

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

View file

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

View file

@ -4,10 +4,16 @@ import pytest
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.components.automation as automation import homeassistant.components.automation as automation
from homeassistant.components.websocket_api.const import TYPE_RESULT 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 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 @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): async def test_automation_with_integration_without_device_trigger(hass, caplog):
"""Test automation with integration without device trigger support.""" """Test automation with integration without device trigger support."""
assert await async_setup_component( 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 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): async def test_automation_with_bad_trigger(hass, caplog):
"""Test automation with bad device trigger.""" """Test automation with bad device trigger."""
assert await async_setup_component( assert await async_setup_component(