From 3b17e570dfe7059d4a46927fb4ba9fb171f28963 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2020 06:05:43 +0100 Subject: [PATCH] Add device actions to cover (#28064) * Add device actions to cover * Remove toggle action, improve tests * lint * lint * Simplify actions * Update homeassistant/components/cover/device_action.py Co-Authored-By: Paulus Schoutsen * Improve tests Co-authored-by: Paulus Schoutsen --- .../components/cover/device_action.py | 176 +++++++ homeassistant/components/cover/strings.json | 8 + tests/components/cover/test_device_action.py | 454 ++++++++++++++++++ .../custom_components/test/cover.py | 50 +- 4 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/cover/device_action.py create mode 100644 tests/components/cover/test_device_action.py diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py new file mode 100644 index 00000000000..dba4ff8be89 --- /dev/null +++ b/homeassistant/components/cover/device_action.py @@ -0,0 +1,176 @@ +"""Provides device automations for Cover.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, +) + +CMD_ACTION_TYPES = {"open", "close", "open_tilt", "close_tilt"} +POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} + +CMD_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(CMD_ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + } +) + +POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(POSITION_ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required("position"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + } +) + +ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Cover devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: + continue + + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + + # Add actions for each entity that belongs to this integration + if supported_features & SUPPORT_SET_POSITION: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_position", + } + ) + else: + if supported_features & SUPPORT_OPEN: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "open", + } + ) + if supported_features & SUPPORT_CLOSE: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "close", + } + ) + + if supported_features & SUPPORT_SET_TILT_POSITION: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_tilt_position", + } + ) + else: + if supported_features & SUPPORT_OPEN_TILT: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "open_tilt", + } + ) + if supported_features & SUPPORT_CLOSE_TILT: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "close_tilt", + } + ) + + return actions + + +async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List action capabilities.""" + if config[CONF_TYPE] not in POSITION_ACTION_TYPES: + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Optional("position", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + } + ) + } + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + if config[CONF_TYPE] == "open": + service = SERVICE_OPEN_COVER + elif config[CONF_TYPE] == "close": + service = SERVICE_CLOSE_COVER + elif config[CONF_TYPE] == "open_tilt": + service = SERVICE_OPEN_COVER_TILT + elif config[CONF_TYPE] == "close_tilt": + service = SERVICE_CLOSE_COVER_TILT + elif config[CONF_TYPE] == "set_position": + service = SERVICE_SET_COVER_POSITION + service_data[ATTR_POSITION] = config["position"] + elif config[CONF_TYPE] == "set_tilt_position": + service = SERVICE_SET_COVER_TILT_POSITION + service_data[ATTR_TILT_POSITION] = config["position"] + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 36492cc5ed5..90dac7c7d02 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,5 +1,13 @@ { "device_automation": { + "action_type": { + "open": "Open {entity_name}", + "close": "Close {entity_name}", + "open_tilt": "Open {entity_name} tilt", + "close_tilt": "Close {entity_name} tilt", + "set_position": "Set {entity_name} position", + "set_tilt_position": "Set {entity_name} tilt position" + }, "condition_type": { "is_open": "{entity_name} is open", "is_closed": "{entity_name} is closed", diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py new file mode 100644 index 00000000000..e70c18621f4 --- /dev/null +++ b/tests/components/cover/test_device_action.py @@ -0,0 +1,454 @@ +"""The tests for Cover device actions.""" +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.cover import DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers import device_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automation_capabilities, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +async def test_get_actions(hass, device_reg, entity_reg): + """Test we get the expected actions from a cover.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[0] + + 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( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "open", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "close", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_tilt(hass, device_reg, entity_reg): + """Test we get the expected actions from a cover.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[3] + + 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( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "open", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "close", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "open_tilt", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "close_tilt", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_set_pos(hass, device_reg, entity_reg): + """Test we get the expected actions from a cover.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[1] + + 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( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "set_position", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_set_tilt_pos(hass, device_reg, entity_reg): + """Test we get the expected actions from a cover.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[2] + + 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( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "open", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "close", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "set_tilt_position", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_action_capabilities(hass, device_reg, entity_reg): + """Test we get the expected capabilities from a cover action.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[0] + + 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( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert len(actions) == 2 # open, close + for action in actions: + capabilities = await async_get_device_automation_capabilities( + hass, "action", action + ) + assert capabilities == {"extra_fields": []} + + +async def test_get_action_capabilities_set_pos(hass, device_reg, entity_reg): + """Test we get the expected capabilities from a cover action.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[1] + + 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( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_capabilities = { + "extra_fields": [ + { + "name": "position", + "optional": True, + "type": "integer", + "default": 0, + "valueMax": 100, + "valueMin": 0, + } + ] + } + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert len(actions) == 1 # set_position + for action in actions: + capabilities = await async_get_device_automation_capabilities( + hass, "action", action + ) + if action["type"] == "set_position": + assert capabilities == expected_capabilities + else: + assert capabilities == {"extra_fields": []} + + +async def test_get_action_capabilities_set_tilt_pos(hass, device_reg, entity_reg): + """Test we get the expected capabilities from a cover action.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[2] + + 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( + DOMAIN, "test", ent.unique_id, device_id=device_entry.id + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_capabilities = { + "extra_fields": [ + { + "name": "position", + "optional": True, + "type": "integer", + "default": 0, + "valueMax": 100, + "valueMin": 0, + } + ] + } + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert len(actions) == 3 # open, close, set_tilt_position + for action in actions: + capabilities = await async_get_device_automation_capabilities( + hass, "action", action + ) + if action["type"] == "set_tilt_position": + assert capabilities == expected_capabilities + else: + assert capabilities == {"extra_fields": []} + + +async def test_action(hass): + """Test for cover actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event_open"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "open", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event_close"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "close", + }, + }, + ] + }, + ) + + open_calls = async_mock_service(hass, "cover", "open_cover") + close_calls = async_mock_service(hass, "cover", "close_cover") + + hass.bus.async_fire("test_event_open") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 0 + + hass.bus.async_fire("test_event_close") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 1 + + hass.bus.async_fire("test_event_stop") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 1 + + +async def test_action_tilt(hass): + """Test for cover tilt actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event_open"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "open_tilt", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event_close"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "close_tilt", + }, + }, + ] + }, + ) + + open_calls = async_mock_service(hass, "cover", "open_cover_tilt") + close_calls = async_mock_service(hass, "cover", "close_cover_tilt") + + hass.bus.async_fire("test_event_open") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 0 + + hass.bus.async_fire("test_event_close") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 1 + + hass.bus.async_fire("test_event_stop") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 1 + + +async def test_action_set_position(hass): + """Test for cover set position actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_pos", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "set_position", + "position": 25, + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_tilt_pos", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "set_tilt_position", + "position": 75, + }, + }, + ] + }, + ) + + cover_pos_calls = async_mock_service(hass, "cover", "set_cover_position") + tilt_pos_calls = async_mock_service(hass, "cover", "set_cover_tilt_position") + + hass.bus.async_fire("test_event_set_pos") + await hass.async_block_till_done() + assert len(cover_pos_calls) == 1 + assert cover_pos_calls[0].data["position"] == 25 + assert len(tilt_pos_calls) == 0 + + hass.bus.async_fire("test_event_set_tilt_pos") + await hass.async_block_till_done() + assert len(cover_pos_calls) == 1 + assert len(tilt_pos_calls) == 1 + assert tilt_pos_calls[0].data["tilt_position"] == 75 diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index ce5462790bb..bdaacfa4e3c 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -3,7 +3,17 @@ Provide a mock cover platform. Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import ( + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, + SUPPORT_STOP_TILT, + CoverDevice, +) from tests.common import MockEntity @@ -18,18 +28,31 @@ def init(empty=False): [] if empty else [ - MockCover(name=f"Simple cover", is_on=True, unique_id=f"unique_cover"), + MockCover( + name=f"Simple cover", + is_on=True, + unique_id=f"unique_cover", + supports_tilt=False, + ), MockCover( name=f"Set position cover", is_on=True, unique_id=f"unique_set_pos_cover", current_cover_position=50, + supports_tilt=False, ), MockCover( name=f"Set tilt position cover", is_on=True, unique_id=f"unique_set_pos_tilt_cover", current_cover_tilt_position=50, + supports_tilt=True, + ), + MockCover( + name=f"Tilt cover", + is_on=True, + unique_id=f"unique_tilt_cover", + supports_tilt=True, ), ] ) @@ -59,3 +82,26 @@ class MockCover(MockEntity, CoverDevice): def current_cover_tilt_position(self): """Return current position of cover tilt.""" return self._handle("current_cover_tilt_position") + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + + if self._handle("supports_tilt"): + supported_features |= ( + SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT + ) + + if self.current_cover_position is not None: + supported_features |= SUPPORT_SET_POSITION + + if self.current_cover_tilt_position is not None: + supported_features |= ( + SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + | SUPPORT_SET_TILT_POSITION + ) + + return supported_features