Skip to content

Commit

Permalink
Add support for for to binary_sensor, light and switch device trigg…
Browse files Browse the repository at this point in the history
…ers (#26658)

* Add support for `for` to binary_sensor, light and switch device triggers

* Add WS API device_automation/trigger/capabilities
  • Loading branch information
emontnemery authored Oct 2, 2019
1 parent d8c6b28 commit 65ce3b4
Show file tree
Hide file tree
Showing 21 changed files with 495 additions and 42 deletions.
9 changes: 7 additions & 2 deletions homeassistant/components/automation/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
TRIGGER_BASE_SCHEMA,
async_get_device_automation_platform,
)
from homeassistant.const import CONF_DOMAIN


# mypy: allow-untyped-defs, no-check-untyped-defs
Expand All @@ -14,11 +15,15 @@

async def async_validate_trigger_config(hass, config):
"""Validate config."""
platform = await async_get_device_automation_platform(hass, config, "trigger")
platform = await async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "trigger"
)
return platform.TRIGGER_SCHEMA(config)


async def async_attach_trigger(hass, config, action, automation_info):
"""Listen for trigger."""
platform = await async_get_device_automation_platform(hass, config, "trigger")
platform = await async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "trigger"
)
return await platform.async_attach_trigger(hass, config, action, automation_info)
15 changes: 13 additions & 2 deletions homeassistant/components/binary_sensor/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
CONF_TURNED_OFF,
CONF_TURNED_ON,
)
from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_TYPE
from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FOR, CONF_TYPE
from homeassistant.helpers.entity_registry import async_entries_for_device
from homeassistant.helpers import config_validation as cv

Expand Down Expand Up @@ -175,13 +175,13 @@
{
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Required(CONF_TYPE): vol.In(TURNED_OFF + TURNED_ON),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
)


async def async_attach_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
config = TRIGGER_SCHEMA(config)
trigger_type = config[CONF_TYPE]
if trigger_type in TURNED_ON:
from_state = "off"
Expand All @@ -195,6 +195,8 @@ async def async_attach_trigger(hass, config, action, automation_info):
state_automation.CONF_FROM: from_state,
state_automation.CONF_TO: to_state,
}
if "for" in config:
state_config["for"] = config["for"]

return await state_automation.async_attach_trigger(
hass, state_config, action, automation_info, platform_type="device"
Expand Down Expand Up @@ -236,3 +238,12 @@ async def async_get_triggers(hass, device_id):
)

return triggers


async def async_get_trigger_capabilities(hass, trigger):
"""List trigger capabilities."""
return {
"extra_fields": vol.Schema(
{vol.Optional(CONF_FOR): cv.positive_time_period_dict}
)
}
2 changes: 0 additions & 2 deletions homeassistant/components/deconz/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,6 @@ def _get_deconz_event_from_device_id(hass, device_id):

async def async_attach_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
config = TRIGGER_SCHEMA(config)

device_registry = await hass.helpers.device_registry.async_get_registry()
device = device_registry.async_get(config[CONF_DEVICE_ID])

Expand Down
93 changes: 72 additions & 21 deletions homeassistant/components/device_automation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
from typing import Any, List, MutableMapping

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.helpers import config_validation as cv
from homeassistant.helpers.entity_registry import async_entries_for_device
from homeassistant.loader import async_get_integration, IntegrationNotFound

Expand All @@ -29,9 +31,18 @@
)

TYPES = {
"trigger": ("device_trigger", "async_get_triggers"),
"condition": ("device_condition", "async_get_conditions"),
"action": ("device_action", "async_get_actions"),
# platform name, get automations function, get capabilities function
"trigger": (
"device_trigger",
"async_get_triggers",
"async_get_trigger_capabilities",
),
"condition": (
"device_condition",
"async_get_conditions",
"async_get_condition_capabilities",
),
"action": ("device_action", "async_get_actions", "async_get_action_capabilities"),
}


Expand All @@ -46,25 +57,26 @@ async def async_setup(hass, config):
hass.components.websocket_api.async_register_command(
websocket_device_automation_list_triggers
)
hass.components.websocket_api.async_register_command(
websocket_device_automation_get_trigger_capabilities
)
return True


