From 6c89b6c5a2229a1000cd539a6c025380541e17f7 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 4 Jan 2020 16:58:51 -0500 Subject: [PATCH] Add Zigbee group binding to ZHA (#30433) * initial group binding work * add group cluster binding --- homeassistant/components/zha/api.py | 65 ++++++++++++++++++++ homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/device.py | 66 +++++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 6228a2bc0c8..7c732b6906e 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1,7 +1,9 @@ """Web socket API for Zigbee Home Automation devices.""" import asyncio +import collections import logging +from typing import Any import voluptuous as vol from zigpy.types.named import EUI64 @@ -31,6 +33,7 @@ from .core.const import ( ATTR_WARNING_DEVICE_STROBE, ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, ATTR_WARNING_DEVICE_STROBE_INTENSITY, + BINDINGS, CHANNEL_IAS_WD, CLUSTER_COMMAND_SERVER, CLUSTER_COMMANDS_CLIENT, @@ -163,6 +166,8 @@ SERVICE_SCHEMAS = { ), } +ClusterBinding = collections.namedtuple("ClusterBinding", "id endpoint_id type name") + @websocket_api.require_admin @websocket_api.async_response @@ -774,6 +779,64 @@ async def websocket_unbind_devices(hass, connection, msg): ) +def is_cluster_binding(value: Any) -> ClusterBinding: + """Validate and transform a cluster binding.""" + if not isinstance(value, collections.Mapping): + raise vol.Invalid("Not a cluster binding") + try: + cluster_binding = ClusterBinding( + name=value["name"], + type=value["type"], + id=value["id"], + endpoint_id=value["endpoint_id"], + ) + except KeyError: + raise vol.Invalid("Not a cluster binding") + + return cluster_binding + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/bind", + vol.Required(ATTR_SOURCE_IEEE): EUI64.convert, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [is_cluster_binding]), + } +) +async def websocket_bind_group(hass, connection, msg): + """Directly bind a device to a group.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee = msg[ATTR_SOURCE_IEEE] + group_id = msg[GROUP_ID] + bindings = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + + await source_device.async_bind_to_group(group_id, bindings) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/unbind", + vol.Required(ATTR_SOURCE_IEEE): EUI64.convert, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [is_cluster_binding]), + } +) +async def websocket_unbind_group(hass, connection, msg): + """Unbind a device from a group.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee = msg[ATTR_SOURCE_IEEE] + group_id = msg[GROUP_ID] + bindings = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + await source_device.async_unbind_from_group(group_id, bindings) + + async def async_binding_operation(zha_gateway, source_ieee, target_ieee, operation): """Create or remove a direct zigbee binding between 2 devices.""" @@ -1082,6 +1145,8 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_remove_groups) websocket_api.async_register_command(hass, websocket_add_group_members) websocket_api.async_register_command(hass, websocket_remove_group_members) + websocket_api.async_register_command(hass, websocket_bind_group) + websocket_api.async_register_command(hass, websocket_unbind_group) websocket_api.async_register_command(hass, websocket_reconfigure_node) websocket_api.async_register_command(hass, websocket_device_clusters) websocket_api.async_register_command(hass, websocket_device_cluster_attributes) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index c658febfd2d..61be496fa1c 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -42,6 +42,7 @@ ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE = "duty_cycle" ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] +BINDINGS = "bindings" CHANNEL_ACCELEROMETER = "accelerometer" CHANNEL_ATTRIBUTE = "attribute" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index dbaf3fd4435..634a06f7f58 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -10,10 +10,12 @@ from enum import Enum import logging import time +from zigpy import types import zigpy.exceptions from zigpy.profiles import zha, zll import zigpy.quirks from zigpy.zcl.clusters.general import Groups +import zigpy.zdo.types as zdo_types from homeassistant.core import callback from homeassistant.helpers.dispatcher import ( @@ -526,6 +528,70 @@ class ZHADevice(LogMixin): """Remove this device from the provided zigbee group.""" await self._zigpy_device.remove_from_group(group_id) + async def async_bind_to_group(self, group_id, cluster_bindings): + """Directly bind this device to a group for the given clusters.""" + await self._async_group_binding_operation( + group_id, zdo_types.ZDOCmd.Bind_req, cluster_bindings + ) + + async def async_unbind_from_group(self, group_id, cluster_bindings): + """Unbind this device from a group for the given clusters.""" + await self._async_group_binding_operation( + group_id, zdo_types.ZDOCmd.Unbind_req, cluster_bindings + ) + + async def _async_group_binding_operation( + self, group_id, operation, cluster_bindings + ): + """Create or remove a direct zigbee binding between a device and a group.""" + + zdo = self._zigpy_device.zdo + op_msg = "0x%04x: %s %s, ep: %s, cluster: %s to group: 0x%04x" + destination_address = zdo_types.MultiAddress() + destination_address.addrmode = types.uint8_t(1) + destination_address.nwk = types.uint16_t(group_id) + + tasks = [] + + for cluster_binding in cluster_bindings: + if cluster_binding.endpoint_id == 0: + continue + if ( + cluster_binding.id + in self._zigpy_device.endpoints[ + cluster_binding.endpoint_id + ].out_clusters + ): + op_params = ( + self.nwk, + operation.name, + str(self.ieee), + cluster_binding.endpoint_id, + cluster_binding.id, + group_id, + ) + zdo.debug("processing " + op_msg, *op_params) + tasks.append( + ( + zdo.request( + operation, + self.ieee, + cluster_binding.endpoint_id, + cluster_binding.id, + destination_address, + ), + op_msg, + op_params, + ) + ) + res = await asyncio.gather(*(t[0] for t in tasks), return_exceptions=True) + for outcome, log_msg in zip(res, tasks): + if isinstance(outcome, Exception): + fmt = log_msg[1] + " failed: %s" + else: + fmt = log_msg[1] + " completed: %s" + zdo.debug(fmt, *(log_msg[2] + (outcome,))) + def log(self, level, msg, *args): """Log a message.""" msg = f"[%s](%s): {msg}"