Add direct binding for remotes and lights for ZHA (#21498)

* cluster matching and binding apis

implement binding

callback

fix loop

fix loops

* review comments

* use any because it is clearer
pull/21542/head
David F. Mulcahey 2019-02-28 13:04:35 -05:00 committed by Paulus Schoutsen
parent 5ce4fe65b2
commit 82bdd9568d
5 changed files with 192 additions and 5 deletions

View File

@ -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."""

View File

@ -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

View File

@ -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."""

View File

@ -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',

View File

@ -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