Improve validation of device condition config (#27131)
* Improve validation of device condition config * Fix typing
This commit is contained in:
parent
363873dfcb
commit
c43eeee62f
8 changed files with 269 additions and 21 deletions
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue