From 7be015fcc69e9f9424eea832a5e6a472b1be8536 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 11 Jan 2019 14:34:29 -0500 Subject: [PATCH] Add services and helper functions to support a config panel for ZHA (#19664) * reconfigure zha device service add log line to reconfigure service for consistency * add entity functions to support new services * added new services and web socket api and split them into their own module * support manufacturer code logging to debug get safe value for manufacturer * update services.yaml * add comma back * update coveragerc * remove blank line * fix type * api cleanup - review comments * move static method to helpers - review comment * convert reconfigure service to websocket command - review comment * change path * fix attribute --- .coveragerc | 1 + homeassistant/components/zha/__init__.py | 64 ++- homeassistant/components/zha/api.py | 416 ++++++++++++++++++ homeassistant/components/zha/const.py | 15 + .../components/zha/entities/entity.py | 114 ++++- homeassistant/components/zha/helpers.py | 12 +- homeassistant/components/zha/services.yaml | 60 +++ 7 files changed, 637 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/zha/api.py diff --git a/.coveragerc b/.coveragerc index 3f8d0e6959b..3107af9140a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -426,6 +426,7 @@ omit = homeassistant/components/zha/__init__.py homeassistant/components/zha/const.py homeassistant/components/zha/event.py + homeassistant/components/zha/api.py homeassistant/components/zha/entities/* homeassistant/components/zha/helpers.py homeassistant/components/*/zha.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 5dc8f628581..26d11586a1b 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -12,6 +12,7 @@ import types import voluptuous as vol from homeassistant import config_entries, const as ha_const + from homeassistant.components.zha.entities import ZhaDeviceEntity import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE @@ -22,6 +23,8 @@ from homeassistant.helpers.entity_component import EntityComponent from . import config_flow # noqa # pylint: disable=unused-import from . import const as zha_const from .event import ZhaEvent, ZhaRelayEvent +from . import api +from .helpers import convert_ieee from .const import ( COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG, CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_BRIDGE_ID, @@ -56,22 +59,6 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) -ATTR_DURATION = 'duration' -ATTR_IEEE = 'ieee_address' - -SERVICE_PERMIT = 'permit' -SERVICE_REMOVE = 'remove' -SERVICE_SCHEMAS = { - SERVICE_PERMIT: vol.Schema({ - vol.Optional(ATTR_DURATION, default=60): - vol.All(vol.Coerce(int), vol.Range(1, 254)), - }), - SERVICE_REMOVE: vol.Schema({ - vol.Required(ATTR_IEEE): cv.string, - }), -} - - # Zigbee definitions CENTICELSIUS = 'C-100' @@ -179,25 +166,7 @@ async def async_setup_entry(hass, config_entry): config_entry, component) ) - async def permit(service): - """Allow devices to join this network.""" - duration = service.data.get(ATTR_DURATION) - _LOGGER.info("Permitting joins for %ss", duration) - await application_controller.permit(duration) - - hass.services.async_register(DOMAIN, SERVICE_PERMIT, permit, - schema=SERVICE_SCHEMAS[SERVICE_PERMIT]) - - async def remove(service): - """Remove a node from the network.""" - from bellows.types import EmberEUI64, uint8_t - ieee = service.data.get(ATTR_IEEE) - ieee = EmberEUI64([uint8_t(p, base=16) for p in ieee.split(':')]) - _LOGGER.info("Removing node %s", ieee) - await application_controller.remove(ieee) - - hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, - schema=SERVICE_SCHEMAS[SERVICE_REMOVE]) + api.async_load_api(hass, application_controller, listener) def zha_shutdown(event): """Close radio.""" @@ -209,8 +178,7 @@ async def async_setup_entry(hass, config_entry): async def async_unload_entry(hass, config_entry): """Unload ZHA config entry.""" - hass.services.async_remove(DOMAIN, SERVICE_PERMIT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + api.async_unload_api(hass) dispatchers = hass.data[DATA_ZHA].get(DATA_ZHA_DISPATCHERS, []) for unsub_dispatcher in dispatchers: @@ -285,6 +253,28 @@ class ApplicationListener: if device.ieee in self._events: self._events.pop(device.ieee) + def get_device_entity(self, ieee_str): + """Return ZHADeviceEntity for given ieee.""" + ieee = convert_ieee(ieee_str) + if ieee in self._device_registry: + entities = self._device_registry[ieee] + entity = next( + ent for ent in entities if isinstance(ent, ZhaDeviceEntity)) + return entity + return None + + def get_entities_for_ieee(self, ieee_str): + """Return list of entities for given ieee.""" + ieee = convert_ieee(ieee_str) + if ieee in self._device_registry: + return self._device_registry[ieee] + return [] + + @property + def device_registry(self) -> str: + """Return devices.""" + return self._device_registry + async def async_device_initialized(self, device, join): """Handle device joined and basic information discovered (async).""" import zigpy.profiles diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py new file mode 100644 index 00000000000..a8aa7a2dbb9 --- /dev/null +++ b/homeassistant/components/zha/api.py @@ -0,0 +1,416 @@ +""" +Web socket API for Zigbee Home Automation devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" + +import logging +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.zha.entities import ZhaDeviceEntity +from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv +from .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) + +_LOGGER = logging.getLogger(__name__) + +TYPE = 'type' +CLIENT = 'client' +ID = 'id' +NAME = 'name' +RESPONSE = 'response' +DEVICE_INFO = 'device_info' + +ATTR_DURATION = 'duration' +ATTR_IEEE_ADDRESS = 'ieee_address' +ATTR_IEEE = 'ieee' + +SERVICE_PERMIT = 'permit' +SERVICE_REMOVE = 'remove' +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = 'set_zigbee_cluster_attribute' +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = 'issue_zigbee_cluster_command' +ZIGBEE_CLUSTER_SERVICE = 'zigbee_cluster_service' +IEEE_SERVICE = 'ieee_based_service' + +SERVICE_SCHEMAS = { + SERVICE_PERMIT: vol.Schema({ + vol.Optional(ATTR_DURATION, default=60): + vol.All(vol.Coerce(int), vol.Range(1, 254)), + }), + IEEE_SERVICE: vol.Schema({ + vol.Required(ATTR_IEEE_ADDRESS): cv.string, + }), + ZIGBEE_CLUSTER_SERVICE: vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=IN): cv.string + }), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=IN): cv.string, + vol.Required(ATTR_ATTRIBUTE): cv.positive_int, + vol.Required(ATTR_VALUE): cv.string, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + }), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Optional(ATTR_ARGS, default=''): cv.string, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + }), +} + +WS_RECONFIGURE_NODE = 'zha/nodes/reconfigure' +SCHEMA_WS_RECONFIGURE_NODE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_RECONFIGURE_NODE, + vol.Required(ATTR_IEEE): str +}) + +WS_ENTITIES_BY_IEEE = 'zha/entities' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_ENTITIES_BY_IEEE, +}) + +WS_ENTITY_CLUSTERS = 'zha/entities/clusters' +SCHEMA_WS_CLUSTERS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_ENTITY_CLUSTERS, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_IEEE): str +}) + +WS_ENTITY_CLUSTER_ATTRIBUTES = 'zha/entities/clusters/attributes' +SCHEMA_WS_CLUSTER_ATTRIBUTES = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_ENTITY_CLUSTER_ATTRIBUTES, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_IEEE): str, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str + }) + +WS_READ_CLUSTER_ATTRIBUTE = 'zha/entities/clusters/attributes/value' +SCHEMA_WS_READ_CLUSTER_ATTRIBUTE = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_READ_CLUSTER_ATTRIBUTE, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): object, + }) + +WS_ENTITY_CLUSTER_COMMANDS = 'zha/entities/clusters/commands' +SCHEMA_WS_CLUSTER_COMMANDS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_ENTITY_CLUSTER_COMMANDS, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_IEEE): str, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str +}) + + +@websocket_api.async_response +async def websocket_entity_cluster_attributes(hass, connection, msg): + """Return a list of cluster attributes.""" + entity_id = msg[ATTR_ENTITY_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + cluster_attributes = [] + if entity is not None: + res = await entity.get_cluster_attributes(cluster_id, cluster_type) + if res is not None: + for attr_id in res: + cluster_attributes.append( + { + ID: attr_id, + NAME: res[attr_id][0] + } + ) + _LOGGER.debug("Requested attributes for: %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(RESPONSE, cluster_attributes) + ) + + connection.send_message(websocket_api.result_message( + msg[ID], + cluster_attributes + )) + + +@websocket_api.async_response +async def websocket_entity_cluster_commands(hass, connection, msg): + """Return a list of cluster commands.""" + entity_id = msg[ATTR_ENTITY_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + cluster_commands = [] + if entity is not None: + res = await entity.get_cluster_commands(cluster_id, cluster_type) + if res is not None: + for cmd_id in res[CLIENT_COMMANDS]: + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + NAME: res[CLIENT_COMMANDS][cmd_id][0] + } + ) + for cmd_id in res[SERVER_COMMANDS]: + cluster_commands.append( + { + TYPE: SERVER, + ID: cmd_id, + NAME: res[SERVER_COMMANDS][cmd_id][0] + } + ) + _LOGGER.debug("Requested commands for: %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(RESPONSE, cluster_commands) + ) + + connection.send_message(websocket_api.result_message( + msg[ID], + cluster_commands + )) + + +@websocket_api.async_response +async def websocket_read_zigbee_cluster_attributes(hass, connection, msg): + """Read zigbee attribute for cluster on zha entity.""" + entity_id = msg[ATTR_ENTITY_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + attribute = msg[ATTR_ATTRIBUTE] + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + clusters = await entity.get_clusters() + cluster = clusters[cluster_type][cluster_id] + manufacturer = msg.get(ATTR_MANUFACTURER) or None + success = failure = None + if entity is not None: + success, failure = await cluster.read_attributes( + [attribute], + allow_cache=False, + only_cache=False, + manufacturer=manufacturer + ) + _LOGGER.debug("Read attribute for: %s %s %s %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(ATTR_ATTRIBUTE, attribute), + "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), + "{}: [{}]".format(RESPONSE, str(success.get(attribute))), + "{}: [{}]".format('failure', failure) + ) + connection.send_message(websocket_api.result_message( + msg[ID], + str(success.get(attribute)) + )) + + +def async_load_api(hass, application_controller, listener): + """Set up the web socket API.""" + async def permit(service): + """Allow devices to join this network.""" + duration = service.data.get(ATTR_DURATION) + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(duration) + + hass.services.async_register(DOMAIN, SERVICE_PERMIT, permit, + schema=SERVICE_SCHEMAS[SERVICE_PERMIT]) + + async def remove(service): + """Remove a node from the network.""" + from bellows.types import EmberEUI64, uint8_t + ieee = service.data.get(ATTR_IEEE_ADDRESS) + ieee = EmberEUI64([uint8_t(p, base=16) for p in ieee.split(':')]) + _LOGGER.info("Removing node %s", ieee) + await application_controller.remove(ieee) + + hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, + schema=SERVICE_SCHEMAS[IEEE_SERVICE]) + + async def set_zigbee_cluster_attributes(service): + """Set zigbee attribute for cluster on zha entity.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + cluster_id = service.data.get(ATTR_CLUSTER_ID) + cluster_type = service.data.get(ATTR_CLUSTER_TYPE) + attribute = service.data.get(ATTR_ATTRIBUTE) + value = service.data.get(ATTR_VALUE) + manufacturer = service.data.get(ATTR_MANUFACTURER) or None + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + response = None + if entity is not None: + response = await entity.write_zigbe_attribute( + cluster_id, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer + ) + _LOGGER.debug("Set attribute for: %s %s %s %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(ATTR_ATTRIBUTE, attribute), + "{}: [{}]".format(ATTR_VALUE, value), + "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), + "{}: [{}]".format(RESPONSE, response) + ) + + hass.services.async_register(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[ + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE + ]) + + async def issue_zigbee_cluster_command(service): + """Issue command on zigbee cluster on zha entity.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + cluster_id = service.data.get(ATTR_CLUSTER_ID) + cluster_type = service.data.get(ATTR_CLUSTER_TYPE) + command = service.data.get(ATTR_COMMAND) + command_type = service.data.get(ATTR_COMMAND_TYPE) + args = service.data.get(ATTR_ARGS) + manufacturer = service.data.get(ATTR_MANUFACTURER) or None + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + response = None + if entity is not None: + response = await entity.issue_cluster_command( + cluster_id, + command, + command_type, + args, + cluster_type=cluster_type, + manufacturer=manufacturer + ) + _LOGGER.debug("Issue command for: %s %s %s %s %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(ATTR_COMMAND, command), + "{}: [{}]".format(ATTR_COMMAND_TYPE, command_type), + "{}: [{}]".format(ATTR_ARGS, args), + "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), + "{}: [{}]".format(RESPONSE, response) + ) + + hass.services.async_register(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[ + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND + ]) + + @websocket_api.async_response + async def websocket_reconfigure_node(hass, connection, msg): + """Reconfigure a ZHA nodes entities by its ieee address.""" + ieee = msg[ATTR_IEEE] + entities = listener.get_entities_for_ieee(ieee) + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + for entity in entities: + if hasattr(entity, 'async_configure'): + hass.async_create_task(entity.async_configure()) + + hass.components.websocket_api.async_register_command( + WS_RECONFIGURE_NODE, websocket_reconfigure_node, + SCHEMA_WS_RECONFIGURE_NODE + ) + + @websocket_api.async_response + async def websocket_entities_by_ieee(hass, connection, msg): + """Return a dict of all zha entities grouped by ieee.""" + entities_by_ieee = {} + for ieee, entities in listener.device_registry.items(): + ieee_string = str(ieee) + entities_by_ieee[ieee_string] = [] + for entity in entities: + if not isinstance(entity, ZhaDeviceEntity): + entities_by_ieee[ieee_string].append({ + ATTR_ENTITY_ID: entity.entity_id, + DEVICE_INFO: entity.device_info + }) + connection.send_message(websocket_api.result_message( + msg[ID], + entities_by_ieee + )) + + hass.components.websocket_api.async_register_command( + WS_ENTITIES_BY_IEEE, websocket_entities_by_ieee, + SCHEMA_WS_LIST + ) + + @websocket_api.async_response + async def websocket_entity_clusters(hass, connection, msg): + """Return a list of entity clusters.""" + entity_id = msg[ATTR_ENTITY_ID] + entities = listener.get_entities_for_ieee(msg[ATTR_IEEE]) + entity = next( + ent for ent in entities if ent.entity_id == entity_id) + entity_clusters = await entity.get_clusters() + clusters = [] + + for cluster_id, cluster in entity_clusters[IN].items(): + clusters.append({ + TYPE: IN, + ID: cluster_id, + NAME: cluster.__class__.__name__ + }) + for cluster_id, cluster in entity_clusters[OUT].items(): + clusters.append({ + TYPE: OUT, + ID: cluster_id, + NAME: cluster.__class__.__name__ + }) + + connection.send_message(websocket_api.result_message( + msg[ID], + clusters + )) + + hass.components.websocket_api.async_register_command( + WS_ENTITY_CLUSTERS, websocket_entity_clusters, + SCHEMA_WS_CLUSTERS + ) + + hass.components.websocket_api.async_register_command( + WS_ENTITY_CLUSTER_ATTRIBUTES, websocket_entity_cluster_attributes, + SCHEMA_WS_CLUSTER_ATTRIBUTES + ) + + hass.components.websocket_api.async_register_command( + WS_ENTITY_CLUSTER_COMMANDS, websocket_entity_cluster_commands, + SCHEMA_WS_CLUSTER_COMMANDS + ) + + hass.components.websocket_api.async_register_command( + WS_READ_CLUSTER_ATTRIBUTE, websocket_read_zigbee_cluster_attributes, + SCHEMA_WS_READ_CLUSTER_ATTRIBUTE + ) + + +def async_unload_api(hass): + """Unload the ZHA API.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 5b650c95cc4..29295e35060 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -36,6 +36,21 @@ DEFAULT_RADIO_TYPE = 'ezsp' DEFAULT_BAUDRATE = 57600 DEFAULT_DATABASE_NAME = 'zigbee.db' +ATTR_CLUSTER_ID = 'cluster_id' +ATTR_CLUSTER_TYPE = 'cluster_type' +ATTR_ATTRIBUTE = 'attribute' +ATTR_VALUE = 'value' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_COMMAND = 'command' +ATTR_COMMAND_TYPE = 'command_type' +ATTR_ARGS = 'args' + +IN = 'in' +OUT = 'out' +CLIENT_COMMANDS = 'client_commands' +SERVER_COMMANDS = 'server_commands' +SERVER = 'server' + class RadioType(enum.Enum): """Possible options for radio type.""" diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entities/entity.py index dadd79e82a5..2b8ee2f1748 100644 --- a/homeassistant/components/zha/entities/entity.py +++ b/homeassistant/components/zha/entities/entity.py @@ -9,8 +9,11 @@ import logging from random import uniform from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_BRIDGE_ID, DOMAIN) + DATA_ZHA, DATA_ZHA_BRIDGE_ID, DOMAIN, ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, + ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, SERVER, ATTR_COMMAND_TYPE, + ATTR_ARGS, IN, OUT, CLIENT_COMMANDS, SERVER_COMMANDS) from homeassistant.components.zha.helpers import bind_configure_reporting +from homeassistant.const import ATTR_ENTITY_ID, CONF_FRIENDLY_NAME from homeassistant.core import callback from homeassistant.helpers import entity from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE @@ -18,6 +21,8 @@ from homeassistant.util import slugify _LOGGER = logging.getLogger(__name__) +ENTITY_SUFFIX = 'entity_suffix' + class ZhaEntity(entity.Entity): """A base class for ZHA entities.""" @@ -38,9 +43,9 @@ class ZhaEntity(entity.Entity): slugify(model), ieeetail, endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), + kwargs.get(ENTITY_SUFFIX, ''), ) - self._device_state_attributes['friendly_name'] = "{} {}".format( + self._device_state_attributes[CONF_FRIENDLY_NAME] = "{} {}".format( manufacturer, model, ) @@ -49,7 +54,7 @@ class ZhaEntity(entity.Entity): self._domain, ieeetail, endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), + kwargs.get(ENTITY_SUFFIX, ''), ) self._endpoint = endpoint @@ -69,6 +74,100 @@ class ZhaEntity(entity.Entity): self.manufacturer_code = None application_listener.register_entity(ieee, self) + async def get_clusters(self): + """Get zigbee clusters from this entity.""" + return { + IN: self._in_clusters, + OUT: self._out_clusters + } + + async def _get_cluster(self, cluster_id, cluster_type=IN): + """Get zigbee cluster from this entity.""" + if cluster_type == IN: + cluster = self._in_clusters[cluster_id] + else: + cluster = self._out_clusters[cluster_id] + if cluster is None: + _LOGGER.warning('in_cluster with id: %s not found on entity: %s', + cluster_id, self.entity_id) + return cluster + + async def get_cluster_attributes(self, cluster_id, cluster_type=IN): + """Get zigbee attributes for specified cluster.""" + cluster = await self._get_cluster(cluster_id, cluster_type) + if cluster is None: + return + return cluster.attributes + + async def write_zigbe_attribute(self, cluster_id, attribute, value, + cluster_type=IN, manufacturer=None): + """Write a value to a zigbee attribute for a cluster in this entity.""" + cluster = await self._get_cluster(cluster_id, cluster_type) + if cluster is None: + return + + from zigpy.exceptions import DeliveryError + try: + response = await cluster.write_attributes( + {attribute: value}, + manufacturer=manufacturer + ) + _LOGGER.debug( + 'set: %s for attr: %s to cluster: %s for entity: %s - res: %s', + value, + attribute, + cluster_id, + self.entity_id, + response + ) + return response + except DeliveryError as exc: + _LOGGER.debug( + 'failed to set attribute: %s %s %s %s %s', + '{}: {}'.format(ATTR_VALUE, value), + '{}: {}'.format(ATTR_ATTRIBUTE, attribute), + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_id), + '{}: {}'.format(ATTR_ENTITY_ID, self.entity_id), + exc + ) + + async def get_cluster_commands(self, cluster_id, cluster_type=IN): + """Get zigbee commands for specified cluster.""" + cluster = await self._get_cluster(cluster_id, cluster_type) + if cluster is None: + return + return { + CLIENT_COMMANDS: cluster.client_commands, + SERVER_COMMANDS: cluster.server_commands, + } + + async def issue_cluster_command(self, cluster_id, command, command_type, + args, cluster_type=IN, + manufacturer=None): + """Issue a command against specified zigbee cluster on this entity.""" + cluster = await self._get_cluster(cluster_id, cluster_type) + if cluster is None: + return + response = None + if command_type == SERVER: + response = await cluster.command(command, *args, + manufacturer=manufacturer, + expect_reply=True) + else: + response = await cluster.client_command(command, *args) + + _LOGGER.debug( + 'Issued cluster command: %s %s %s %s %s %s %s', + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_id), + '{}: {}'.format(ATTR_COMMAND, command), + '{}: {}'.format(ATTR_COMMAND_TYPE, command_type), + '{}: {}'.format(ATTR_ARGS, args), + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_type), + '{}: {}'.format(ATTR_MANUFACTURER, manufacturer), + '{}: {}'.format(ATTR_ENTITY_ID, self.entity_id) + ) + return response + async def async_added_to_hass(self): """Handle entity addition to hass. @@ -201,9 +300,12 @@ class ZhaEntity(entity.Entity): return { 'connections': {(CONNECTION_ZIGBEE, ieee)}, 'identifiers': {(DOMAIN, ieee)}, - 'manufacturer': self._endpoint.manufacturer, + ATTR_MANUFACTURER: self._endpoint.manufacturer, 'model': self._endpoint.model, - 'name': self._device_state_attributes.get('friendly_name', ieee), + 'name': self._device_state_attributes.get( + CONF_FRIENDLY_NAME, + ieee + ), 'via_hub': (DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), } diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 3212849f721..b6c09dd1fce 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -14,7 +14,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): +async def safe_read(cluster, attributes, allow_cache=True, only_cache=False, + manufacturer=None): """Swallow all exceptions from network read. If we throw during initialization, setup fails. Rather have an entity that @@ -25,7 +26,8 @@ async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): result, _ = await cluster.read_attributes( attributes, allow_cache=allow_cache, - only_cache=only_cache + only_cache=only_cache, + manufacturer=manufacturer ) return result except Exception: # pylint: disable=broad-except @@ -124,3 +126,9 @@ async def check_zigpy_connection(usb_path, radio_type, database_path): except Exception: # pylint: disable=broad-except return False return True + + +def convert_ieee(ieee_str): + """Convert given ieee string to EUI64.""" + from zigpy.types import EUI64, uint8_t + return EUI64([uint8_t(p, base=16) for p in ieee_str.split(':')]) diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml index 4b1122b8167..c328d69a6c3 100644 --- a/homeassistant/components/zha/services.yaml +++ b/homeassistant/components/zha/services.yaml @@ -13,3 +13,63 @@ remove: ieee_address: description: IEEE address of the node to remove example: "00:0d:6f:00:05:7d:2d:34" + +reconfigure_device: + description: >- + Reconfigure ZHA device (heal device). Use this if you are having issues + with the device. If the device in question is a battery powered device + please ensure it is awake and accepting commands when you use this + service. + fields: + ieee_address: + description: IEEE address of the device to reconfigure + example: "00:0d:6f:00:05:7d:2d:34" + +set_zigbee_cluster_attribute: + description: >- + Set attribute value for the specified cluster on the specified entity. + fields: + entity_id: + description: Entity id + example: "binary_sensor.centralite_3130_00e8fb4e_1" + cluster_id: + description: ZCL cluster to retrieve attributes for + example: 6 + cluster_type: + description: type of the cluster (in or out) + example: "out" + attribute: + description: id of the attribute to set + example: 0 + value: + description: value to write to the attribute + example: 0x0001 + manufacturer: + description: manufacturer code + example: 0x00FC + +issue_zigbee_cluster_command: + description: >- + Issue command on the specified cluster on the specified entity. + fields: + entity_id: + description: Entity id + example: "binary_sensor.centralite_3130_00e8fb4e_1" + cluster_id: + description: ZCL cluster to retrieve attributes for + example: 6 + cluster_type: + description: type of the cluster (in or out) + example: "out" + command: + description: id of the command to execute + example: 0 + command_type: + description: type of the command to execute (client or server) + example: "server" + args: + description: args to pass to the command + example: {} + manufacturer: + description: manufacturer code + example: 0x00FC