From ed75549123bd8c409cd8678c41d5ab20ee629c12 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 30 Jan 2019 16:44:22 -0500 Subject: [PATCH] ZHA component rewrite part 4 - add device module (#20469) * add device module * spelling * review comments * filter out endpoint id 0 (ZDO) * review comments * change name * remove return --- .coveragerc | 1 + homeassistant/components/zha/core/const.py | 9 + homeassistant/components/zha/core/device.py | 316 ++++++++++++++++++ .../components/zha/core/listeners.py | 35 ++ 4 files changed, 361 insertions(+) create mode 100644 homeassistant/components/zha/core/device.py diff --git a/.coveragerc b/.coveragerc index 75b8b3b4ff8..f1ff7715580 100644 --- a/.coveragerc +++ b/.coveragerc @@ -460,6 +460,7 @@ omit = homeassistant/components/zha/device_entity.py homeassistant/components/zha/core/helpers.py homeassistant/components/zha/core/const.py + homeassistant/components/zha/core/device.py homeassistant/components/zha/core/listeners.py homeassistant/components/zha/core/gateway.py homeassistant/components/*/zha.py diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 47c3982c5d6..3069ebf02db 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -44,12 +44,21 @@ ATTR_MANUFACTURER = 'manufacturer' ATTR_COMMAND = 'command' ATTR_COMMAND_TYPE = 'command_type' ATTR_ARGS = 'args' +ATTR_ENDPOINT_ID = 'endpoint_id' IN = 'in' OUT = 'out' CLIENT_COMMANDS = 'client_commands' SERVER_COMMANDS = 'server_commands' SERVER = 'server' +IEEE = 'ieee' +MODEL = 'model' +NAME = 'name' + +LISTENER_BATTERY = 'battery' + +SIGNAL_ATTR_UPDATED = 'attribute_updated' +SIGNAL_AVAILABLE = 'available' class RadioType(enum.Enum): diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py new file mode 100644 index 00000000000..c7dabced24b --- /dev/null +++ b/homeassistant/components/zha/core/device.py @@ -0,0 +1,316 @@ +""" +Device for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import asyncio +import logging + +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send +) +from .const import ( + ATTR_MANUFACTURER, LISTENER_BATTERY, SIGNAL_AVAILABLE, IN, OUT, + ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_COMMAND, SERVER, + ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS, + ATTR_ENDPOINT_ID, IEEE, MODEL, NAME +) +from .listeners import EventRelayListener + +_LOGGER = logging.getLogger(__name__) + + +class ZHADevice: + """ZHA Zigbee device object.""" + + def __init__(self, hass, zigpy_device, zha_gateway): + """Initialize the gateway.""" + self.hass = hass + self._zigpy_device = zigpy_device + # Get first non ZDO endpoint id to use to get manufacturer and model + endpoint_ids = zigpy_device.endpoints.keys() + ept_id = next(ept_id for ept_id in endpoint_ids if ept_id != 0) + self._manufacturer = zigpy_device.endpoints[ept_id].manufacturer + self._model = zigpy_device.endpoints[ept_id].model + self._zha_gateway = zha_gateway + self._cluster_listeners = {} + self._relay_listeners = [] + self._all_listeners = [] + self._name = "{} {}".format( + self.manufacturer, + self.model + ) + self._available = False + self._available_signal = "{}_{}_{}".format( + self.name, self.ieee, SIGNAL_AVAILABLE) + self._unsub = async_dispatcher_connect( + self.hass, + self._available_signal, + self.async_initialize + ) + + @property + def name(self): + """Return device name.""" + return self._name + + @property + def ieee(self): + """Return ieee address for device.""" + return self._zigpy_device.ieee + + @property + def manufacturer(self): + """Return ieee address for device.""" + return self._manufacturer + + @property + def model(self): + """Return ieee address for device.""" + return self._model + + @property + def nwk(self): + """Return nwk for device.""" + return self._zigpy_device.nwk + + @property + def lqi(self): + """Return lqi for device.""" + return self._zigpy_device.lqi + + @property + def rssi(self): + """Return rssi for device.""" + return self._zigpy_device.rssi + + @property + def last_seen(self): + """Return last_seen for device.""" + return self._zigpy_device.last_seen + + @property + def manufacturer_code(self): + """Return manufacturer code for device.""" + # will eventually get this directly from Zigpy + return None + + @property + def gateway(self): + """Return the gateway for this device.""" + return self._zha_gateway + + @property + def cluster_listeners(self): + """Return cluster listeners for device.""" + return self._cluster_listeners.values() + + @property + def all_listeners(self): + """Return cluster listeners and relay listeners for device.""" + return self._all_listeners + + @property + def cluster_listener_keys(self): + """Return cluster listeners for device.""" + return self._cluster_listeners.keys() + + @property + def available_signal(self): + """Signal to use to subscribe to device availability changes.""" + return self._available_signal + + @property + def available(self): + """Return True if sensor is available.""" + return self._available + + def update_available(self, available): + """Set sensor availability.""" + if self._available != available and available: + # Update the state the first time the device comes online + async_dispatcher_send( + self.hass, + self._available_signal, + False + ) + async_dispatcher_send( + self.hass, + "{}_{}".format(self._available_signal, 'entity'), + True + ) + self._available = available + + @property + def device_info(self): + """Return a device description for device.""" + ieee = str(self.ieee) + return { + IEEE: ieee, + ATTR_MANUFACTURER: self.manufacturer, + MODEL: self.model, + NAME: self.name or ieee + } + + def add_cluster_listener(self, cluster_listener): + """Add cluster listener to device.""" + # only keep 1 power listener + if cluster_listener.name is LISTENER_BATTERY and \ + LISTENER_BATTERY in self._cluster_listeners: + return + self._all_listeners.append(cluster_listener) + if isinstance(cluster_listener, EventRelayListener): + self._relay_listeners.append(cluster_listener) + else: + self._cluster_listeners[cluster_listener.name] = cluster_listener + + def get_cluster_listener(self, name): + """Get cluster listener by name.""" + return self._cluster_listeners.get(name, None) + + async def async_configure(self): + """Configure the device.""" + _LOGGER.debug('%s: started configuration', self.name) + await self._execute_listener_tasks('async_configure') + _LOGGER.debug('%s: completed configuration', self.name) + + async def async_initialize(self, from_cache): + """Initialize listeners.""" + _LOGGER.debug('%s: started initialization', self.name) + await self._execute_listener_tasks('async_initialize', from_cache) + _LOGGER.debug('%s: completed initialization', self.name) + + async def async_accept_messages(self): + """Start accepting messages from the zigbee network.""" + await self._execute_listener_tasks('accept_messages') + + async def _execute_listener_tasks(self, task_name, *args): + """Gather and execute a set of listener tasks.""" + listener_tasks = [] + for listener in self.all_listeners: + listener_tasks.append( + self._async_create_task(listener, task_name, *args)) + await asyncio.gather(*listener_tasks) + + async def _async_create_task(self, listener, func_name, *args): + """Configure a single listener on this device.""" + try: + await getattr(listener, func_name)(*args) + _LOGGER.debug('%s: listener: %s %s stage succeeded', + self.name, + "{}-{}".format( + listener.name, listener.unique_id), + func_name) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.warning( + '%s listener: %s %s stage failed ex: %s', + self.name, + "{}-{}".format(listener.name, listener.unique_id), + func_name, + ex + ) + + async def async_unsub_dispatcher(self): + """Unsubscribe the dispatcher.""" + if self._unsub: + self._unsub() + + async def get_clusters(self): + """Get all clusters for this device.""" + 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 + } + + async def get_cluster(self, endpooint_id, cluster_id, cluster_type=IN): + """Get zigbee cluster from this entity.""" + clusters = await self.get_clusters() + return clusters[endpooint_id][cluster_type][cluster_id] + + async def get_cluster_attributes(self, endpooint_id, cluster_id, + cluster_type=IN): + """Get zigbee attributes for specified cluster.""" + cluster = await self.get_cluster(endpooint_id, cluster_id, + cluster_type) + if cluster is None: + return None + return cluster.attributes + + async def get_cluster_commands(self, endpooint_id, cluster_id, + cluster_type=IN): + """Get zigbee commands for specified cluster.""" + cluster = await self.get_cluster(endpooint_id, cluster_id, + cluster_type) + if cluster is None: + return None + return { + CLIENT_COMMANDS: cluster.client_commands, + SERVER_COMMANDS: cluster.server_commands, + } + + async def write_zigbee_attribute(self, endpooint_id, 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( + endpooint_id, cluster_id, cluster_type) + if cluster is None: + return None + + 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, + endpooint_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_ENDPOINT_ID, endpooint_id), + exc + ) + return None + + async def issue_cluster_command(self, endpooint_id, 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( + endpooint_id, cluster_id, cluster_type) + if cluster is None: + return None + 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_ENDPOINT_ID, endpooint_id) + ) + return response diff --git a/homeassistant/components/zha/core/listeners.py b/homeassistant/components/zha/core/listeners.py index d4fce491563..4f60ea83d6f 100644 --- a/homeassistant/components/zha/core/listeners.py +++ b/homeassistant/components/zha/core/listeners.py @@ -7,6 +7,9 @@ https://home-assistant.io/components/zha/ import logging +from homeassistant.core import callback +from .const import SIGNAL_ATTR_UPDATED + _LOGGER = logging.getLogger(__name__) @@ -108,3 +111,35 @@ class LevelListener(ClusterListener): """Handle attribute updates on this cluster.""" if attrid == self.CURRENT_LEVEL: self._entity.set_level(value) + + +class EventRelayListener(ClusterListener): + """Event relay that can be attached to zigbee clusters.""" + + name = 'event_relay' + + @callback + def attribute_updated(self, attrid, value): + """Handle an attribute updated on this cluster.""" + self.zha_send_event( + self._cluster, + SIGNAL_ATTR_UPDATED, + { + 'attribute_id': attrid, + 'attribute_name': self._cluster.attributes.get( + attrid, + ['Unknown'])[0], + 'value': value + } + ) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + if self._cluster.server_commands is not None and \ + self._cluster.server_commands.get(command_id) is not None: + self.zha_send_event( + self._cluster, + self._cluster.server_commands.get(command_id)[0], + args + )