async def async_get_device_automation_platform(hass, config, automation_type):
async def async_get_device_automation_platform(hass, domain, automation_type):
"""Load device automation platform for integration.
Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation.
"""
platform_name, _ = TYPES[automation_type]
platform_name = TYPES[automation_type][0]
try:
integration = await async_get_integration(hass, config[CONF_DOMAIN])
integration = await async_get_integration(hass, domain)
platform = integration.get_platform(platform_name)
except IntegrationNotFound:
raise InvalidDeviceAutomationConfig(
f"Integration '{config[CONF_DOMAIN]}' not found"
)
raise InvalidDeviceAutomationConfig(f"Integration '{domain}' not found")
except ImportError:
raise InvalidDeviceAutomationConfig(
f"Integration '{config[CONF_DOMAIN]}' does not support device automation {automation_type}s"
f"Integration '{domain}' does not support device automation {automation_type}s"
)

return platform
Expand All @@ -74,20 +86,14 @@ async def _async_get_device_automations_from_domain(
hass, domain, automation_type, device_id
):
"""List device automations."""
integration = None
try:
integration = await async_get_integration(hass, domain)
except IntegrationNotFound:
_LOGGER.warning("Integration %s not found", domain)
platform = await async_get_device_automation_platform(
hass, domain, automation_type
)
except InvalidDeviceAutomationConfig:
return None

platform_name, function_name = TYPES[automation_type]

try:
platform = integration.get_platform(platform_name)
except ImportError:
# The domain does not have device automations
return None
function_name = TYPES[automation_type][1]

return await getattr(platform, function_name)(hass, device_id)

Expand Down Expand Up @@ -125,6 +131,35 @@ async def _async_get_device_automations(hass, automation_type, device_id):
return automations


async def _async_get_device_automation_capabilities(hass, automation_type, automation):
"""List device automations."""
try:
platform = await async_get_device_automation_platform(
hass, automation[CONF_DOMAIN], automation_type
)
except InvalidDeviceAutomationConfig:
return {}

function_name = TYPES[automation_type][2]

if not hasattr(platform, function_name):
# The device automation has no capabilities
return {}

capabilities = await getattr(platform, function_name)(hass, automation)
capabilities = capabilities.copy()

extra_fields = capabilities.get("extra_fields")
if extra_fields is None:
capabilities["extra_fields"] = []
else:
capabilities["extra_fields"] = voluptuous_serialize.convert(
extra_fields, custom_serializer=cv.custom_serializer
)

return capabilities


