diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index cc0e8c25979..9508dd9c849 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -20,6 +20,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass, config): """Set up device automation.""" + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_actions + ) hass.components.websocket_api.async_register_command( websocket_device_automation_list_conditions ) @@ -93,6 +96,20 @@ async def _async_get_device_automations(hass, fname, device_id): return automations +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/action/list", + vol.Required("device_id"): str, + } +) +async def websocket_device_automation_list_actions(hass, connection, msg): + """Handle request for device actions.""" + device_id = msg["device_id"] + actions = await _async_get_device_automations(hass, "async_get_actions", device_id) + connection.send_result(msg["id"], actions) + + @websocket_api.async_response @websocket_api.websocket_command( { diff --git a/homeassistant/components/device_automation/const.py b/homeassistant/components/device_automation/const.py index b846718e96e..a668c78598a 100644 --- a/homeassistant/components/device_automation/const.py +++ b/homeassistant/components/device_automation/const.py @@ -1,5 +1,6 @@ """Constants for device automations.""" CONF_IS_OFF = "is_off" CONF_IS_ON = "is_on" +CONF_TOGGLE = "toggle" CONF_TURN_OFF = "turn_off" CONF_TURN_ON = "turn_on" diff --git a/homeassistant/components/light/device_automation.py b/homeassistant/components/light/device_automation.py index 66bad135659..4ddba8b9423 100644 --- a/homeassistant/components/light/device_automation.py +++ b/homeassistant/components/light/device_automation.py @@ -5,25 +5,48 @@ import homeassistant.components.automation.state as state from homeassistant.components.device_automation.const import ( CONF_IS_OFF, CONF_IS_ON, + CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON, ) from homeassistant.core import split_entity_id from homeassistant.const import ( CONF_CONDITION, + CONF_DEVICE, CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_PLATFORM, CONF_TYPE, ) -from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers import condition, config_validation as cv, service from homeassistant.helpers.entity_registry import async_entries_for_device from . import DOMAIN # mypy: allow-untyped-defs, no-check-untyped-defs +ENTITY_ACTIONS = [ + { + # Turn light off + CONF_DEVICE: None, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: CONF_TURN_OFF, + }, + { + # Turn light on + CONF_DEVICE: None, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: CONF_TURN_ON, + }, + { + # Toggle light + CONF_DEVICE: None, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: CONF_TOGGLE, + }, +] + ENTITY_CONDITIONS = [ { # True when light is turned off @@ -54,6 +77,18 @@ ENTITY_TRIGGERS = [ }, ] +ACTION_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_DEVICE): None, + vol.Optional(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON]), + } + ) +) + CONDITION_SCHEMA = vol.All( vol.Schema( { @@ -83,6 +118,31 @@ def _is_domain(entity, domain): return split_entity_id(entity.entity_id)[0] == domain +async def async_action_from_config(hass, config, variables, context): + """Change state based on configuration.""" + config = ACTION_SCHEMA(config) + action_type = config[CONF_TYPE] + if action_type == CONF_TURN_ON: + action = "turn_on" + elif action_type == CONF_TURN_OFF: + action = "turn_off" + else: + action = "toggle" + service_action = { + service.CONF_SERVICE: "light.{}".format(action), + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + } + + await service.async_call_from_config( + hass, + service_action, + blocking=True, + variables=variables, + # validate_config=False, + context=context, + ) + + def async_condition_from_config(config, config_validation): """Evaluate state based on configuration.""" config = CONDITION_SCHEMA(config) @@ -140,6 +200,11 @@ async def _async_get_automations(hass, device_id, automation_templates): return automations +async def async_get_actions(hass, device_id): + """List device actions.""" + return await _async_get_automations(hass, device_id, ENTITY_ACTIONS) + + async def async_get_conditions(hass, device_id): """List device conditions.""" return await _async_get_automations(hass, device_id, ENTITY_CONDITIONS) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 6f002d9ed8c..6b9e5d7d649 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,5 +1,10 @@ { "device_automation": { + "action_type": { + "toggle": "Toggle {name}", + "turn_on": "Turn on {name}", + "turn_off": "Turn off {name}" + }, "condition_type": { "is_on": "{name} is on", "is_off": "{name} is off" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3aa17befd48..743a848ff93 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -24,6 +24,7 @@ from homeassistant.const import ( CONF_ALIAS, CONF_BELOW, CONF_CONDITION, + CONF_DEVICE, CONF_DOMAIN, CONF_ENTITY_ID, CONF_ENTITY_NAMESPACE, @@ -861,6 +862,11 @@ _SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema( } ) +DEVICE_ACTION_SCHEMA = vol.Schema( + {vol.Required(CONF_DEVICE): None, vol.Required(CONF_DOMAIN): str}, + extra=vol.ALLOW_EXTRA, +) + SCRIPT_SCHEMA = vol.All( ensure_list, [ @@ -870,6 +876,7 @@ SCRIPT_SCHEMA = vol.All( _SCRIPT_WAIT_TEMPLATE_SCHEMA, EVENT_SCHEMA, CONDITION_SCHEMA, + DEVICE_ACTION_SCHEMA, ) ], ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index da173efcba6..1d8f915543f 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -9,7 +9,7 @@ from typing import Optional, Sequence, Callable, Dict, List, Set, Tuple import voluptuous as vol from homeassistant.core import HomeAssistant, Context, callback, CALLBACK_TYPE -from homeassistant.const import CONF_CONDITION, CONF_TIMEOUT +from homeassistant.const import CONF_CONDITION, CONF_DEVICE, CONF_DOMAIN, CONF_TIMEOUT from homeassistant import exceptions from homeassistant.helpers import ( service, @@ -22,6 +22,7 @@ from homeassistant.helpers.event import ( async_track_template, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_integration import homeassistant.util.dt as date_util from homeassistant.util.async_ import run_coroutine_threadsafe, run_callback_threadsafe @@ -48,6 +49,7 @@ ACTION_WAIT_TEMPLATE = "wait_template" ACTION_CHECK_CONDITION = "condition" ACTION_FIRE_EVENT = "event" ACTION_CALL_SERVICE = "call_service" +ACTION_DEVICE_AUTOMATION = "device" def _determine_action(action): @@ -64,6 +66,9 @@ def _determine_action(action): if CONF_EVENT in action: return ACTION_FIRE_EVENT + if CONF_DEVICE in action: + return ACTION_DEVICE_AUTOMATION + return ACTION_CALL_SERVICE @@ -117,6 +122,7 @@ class Script: ACTION_CHECK_CONDITION: self._async_check_condition, ACTION_FIRE_EVENT: self._async_fire_event, ACTION_CALL_SERVICE: self._async_call_service, + ACTION_DEVICE_AUTOMATION: self._async_device_automation, } @property @@ -318,6 +324,17 @@ class Script: context=context, ) + async def _async_device_automation(self, action, variables, context): + """Perform the device automation specified in the action. + + This method is a coroutine. + """ + self.last_action = action.get(CONF_ALIAS, "device automation") + self._log("Executing step %s" % self.last_action) + integration = await async_get_integration(self.hass, action[CONF_DOMAIN]) + platform = integration.get_platform("device_automation") + await platform.async_action_from_config(self.hass, action, variables, context) + async def _async_fire_event(self, action, variables, context): """Fire an event.""" self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT]) diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 4bcb68f119b..b084c64182b 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -31,6 +31,53 @@ def _same_lists(a, b): return True +async def test_websocket_get_actions(hass, hass_ws_client, device_reg, entity_reg): + """Test we get the expected conditions from a light through websocket.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + expected_actions = [ + { + "device": None, + "domain": "light", + "type": "turn_off", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + { + "device": None, + "domain": "light", + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + { + "device": None, + "domain": "light", + "type": "toggle", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + ] + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 1, "type": "device_automation/action/list", "device_id": device_entry.id} + ) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + actions = msg["result"] + assert _same_lists(actions, expected_actions) + + async def test_websocket_get_conditions(hass, hass_ws_client, device_reg, entity_reg): """Test we get the expected conditions from a light through websocket.""" await async_setup_component(hass, "device_automation", {}) diff --git a/tests/components/light/test_device_automation.py b/tests/components/light/test_device_automation.py index 0e356ae13aa..38c573b514f 100644 --- a/tests/components/light/test_device_automation.py +++ b/tests/components/light/test_device_automation.py @@ -46,6 +46,44 @@ def _same_lists(a, b): return True +async def test_get_actions(hass, device_reg, entity_reg): + """Test we get the expected actions from a light.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + expected_actions = [ + { + "device": None, + "domain": "light", + "type": "turn_off", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + { + "device": None, + "domain": "light", + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + { + "device": None, + "domain": "light", + "type": "toggle", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + ] + actions = await async_get_device_automations( + hass, "async_get_actions", device_entry.id + ) + assert _same_lists(actions, expected_actions) + + async def test_get_conditions(hass, device_reg, entity_reg): """Test we get the expected conditions from a light.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -263,3 +301,78 @@ async def test_if_state(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_off event - test_event2" + + +async def test_action(hass, calls): + """Test for turn_on and turn_off actions.""" + platform = getattr(hass.components, "test.light") + + platform.init() + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} + ) + + dev1, dev2, dev3 = platform.DEVICES + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "action": { + "device": None, + "domain": "light", + "entity_id": dev1.entity_id, + "type": "turn_off", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "action": { + "device": None, + "domain": "light", + "entity_id": dev1.entity_id, + "type": "turn_on", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "action": { + "device": None, + "domain": "light", + "entity_id": dev1.entity_id, + "type": "toggle", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_ON + + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_ON + + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_ON