diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index f0739f9a073c2290e470c8636a8b4c844e53d6be..7ef42ef7da7aa20561c84d14eb047fe8fc9a44c6 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +import asyncio import logging import voluptuous as vol @@ -14,6 +15,7 @@ from .core.const import ( DOMAIN, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ARGS, IN, OUT, CLIENT_COMMANDS, SERVER_COMMANDS, SERVER, NAME, ATTR_ENDPOINT_ID) +from .core.helpers import get_matched_clusters, async_is_bindable_target _LOGGER = logging.getLogger(__name__) @@ -26,11 +28,18 @@ DEVICE_INFO = 'device_info' ATTR_DURATION = 'duration' ATTR_IEEE_ADDRESS = 'ieee_address' ATTR_IEEE = 'ieee' +ATTR_SOURCE_IEEE = 'source_ieee' +ATTR_TARGET_IEEE = 'target_ieee' +BIND_REQUEST = 0x0021 +UNBIND_REQUEST = 0x0022 SERVICE_PERMIT = 'permit' SERVICE_REMOVE = 'remove' SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = 'set_zigbee_cluster_attribute' SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = 'issue_zigbee_cluster_command' +SERVICE_DIRECT_ZIGBEE_BIND = 'issue_direct_zigbee_bind' +SERVICE_DIRECT_ZIGBEE_UNBIND = 'issue_direct_zigbee_unbind' +SERVICE_ZIGBEE_BIND = 'service_zigbee_bind' IEEE_SERVICE = 'ieee_based_service' SERVICE_SCHEMAS = { @@ -110,6 +119,26 @@ SCHEMA_WS_CLUSTER_COMMANDS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required(ATTR_CLUSTER_TYPE): str }) +WS_BIND_DEVICE = 'zha/devices/bind' +SCHEMA_WS_BIND_DEVICE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_BIND_DEVICE, + vol.Required(ATTR_SOURCE_IEEE): str, + vol.Required(ATTR_TARGET_IEEE): str +}) + +WS_UNBIND_DEVICE = 'zha/devices/unbind' +SCHEMA_WS_UNBIND_DEVICE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_UNBIND_DEVICE, + vol.Required(ATTR_SOURCE_IEEE): str, + vol.Required(ATTR_TARGET_IEEE): str +}) + +WS_BINDABLE_DEVICES = 'zha/devices/bindable' +SCHEMA_WS_BINDABLE_DEVICES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_BINDABLE_DEVICES, + vol.Required(ATTR_IEEE): str +}) + def async_load_api(hass, application_controller, zha_gateway): """Set up the web socket API.""" @@ -244,6 +273,103 @@ def async_load_api(hass, application_controller, zha_gateway): SCHEMA_WS_RECONFIGURE_NODE ) + @websocket_api.async_response + async def websocket_get_bindable_devices(hass, connection, msg): + """Directly bind devices.""" + source_ieee = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + devices = [ + { + **device.device_info + } for device in zha_gateway.devices.values() if + async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug("Get bindable devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format('bindable devices:', devices) + ) + + connection.send_message(websocket_api.result_message( + msg[ID], + devices + )) + + hass.components.websocket_api.async_register_command( + WS_BINDABLE_DEVICES, websocket_get_bindable_devices, + SCHEMA_WS_BINDABLE_DEVICES + ) + + @websocket_api.async_response + async def websocket_bind_devices(hass, connection, msg): + """Directly bind devices.""" + source_ieee = msg[ATTR_SOURCE_IEEE] + target_ieee = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + source_ieee, target_ieee, BIND_REQUEST) + _LOGGER.info("Issue bind devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) + ) + + hass.components.websocket_api.async_register_command( + WS_BIND_DEVICE, websocket_bind_devices, + SCHEMA_WS_BIND_DEVICE + ) + + @websocket_api.async_response + async def websocket_unbind_devices(hass, connection, msg): + """Remove a direct binding between devices.""" + source_ieee = msg[ATTR_SOURCE_IEEE] + target_ieee = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + source_ieee, target_ieee, UNBIND_REQUEST) + _LOGGER.info("Issue unbind devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) + ) + + hass.components.websocket_api.async_register_command( + WS_UNBIND_DEVICE, websocket_unbind_devices, + SCHEMA_WS_UNBIND_DEVICE + ) + + async def async_binding_operation(source_ieee, target_ieee, + operation): + """Create or remove a direct zigbee binding between 2 devices.""" + from zigpy.zdo import types as zdo_types + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + clusters_to_bind = await get_matched_clusters(source_device, + target_device) + + bind_tasks = [] + for cluster_pair in clusters_to_bind: + destination_address = zdo_types.MultiAddress() + destination_address.addrmode = 3 + destination_address.ieee = target_device.ieee + destination_address.endpoint = \ + cluster_pair.target_cluster.endpoint.endpoint_id + + zdo = cluster_pair.source_cluster.endpoint.device.zdo + + _LOGGER.debug("processing binding operation for: %s %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee), + "{}: {}".format( + 'cluster', + cluster_pair.source_cluster.cluster_id) + ) + bind_tasks.append(zdo.request( + operation, + source_device.ieee, + cluster_pair.source_cluster.endpoint.endpoint_id, + cluster_pair.source_cluster.cluster_id, + destination_address + )) + await asyncio.gather(*bind_tasks) + @websocket_api.async_response async def websocket_device_clusters(hass, connection, msg): """Return a list of device clusters.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index d1001682c7b4e82ff5b3ee4819df3fc8d7ad02c8..3c8adb097482117f0deef03be126dd48d59396d2 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -114,6 +114,7 @@ CUSTOM_CLUSTER_MAPPINGS = {} COMPONENT_CLUSTERS = {} EVENT_RELAY_CLUSTERS = [] NO_SENSOR_CLUSTERS = [] +BINDABLE_CLUSTERS = [] REPORT_CONFIG_MAX_INT = 900 REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 102c9bed2d3703b1668c4f0d9cbbcb3ee884066e..182a08357b6cd09652a9c444d924a115c27028ee 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -242,6 +242,18 @@ class ZHADevice: if ep_id != 0 } + @callback + def async_get_zha_clusters(self): + """Get zigbee home automation clusters for this device.""" + from zigpy.profiles.zha import PROFILE_ID + return { + ep_id: { + IN: endpoint.in_clusters, + OUT: endpoint.out_clusters + } for (ep_id, endpoint) in self._zigpy_device.endpoints.items() + if ep_id != 0 and endpoint.profile_id == PROFILE_ID + } + @callback def async_get_cluster(self, endpoint_id, cluster_id, cluster_type=IN): """Get zigbee cluster from this entity.""" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 563543fa4bd8a1d60658abd9899c32a77edbd660..35a79311253e75eb9bbaa7c5962e3a132c71c285 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -18,11 +18,11 @@ from .const import ( ZHA_DISCOVERY_NEW, DEVICE_CLASS, SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, COMPONENT_CLUSTERS, HUMIDITY, TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, - GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, UNKNOWN, - OPENING, ZONE, OCCUPANCY, CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_IMMEDIATE, + GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, UNKNOWN, OPENING, ZONE, + OCCUPANCY, CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, NO_SENSOR_CLUSTERS, - POWER_CONFIGURATION_CHANNEL) + REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, + NO_SENSOR_CLUSTERS, POWER_CONFIGURATION_CHANNEL, BINDABLE_CLUSTERS) from .device import ZHADevice, DeviceStatus from ..device_entity import ZhaDeviceEntity from .channels import ( @@ -450,6 +450,8 @@ def establish_device_mappings(): NO_SENSOR_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) NO_SENSOR_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) + BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) + BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 643e44ada1bffdf1bb81634338feec80b3869163..d6e9cc32338d508bb5e94d93a8d246dffa8dc39e 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -5,14 +5,19 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ import asyncio +import collections import logging from concurrent.futures import TimeoutError as Timeout +from homeassistant.core import callback from .const import ( DEFAULT_BAUDRATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_RPT_CHANGE, RadioType) + REPORT_CONFIG_RPT_CHANGE, RadioType, IN, OUT, BINDABLE_CLUSTERS) _LOGGER = logging.getLogger(__name__) +ClusterPair = collections.namedtuple( + 'ClusterPair', 'source_cluster target_cluster') + async def safe_read(cluster, attributes, allow_cache=True, only_cache=False, manufacturer=None): @@ -157,3 +162,44 @@ def get_attr_id_by_name(cluster, attr_name): """Get the attribute id for a cluster attribute by its name.""" return next((attrid for attrid, (attrname, datatype) in cluster.attributes.items() if attr_name == attrname), None) + + +async def get_matched_clusters(source_zha_device, target_zha_device): + """Get matched input/output cluster pairs for 2 devices.""" + source_clusters = source_zha_device.async_get_zha_clusters() + target_clusters = target_zha_device.async_get_zha_clusters() + clusters_to_bind = [] + + for endpoint_id in source_clusters: + for cluster_id in source_clusters[endpoint_id][OUT]: + if cluster_id not in BINDABLE_CLUSTERS: + continue + for t_endpoint_id in target_clusters: + if cluster_id in target_clusters[t_endpoint_id][IN]: + cluster_pair = ClusterPair( + source_cluster=source_clusters[ + endpoint_id][OUT][cluster_id], + target_cluster=target_clusters[ + t_endpoint_id][IN][cluster_id] + ) + clusters_to_bind.append(cluster_pair) + return clusters_to_bind + + +@callback +def async_is_bindable_target(source_zha_device, target_zha_device): + """Determine if target is bindable to source.""" + source_clusters = source_zha_device.async_get_zha_clusters() + target_clusters = target_zha_device.async_get_zha_clusters() + + bindables = set(BINDABLE_CLUSTERS) + for endpoint_id in source_clusters: + for t_endpoint_id in target_clusters: + matches = set( + source_clusters[endpoint_id][OUT].keys() + ).intersection( + target_clusters[t_endpoint_id][IN].keys() + ) + if any(bindable in bindables for bindable in matches): + return True + return False