diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index 3f48e2afde6..581ce6b461d 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -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) diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index 70b79becb8b..1749ea91c5b 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -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" diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index a7e04f874b4..fa6deac40ba 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -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. diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py index a69ca7ab8f2..4abf34e6661 100644 --- a/homeassistant/components/light/device_condition.py +++ b/homeassistant/components/light/device_condition.py @@ -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) diff --git a/homeassistant/components/switch/device_condition.py b/homeassistant/components/switch/device_condition.py index 032c765bf59..5825a3ba91a 100644 --- a/homeassistant/components/switch/device_condition.py +++ b/homeassistant/components/switch/device_condition.py @@ -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) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index afb8c3934a7..df82ba6076f 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -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 diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index d9b3df8c01b..05b28102726 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -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 diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 8a92f69e574..fa78ae94416 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -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(