@websocket_api.async_response
@websocket_api.websocket_command(
{
Expand Down Expand Up @@ -165,3 +200,19 @@ async def websocket_device_automation_list_triggers(hass, connection, msg):
device_id = msg["device_id"]
triggers = await _async_get_device_automations(hass, "trigger", device_id)
connection.send_result(msg["id"], triggers)


@websocket_api.async_response
@websocket_api.websocket_command(
{
vol.Required("type"): "device_automation/trigger/capabilities",
vol.Required("trigger"): dict,
}
)
async def websocket_device_automation_get_trigger_capabilities(hass, connection, msg):
"""Handle request for device trigger capabilities."""
trigger = msg["trigger"]
capabilities = await _async_get_device_automation_capabilities(
hass, "trigger", trigger
)
connection.send_result(msg["id"], capabilities)
21 changes: 19 additions & 2 deletions homeassistant/components/device_automation/toggle_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
CONF_TURNED_OFF,
CONF_TURNED_ON,
)
from homeassistant.const import CONF_CONDITION, CONF_ENTITY_ID, CONF_PLATFORM, CONF_TYPE
from homeassistant.const import (
CONF_CONDITION,
CONF_ENTITY_ID,
CONF_FOR,
CONF_PLATFORM,
CONF_TYPE,
)
from homeassistant.helpers.entity_registry import async_entries_for_device
from homeassistant.helpers import condition, config_validation as cv, service
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
Expand Down Expand Up @@ -81,6 +87,7 @@
{
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Required(CONF_TYPE): vol.In([CONF_TURNED_OFF, CONF_TURNED_ON]),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
)

Expand All @@ -93,7 +100,6 @@ async def async_call_action_from_config(
domain: str,
) -> None:
"""Change state based on configuration."""
config = ACTION_SCHEMA(config)
action_type = config[CONF_TYPE]
if action_type == CONF_TURN_ON:
action = "turn_on"
Expand Down Expand Up @@ -149,6 +155,8 @@ async def async_attach_trigger(
state.CONF_FROM: from_state,
state.CONF_TO: to_state,
}
if "for" in config:
state_config["for"] = config["for"]

return await state.async_attach_trigger(
hass, state_config, action, automation_info, platform_type="device"
Expand Down Expand Up @@ -203,3 +211,12 @@ async def async_get_triggers(
) -> List[dict]:
"""List device triggers."""
return await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, domain)


async def async_get_trigger_capabilities(hass: HomeAssistant, trigger: dict) -> dict:
"""List trigger capabilities."""
return {
"extra_fields": vol.Schema(
{vol.Optional(CONF_FOR): cv.positive_time_period_dict}
)
}
1 change: 0 additions & 1 deletion homeassistant/components/light/device_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ async def async_call_action_from_config(
context: Context,
) -> None:
"""Change state based on configuration."""
config = ACTION_SCHEMA(config)
await toggle_entity.async_call_action_from_config(
hass, config, variables, context, DOMAIN
)
Expand Down
6 changes: 5 additions & 1 deletion homeassistant/components/light/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ async def async_attach_trigger(
automation_info: dict,
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
config = TRIGGER_SCHEMA(config)
return await toggle_entity.async_attach_trigger(
hass, config, action, automation_info
)
Expand All @@ -31,3 +30,8 @@ async def async_attach_trigger(
async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]:
"""List device triggers."""
return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN)


async def async_get_trigger_capabilities(hass: HomeAssistant, trigger: dict) -> dict:
"""List trigger capabilities."""
return await toggle_entity.async_get_trigger_capabilities(hass, trigger)
1 change: 0 additions & 1 deletion homeassistant/components/switch/device_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ async def async_call_action_from_config(
context: Context,
) -> None:
"""Change state based on configuration."""
config = ACTION_SCHEMA(config)
await toggle_entity.async_call_action_from_config(
hass, config, variables, context, DOMAIN
)
Expand Down
6 changes: 5 additions & 1 deletion homeassistant/components/switch/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ async def async_attach_trigger(
automation_info: dict,
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
config = TRIGGER_SCHEMA(config)
return await toggle_entity.async_attach_trigger(
hass, config, action, automation_info
)
Expand All @@ -31,3 +30,8 @@ async def async_attach_trigger(
async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]:
"""List device triggers."""
return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN)


async def async_get_trigger_capabilities(hass: HomeAssistant, trigger: dict) -> dict:
"""List trigger capabilities."""
return await toggle_entity.async_get_trigger_capabilities(hass, trigger)
1 change: 0 additions & 1 deletion homeassistant/components/zha/device_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ async def async_call_action_from_config(
context: Context,
) -> None:
"""Perform an action based on configuration."""
config = ACTION_SCHEMA(config)
await ZHA_ACTION_TYPES[DEVICE_ACTION_TYPES[config[CONF_TYPE]]](
hass, config, variables, context
)
Expand Down
1 change: 0 additions & 1 deletion homeassistant/components/zha/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@

async def async_attach_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
config = TRIGGER_SCHEMA(config)
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])

Expand Down
14 changes: 13 additions & 1 deletion homeassistant/helpers/config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
from urllib.parse import urlparse
from uuid import UUID

import voluptuous as vol
from pkg_resources import parse_version
import voluptuous as vol
import voluptuous_serialize

import homeassistant.util.dt as dt_util
from homeassistant.const import (
Expand Down Expand Up @@ -374,6 +375,9 @@ def positive_timedelta(value: timedelta) -> timedelta:
return value


positive_time_period_dict = vol.All(time_period_dict, positive_timedelta)


def remove_falsy(value: List[T]) -> List[T]:
"""Remove falsy values from a list."""
return [v for v in value if v]
Expand Down Expand Up @@ -690,6 +694,14 @@ def validator(value):
return validator


def custom_serializer(schema):
"""Serialize additional types for voluptuous_serialize."""
if schema is positive_time_period_dict:
return {"type": "positive_time_period_dict"}

return voluptuous_serialize.UNSUPPORTED


# Schemas
PLATFORM_SCHEMA = vol.Schema(
{
Expand Down
Loading

0 comments on commit 65ce3b4

Please sign in to comment.