mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 07:37:34 +00:00
Add device action support for ZHA (#26903)
* start implementing device actions * rename file * cleanup and add tests * fix docstrings * sort imports
This commit is contained in:
parent
f267b37105
commit
b1a9fa47ca
@ -1,63 +1,67 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"abort": {
|
"abort": {
|
||||||
"single_instance_allowed": "Only a single configuration of ZHA is allowed."
|
"single_instance_allowed": "Only a single configuration of ZHA is allowed."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "Unable to connect to ZHA device."
|
"cannot_connect": "Unable to connect to ZHA device."
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"radio_type": "Radio Type",
|
"radio_type": "Radio Type",
|
||||||
"usb_path": "USB Device Path"
|
"usb_path": "USB Device Path"
|
||||||
},
|
|
||||||
"title": "ZHA"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"title": "ZHA"
|
"title": "ZHA"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"device_automation": {
|
"title": "ZHA"
|
||||||
"trigger_subtype": {
|
},
|
||||||
"both_buttons": "Both buttons",
|
"device_automation": {
|
||||||
"button_1": "First button",
|
"action_type": {
|
||||||
"button_2": "Second button",
|
"squawk": "Squawk",
|
||||||
"button_3": "Third button",
|
"warn": "Warn"
|
||||||
"button_4": "Fourth button",
|
},
|
||||||
"button_5": "Fifth button",
|
"trigger_subtype": {
|
||||||
"button_6": "Sixth button",
|
"both_buttons": "Both buttons",
|
||||||
"close": "Close",
|
"button_1": "First button",
|
||||||
"dim_down": "Dim down",
|
"button_2": "Second button",
|
||||||
"dim_up": "Dim up",
|
"button_3": "Third button",
|
||||||
"face_1": "with face 1 activated",
|
"button_4": "Fourth button",
|
||||||
"face_2": "with face 2 activated",
|
"button_5": "Fifth button",
|
||||||
"face_3": "with face 3 activated",
|
"button_6": "Sixth button",
|
||||||
"face_4": "with face 4 activated",
|
"close": "Close",
|
||||||
"face_5": "with face 5 activated",
|
"dim_down": "Dim down",
|
||||||
"face_6": "with face 6 activated",
|
"dim_up": "Dim up",
|
||||||
"face_any": "With any/specified face(s) activated",
|
"face_1": "with face 1 activated",
|
||||||
"left": "Left",
|
"face_2": "with face 2 activated",
|
||||||
"open": "Open",
|
"face_3": "with face 3 activated",
|
||||||
"right": "Right",
|
"face_4": "with face 4 activated",
|
||||||
"turn_off": "Turn off",
|
"face_5": "with face 5 activated",
|
||||||
"turn_on": "Turn on"
|
"face_6": "with face 6 activated",
|
||||||
},
|
"face_any": "With any/specified face(s) activated",
|
||||||
"trigger_type": {
|
"left": "Left",
|
||||||
"device_dropped": "Device dropped",
|
"open": "Open",
|
||||||
"device_flipped": "Device flipped \"{subtype}\"",
|
"right": "Right",
|
||||||
"device_knocked": "Device knocked \"{subtype}\"",
|
"turn_off": "Turn off",
|
||||||
"device_rotated": "Device rotated \"{subtype}\"",
|
"turn_on": "Turn on"
|
||||||
"device_shaken": "Device shaken",
|
},
|
||||||
"device_slid": "Device slid \"{subtype}\"",
|
"trigger_type": {
|
||||||
"device_tilted": "Device tilted",
|
"device_dropped": "Device dropped",
|
||||||
"remote_button_double_press": "\"{subtype}\" button double clicked",
|
"device_flipped": "Device flipped \"{subtype}\"",
|
||||||
"remote_button_long_press": "\"{subtype}\" button continuously pressed",
|
"device_knocked": "Device knocked \"{subtype}\"",
|
||||||
"remote_button_long_release": "\"{subtype}\" button released after long press",
|
"device_rotated": "Device rotated \"{subtype}\"",
|
||||||
"remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked",
|
"device_shaken": "Device shaken",
|
||||||
"remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked",
|
"device_slid": "Device slid \"{subtype}\"",
|
||||||
"remote_button_short_press": "\"{subtype}\" button pressed",
|
"device_tilted": "Device tilted",
|
||||||
"remote_button_short_release": "\"{subtype}\" button released",
|
"remote_button_double_press": "\"{subtype}\" button double clicked",
|
||||||
"remote_button_triple_press": "\"{subtype}\" button triple clicked"
|
"remote_button_long_press": "\"{subtype}\" button continuously pressed",
|
||||||
}
|
"remote_button_long_release": "\"{subtype}\" button released after long press",
|
||||||
|
"remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked",
|
||||||
|
"remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked",
|
||||||
|
"remote_button_short_press": "\"{subtype}\" button pressed",
|
||||||
|
"remote_button_short_release": "\"{subtype}\" button released",
|
||||||
|
"remote_button_triple_press": "\"{subtype}\" button triple clicked"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -10,7 +10,14 @@ import logging
|
|||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
|
||||||
from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, DEFAULT_BAUDRATE, RadioType
|
from .const import (
|
||||||
|
CLUSTER_TYPE_IN,
|
||||||
|
CLUSTER_TYPE_OUT,
|
||||||
|
DATA_ZHA,
|
||||||
|
DATA_ZHA_GATEWAY,
|
||||||
|
DEFAULT_BAUDRATE,
|
||||||
|
RadioType,
|
||||||
|
)
|
||||||
from .registries import BINDABLE_CLUSTERS
|
from .registries import BINDABLE_CLUSTERS
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -132,6 +139,16 @@ def async_is_bindable_target(source_zha_device, target_zha_device):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_zha_device(hass, device_id):
|
||||||
|
"""Get a ZHA device for the given device registry id."""
|
||||||
|
device_registry = await hass.helpers.device_registry.async_get_registry()
|
||||||
|
registry_device = device_registry.async_get(device_id)
|
||||||
|
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
|
||||||
|
ieee_address = list(list(registry_device.identifiers)[0])[1]
|
||||||
|
ieee = convert_ieee(ieee_address)
|
||||||
|
return zha_gateway.devices[ieee]
|
||||||
|
|
||||||
|
|
||||||
class LogMixin:
|
class LogMixin:
|
||||||
"""Log helper."""
|
"""Log helper."""
|
||||||
|
|
||||||
|
92
homeassistant/components/zha/device_action.py
Normal file
92
homeassistant/components/zha/device_action.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Provides device actions for ZHA devices."""
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE
|
||||||
|
from homeassistant.core import Context, HomeAssistant
|
||||||
|
from homeassistant.helpers import config_validation as cv, service
|
||||||
|
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||||
|
|
||||||
|
from . import DOMAIN
|
||||||
|
from .api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN
|
||||||
|
from .core.const import CHANNEL_IAS_WD
|
||||||
|
from .core.helpers import async_get_zha_device
|
||||||
|
|
||||||
|
ACTION_SQUAWK = "squawk"
|
||||||
|
ACTION_WARN = "warn"
|
||||||
|
ATTR_DATA = "data"
|
||||||
|
ATTR_IEEE = "ieee"
|
||||||
|
CONF_ZHA_ACTION_TYPE = "zha_action_type"
|
||||||
|
ZHA_ACTION_TYPE_SERVICE_CALL = "service_call"
|
||||||
|
|
||||||
|
ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
|
||||||
|
{vol.Required(CONF_DOMAIN): DOMAIN, vol.Required(CONF_TYPE): str}
|
||||||
|
)
|
||||||
|
|
||||||
|
DEVICE_ACTIONS = {
|
||||||
|
CHANNEL_IAS_WD: [
|
||||||
|
{CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN},
|
||||||
|
{CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
DEVICE_ACTION_TYPES = {
|
||||||
|
ACTION_SQUAWK: ZHA_ACTION_TYPE_SERVICE_CALL,
|
||||||
|
ACTION_WARN: ZHA_ACTION_TYPE_SERVICE_CALL,
|
||||||
|
}
|
||||||
|
|
||||||
|
SERVICE_NAMES = {
|
||||||
|
ACTION_SQUAWK: SERVICE_WARNING_DEVICE_SQUAWK,
|
||||||
|
ACTION_WARN: SERVICE_WARNING_DEVICE_WARN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_call_action_from_config(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: ConfigType,
|
||||||
|
variables: TemplateVarsType,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]:
|
||||||
|
"""List device actions."""
|
||||||
|
zha_device = await async_get_zha_device(hass, device_id)
|
||||||
|
actions = [
|
||||||
|
action
|
||||||
|
for channel in DEVICE_ACTIONS
|
||||||
|
for action in DEVICE_ACTIONS[channel]
|
||||||
|
if channel in zha_device.cluster_channels
|
||||||
|
]
|
||||||
|
for action in actions:
|
||||||
|
action[CONF_DEVICE_ID] = device_id
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_service_based_action(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: ACTION_SCHEMA,
|
||||||
|
variables: TemplateVarsType,
|
||||||
|
context: Context,
|
||||||
|
) -> None:
|
||||||
|
action_type = config[CONF_TYPE]
|
||||||
|
service_name = SERVICE_NAMES[action_type]
|
||||||
|
zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
|
||||||
|
|
||||||
|
service_action = {
|
||||||
|
service.CONF_SERVICE: "{}.{}".format(DOMAIN, service_name),
|
||||||
|
ATTR_DATA: {ATTR_IEEE: str(zha_device.ieee)},
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.async_call_from_config(
|
||||||
|
hass, service_action, blocking=True, variables=variables, context=context
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ZHA_ACTION_TYPES = {ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action}
|
@ -9,8 +9,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF
|
|||||||
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
|
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
|
||||||
|
|
||||||
from . import DOMAIN
|
from . import DOMAIN
|
||||||
from .core.const import DATA_ZHA, DATA_ZHA_GATEWAY
|
from .core.helpers import async_get_zha_device
|
||||||
from .core.helpers import convert_ieee
|
|
||||||
|
|
||||||
CONF_SUBTYPE = "subtype"
|
CONF_SUBTYPE = "subtype"
|
||||||
DEVICE = "device"
|
DEVICE = "device"
|
||||||
@ -26,7 +25,7 @@ async def async_attach_trigger(hass, config, action, automation_info):
|
|||||||
"""Listen for state changes based on configuration."""
|
"""Listen for state changes based on configuration."""
|
||||||
config = TRIGGER_SCHEMA(config)
|
config = TRIGGER_SCHEMA(config)
|
||||||
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
|
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
|
||||||
zha_device = await _async_get_zha_device(hass, config[CONF_DEVICE_ID])
|
zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
zha_device.device_automation_triggers is None
|
zha_device.device_automation_triggers is None
|
||||||
@ -52,7 +51,7 @@ async def async_get_triggers(hass, device_id):
|
|||||||
Make sure the device supports device automations and
|
Make sure the device supports device automations and
|
||||||
if it does return the trigger list.
|
if it does return the trigger list.
|
||||||
"""
|
"""
|
||||||
zha_device = await _async_get_zha_device(hass, device_id)
|
zha_device = await async_get_zha_device(hass, device_id)
|
||||||
|
|
||||||
if not zha_device.device_automation_triggers:
|
if not zha_device.device_automation_triggers:
|
||||||
return
|
return
|
||||||
@ -70,15 +69,3 @@ async def async_get_triggers(hass, device_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return triggers
|
return triggers
|
||||||
|
|
||||||
|
|
||||||
async def _async_get_zha_device(hass, device_id):
|
|
||||||
device_registry = await hass.helpers.device_registry.async_get_registry()
|
|
||||||
registry_device = device_registry.async_get(device_id)
|
|
||||||
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
|
|
||||||
ieee_address = list(list(registry_device.identifiers)[0])[1]
|
|
||||||
ieee = convert_ieee(ieee_address)
|
|
||||||
zha_device = zha_gateway.devices[ieee]
|
|
||||||
if not zha_device:
|
|
||||||
raise InvalidDeviceAutomationConfig
|
|
||||||
return zha_device
|
|
||||||
|
@ -18,6 +18,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"device_automation": {
|
"device_automation": {
|
||||||
|
"action_type": {
|
||||||
|
"squawk": "Squawk",
|
||||||
|
"warn": "Warn"
|
||||||
|
},
|
||||||
"trigger_type": {
|
"trigger_type": {
|
||||||
"remote_button_short_press": "\"{subtype}\" button pressed",
|
"remote_button_short_press": "\"{subtype}\" button pressed",
|
||||||
"remote_button_short_release": "\"{subtype}\" button released",
|
"remote_button_short_release": "\"{subtype}\" button released",
|
||||||
|
133
tests/components/zha/test_device_action.py
Normal file
133
tests/components/zha/test_device_action.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""The test for zha device automation actions."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import homeassistant.components.automation as automation
|
||||||
|
from homeassistant.components.device_automation import (
|
||||||
|
_async_get_device_automations as async_get_device_automations,
|
||||||
|
)
|
||||||
|
from homeassistant.components.zha import DOMAIN
|
||||||
|
from homeassistant.components.zha.core.const import CHANNEL_ON_OFF
|
||||||
|
from homeassistant.helpers.device_registry import async_get_registry
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from .common import async_enable_traffic, async_init_zigpy_device
|
||||||
|
|
||||||
|
from tests.common import async_mock_service, mock_coro
|
||||||
|
|
||||||
|
SHORT_PRESS = "remote_button_short_press"
|
||||||
|
COMMAND = "command"
|
||||||
|
COMMAND_SINGLE = "single"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calls(hass):
|
||||||
|
"""Track calls to a mock serivce."""
|
||||||
|
return async_mock_service(hass, "zha", "warning_device_warn")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_actions(hass, config_entry, zha_gateway):
|
||||||
|
"""Test we get the expected actions from a zha device."""
|
||||||
|
from zigpy.zcl.clusters.general import Basic
|
||||||
|
from zigpy.zcl.clusters.security import IasZone, IasWd
|
||||||
|
|
||||||
|
# create zigpy device
|
||||||
|
zigpy_device = await async_init_zigpy_device(
|
||||||
|
hass,
|
||||||
|
[Basic.cluster_id, IasZone.cluster_id, IasWd.cluster_id],
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
zha_gateway,
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.config_entries._entries.append(config_entry)
|
||||||
|
|
||||||
|
zha_device = zha_gateway.get_device(zigpy_device.ieee)
|
||||||
|
ieee_address = str(zha_device.ieee)
|
||||||
|
|
||||||
|
ha_device_registry = await async_get_registry(hass)
|
||||||
|
reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}, set())
|
||||||
|
|
||||||
|
actions = await async_get_device_automations(hass, "action", reg_device.id)
|
||||||
|
|
||||||
|
expected_actions = [
|
||||||
|
{"domain": DOMAIN, "type": "squawk", "device_id": reg_device.id},
|
||||||
|
{"domain": DOMAIN, "type": "warn", "device_id": reg_device.id},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert actions == expected_actions
|
||||||
|
|
||||||
|
|
||||||
|
async def test_action(hass, config_entry, zha_gateway, calls):
|
||||||
|
"""Test for executing a zha device action."""
|
||||||
|
|
||||||
|
from zigpy.zcl.clusters.general import Basic, OnOff
|
||||||
|
from zigpy.zcl.clusters.security import IasZone, IasWd
|
||||||
|
from zigpy.zcl.foundation import Status
|
||||||
|
|
||||||
|
# create zigpy device
|
||||||
|
zigpy_device = await async_init_zigpy_device(
|
||||||
|
hass,
|
||||||
|
[Basic.cluster_id, IasZone.cluster_id, IasWd.cluster_id],
|
||||||
|
[OnOff.cluster_id],
|
||||||
|
None,
|
||||||
|
zha_gateway,
|
||||||
|
)
|
||||||
|
|
||||||
|
zigpy_device.device_automation_triggers = {
|
||||||
|
(SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setup(config_entry, "switch")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
hass.config_entries._entries.append(config_entry)
|
||||||
|
|
||||||
|
zha_device = zha_gateway.get_device(zigpy_device.ieee)
|
||||||
|
ieee_address = str(zha_device.ieee)
|
||||||
|
|
||||||
|
ha_device_registry = await async_get_registry(hass)
|
||||||
|
reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}, set())
|
||||||
|
|
||||||
|
# allow traffic to flow through the gateway and device
|
||||||
|
await async_enable_traffic(hass, zha_gateway, [zha_device])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"zigpy.zcl.Cluster.request", return_value=mock_coro([0x00, Status.SUCCESS])
|
||||||
|
):
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
automation.DOMAIN,
|
||||||
|
{
|
||||||
|
automation.DOMAIN: [
|
||||||
|
{
|
||||||
|
"trigger": {
|
||||||
|
"device_id": reg_device.id,
|
||||||
|
"domain": "zha",
|
||||||
|
"platform": "device",
|
||||||
|
"type": SHORT_PRESS,
|
||||||
|
"subtype": SHORT_PRESS,
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"domain": DOMAIN,
|
||||||
|
"device_id": reg_device.id,
|
||||||
|
"type": "warn",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
on_off_channel = zha_device.cluster_channels[CHANNEL_ON_OFF]
|
||||||
|
on_off_channel.zha_send_event(on_off_channel.cluster, COMMAND_SINGLE, [])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0].domain == DOMAIN
|
||||||
|
assert calls[0].service == "warning_device_warn"
|
||||||
|
assert calls[0].data["ieee"] == ieee_address
|
@ -1,4 +1,4 @@
|
|||||||
"""ZHA device automation tests."""
|
"""ZHA device automation trigger tests."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
Loading…
x
Reference in New Issue
Block a user