diff --git a/.coveragerc b/.coveragerc index 488ea50298a..1f467c93c2d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -658,7 +658,6 @@ omit = homeassistant/components/zeroconf/* homeassistant/components/zha/__init__.py homeassistant/components/zha/api.py - homeassistant/components/zha/binary_sensor.py homeassistant/components/zha/const.py homeassistant/components/zha/core/const.py homeassistant/components/zha/core/device.py @@ -667,11 +666,8 @@ omit = homeassistant/components/zha/core/listeners.py homeassistant/components/zha/device_entity.py homeassistant/components/zha/entity.py - homeassistant/components/zha/event.py - homeassistant/components/zha/fan.py homeassistant/components/zha/light.py homeassistant/components/zha/sensor.py - homeassistant/components/zha/switch.py homeassistant/components/zigbee/* homeassistant/components/zoneminder/* homeassistant/components/zwave/util.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 4f9b5b04362..2e693907769 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -4,6 +4,7 @@ Support for Zigbee Home Automation devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +import asyncio import logging import os import types @@ -17,14 +18,15 @@ from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE # Loading the config flow file will register the flow from . import config_flow # noqa # pylint: disable=unused-import from . import api -from .core.gateway import ZHAGateway -from .const import ( +from .core import ZHAGateway +from .core.const import ( COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG, CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_BRIDGE_ID, DATA_ZHA_CONFIG, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, - DEFAULT_RADIO_TYPE, DOMAIN, RadioType, DATA_ZHA_CORE_EVENTS, - ENABLE_QUIRKS) + DEFAULT_RADIO_TYPE, DOMAIN, RadioType, DATA_ZHA_CORE_EVENTS, ENABLE_QUIRKS) +from .core.gateway import establish_device_mappings +from .core.listeners import populate_listener_registry REQUIREMENTS = [ 'bellows==0.7.0', @@ -87,9 +89,16 @@ async def async_setup_entry(hass, config_entry): Will automatically load components to support devices found on the network. """ + establish_device_mappings() + populate_listener_registry() + + for component in COMPONENTS: + hass.data[DATA_ZHA][component] = ( + hass.data[DATA_ZHA].get(component, {}) + ) + hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {}) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = [] - config = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) if config.get(ENABLE_QUIRKS, True): @@ -137,14 +146,32 @@ async def async_setup_entry(hass, config_entry): ClusterPersistingListener ) - application_controller = ControllerApplication(radio, database) zha_gateway = ZHAGateway(hass, config) + hass.bus.async_listen_once( + ha_const.EVENT_HOMEASSISTANT_START, zha_gateway.accept_zigbee_messages) + + # Patch handle_message until zigpy can provide an event here + def handle_message(sender, is_reply, profile, cluster, + src_ep, dst_ep, tsn, command_id, args): + """Handle message from a device.""" + if sender.last_seen is None and not sender.initializing: + if sender.ieee in zha_gateway.devices: + device = zha_gateway.devices[sender.ieee] + device.update_available(True) + return sender.handle_message( + is_reply, profile, cluster, src_ep, dst_ep, tsn, command_id, args) + + application_controller = ControllerApplication(radio, database) + application_controller.handle_message = handle_message application_controller.add_listener(zha_gateway) await application_controller.startup(auto_form=True) + hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(application_controller.ieee) + + init_tasks = [] for device in application_controller.devices.values(): - hass.async_create_task( - zha_gateway.async_device_initialized(device, False)) + init_tasks.append(zha_gateway.async_device_initialized(device, False)) + await asyncio.gather(*init_tasks) device_registry = await \ hass.helpers.device_registry.async_get_registry() @@ -157,8 +184,6 @@ async def async_setup_entry(hass, config_entry): model=radio_description, ) - hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(application_controller.ieee) - for component in COMPONENTS: hass.async_create_task( hass.config_entries.async_forward_entry_setup( diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 0312a40967f..c412cb9fef0 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -11,8 +11,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import ATTR_ENTITY_ID import homeassistant.helpers.config_validation as cv -from .device_entity import ZhaDeviceEntity -from .const import ( +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) @@ -118,115 +117,7 @@ SCHEMA_WS_CLUSTER_COMMANDS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) -@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): +def async_load_api(hass, application_controller, zha_gateway): """Set up the web socket API.""" async def permit(service): """Allow devices to join this network.""" @@ -256,11 +147,12 @@ def async_load_api(hass, application_controller, listener): 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) + entity_ref = zha_gateway.get_entity_reference(entity_id) response = None - if entity is not None: - response = await entity.write_zigbe_attribute( + if entity_ref is not None: + response = await entity_ref.zha_device.write_zigbee_attribute( + list(entity_ref.cluster_listeners.values())[ + 0].cluster.endpoint.endpoint_id, cluster_id, attribute, value, @@ -292,11 +184,13 @@ def async_load_api(hass, application_controller, listener): 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) + entity_ref = zha_gateway.get_entity_reference(entity_id) + zha_device = entity_ref.zha_device response = None - if entity is not None: - response = await entity.issue_cluster_command( + if entity_ref is not None: + response = await zha_device.issue_cluster_command( + list(entity_ref.cluster_listeners.values())[ + 0].cluster.endpoint.endpoint_id, cluster_id, command, command_type, @@ -325,11 +219,9 @@ def async_load_api(hass, application_controller, listener): 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) + device = zha_gateway.get_device(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.async_create_task(device.async_configure()) hass.components.websocket_api.async_register_command( WS_RECONFIGURE_NODE, websocket_reconfigure_node, @@ -340,15 +232,15 @@ def async_load_api(hass, application_controller, listener): 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(): + for ieee, entities in zha_gateway.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 - }) + entities_by_ieee[ieee_string].append({ + ATTR_ENTITY_ID: entity.reference_id, + DEVICE_INFO: entity.device_info + }) + connection.send_message(websocket_api.result_message( msg[ID], entities_by_ieee @@ -363,24 +255,25 @@ def async_load_api(hass, application_controller, listener): 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() + entity_ref = zha_gateway.get_entity_reference(entity_id) 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__ - }) + if entity_ref is not None: + for listener in entity_ref.cluster_listeners.values(): + cluster = listener.cluster + in_clusters = cluster.endpoint.in_clusters.values() + out_clusters = cluster.endpoint.out_clusters.values() + if cluster in in_clusters: + clusters.append({ + TYPE: IN, + ID: cluster.cluster_id, + NAME: cluster.__class__.__name__ + }) + elif cluster in out_clusters: + clusters.append({ + TYPE: OUT, + ID: cluster.cluster_id, + NAME: cluster.__class__.__name__ + }) connection.send_message(websocket_api.result_message( msg[ID], @@ -392,16 +285,141 @@ def async_load_api(hass, application_controller, listener): SCHEMA_WS_CLUSTERS ) + @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] + ieee = msg[ATTR_IEEE] + cluster_attributes = [] + entity_ref = zha_gateway.get_entity_reference(entity_id) + device = zha_gateway.get_device(ieee) + attributes = None + if entity_ref is not None: + attributes = await device.get_cluster_attributes( + list(entity_ref.cluster_listeners.values())[ + 0].cluster.endpoint.endpoint_id, + cluster_id, + cluster_type) + if attributes is not None: + for attr_id in attributes: + cluster_attributes.append( + { + ID: attr_id, + NAME: attributes[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 + )) + hass.components.websocket_api.async_register_command( WS_ENTITY_CLUSTER_ATTRIBUTES, websocket_entity_cluster_attributes, SCHEMA_WS_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] + ieee = msg[ATTR_IEEE] + entity_ref = zha_gateway.get_entity_reference(entity_id) + device = zha_gateway.get_device(ieee) + cluster_commands = [] + commands = None + if entity_ref is not None: + commands = await device.get_cluster_commands( + list(entity_ref.cluster_listeners.values())[ + 0].cluster.endpoint.endpoint_id, + cluster_id, + cluster_type) + + if commands is not None: + for cmd_id in commands[CLIENT_COMMANDS]: + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + NAME: commands[CLIENT_COMMANDS][cmd_id][0] + } + ) + for cmd_id in commands[SERVER_COMMANDS]: + cluster_commands.append( + { + TYPE: SERVER, + ID: cmd_id, + NAME: commands[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 + )) + hass.components.websocket_api.async_register_command( WS_ENTITY_CLUSTER_COMMANDS, websocket_entity_cluster_commands, SCHEMA_WS_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] + entity_ref = zha_gateway.get_entity_reference(entity_id) + manufacturer = msg.get(ATTR_MANUFACTURER) or None + success = failure = None + clusters = [] + if cluster_type == IN: + clusters = \ + list(entity_ref.cluster_listeners.values())[ + 0].cluster.endpoint.in_clusters + else: + clusters = \ + list(entity_ref.cluster_listeners.values())[ + 0].cluster.endpoint.out_clusters + cluster = clusters[cluster_id] + if entity_ref 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)) + )) + hass.components.websocket_api.async_register_command( WS_READ_CLUSTER_ATTRIBUTE, websocket_read_zigbee_cluster_attributes, SCHEMA_WS_READ_CLUSTER_ATTRIBUTE diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index d0f23ff3dd2..1f85373eecc 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -7,16 +7,13 @@ at https://home-assistant.io/components/binary_sensor.zha/ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice -from homeassistant.const import STATE_ON from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.restore_state import RestoreEntity -from .core import helpers from .core.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, LISTENER_ON_OFF, + LISTENER_LEVEL, LISTENER_ZONE, SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, + SIGNAL_SET_LEVEL, LISTENER_ATTRIBUTE, UNKNOWN, OPENING, ZONE, OCCUPANCY, + ATTR_LEVEL, SENSOR_TYPE) from .entity import ZhaEntity -from .core.listeners import ( - OnOffListener, LevelListener -) _LOGGER = logging.getLogger(__name__) @@ -31,7 +28,20 @@ CLASS_MAPPING = { 0x002b: 'gas', 0x002d: 'vibration', } -DEVICE_CLASS_OCCUPANCY = 'occupancy' + + +async def get_ias_device_class(listener): + """Get the HA device class from the listener.""" + zone_type = await listener.get_attribute_value('zone_type') + return CLASS_MAPPING.get(zone_type) + + +DEVICE_CLASS_REGISTRY = { + UNKNOWN: None, + OPENING: OPENING, + ZONE: get_ias_device_class, + OCCUPANCY: OCCUPANCY, +} async def async_setup_platform(hass, config, async_add_entities, @@ -60,249 +70,60 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entities(hass, config_entry, async_add_entities, discovery_infos): """Set up the ZHA binary sensors.""" - from zigpy.zcl.clusters.general import OnOff - from zigpy.zcl.clusters.measurement import OccupancySensing - from zigpy.zcl.clusters.security import IasZone - entities = [] for discovery_info in discovery_infos: - if IasZone.cluster_id in discovery_info['in_clusters']: - entities.append(await _async_setup_iaszone(discovery_info)) - elif OccupancySensing.cluster_id in discovery_info['in_clusters']: - entities.append( - BinarySensor(DEVICE_CLASS_OCCUPANCY, **discovery_info)) - elif OnOff.cluster_id in discovery_info['out_clusters']: - entities.append(Remote(**discovery_info)) + entities.append(BinarySensor(**discovery_info)) async_add_entities(entities, update_before_add=True) -async def _async_setup_iaszone(discovery_info): - device_class = None - from zigpy.zcl.clusters.security import IasZone - cluster = discovery_info['in_clusters'][IasZone.cluster_id] - - try: - zone_type = await cluster['zone_type'] - device_class = CLASS_MAPPING.get(zone_type, None) - except Exception: # pylint: disable=broad-except - # If we fail to read from the device, use a non-specific class - pass - - return IasZoneSensor(device_class, **discovery_info) - - -class IasZoneSensor(RestoreEntity, ZhaEntity, BinarySensorDevice): - """The IasZoneSensor Binary Sensor.""" - - _domain = DOMAIN - - def __init__(self, device_class, **kwargs): - """Initialize the ZHA binary sensor.""" - super().__init__(**kwargs) - self._device_class = device_class - from zigpy.zcl.clusters.security import IasZone - self._ias_zone_cluster = self._in_clusters[IasZone.cluster_id] - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - if self._state is None: - return False - return bool(self._state) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class - - def cluster_command(self, tsn, command_id, args): - """Handle commands received to this cluster.""" - if command_id == 0: - self._state = args[0] & 3 - _LOGGER.debug("Updated alarm state: %s", self._state) - self.async_schedule_update_ha_state() - elif command_id == 1: - _LOGGER.debug("Enroll requested") - res = self._ias_zone_cluster.enroll_response(0, 0) - self.hass.async_add_job(res) - - async def async_added_to_hass(self): - """Run when about to be added to hass.""" - await super().async_added_to_hass() - old_state = await self.async_get_last_state() - if self._state is not None or old_state is None: - return - - _LOGGER.debug("%s restoring old state: %s", self.entity_id, old_state) - if old_state.state == STATE_ON: - self._state = 3 - else: - self._state = 0 - - async def async_configure(self): - """Configure IAS device.""" - await self._ias_zone_cluster.bind() - ieee = self._ias_zone_cluster.endpoint.device.application.ieee - await self._ias_zone_cluster.write_attributes({'cie_addr': ieee}) - _LOGGER.debug("%s: finished configuration", self.entity_id) - - async def async_update(self): - """Retrieve latest state.""" - from zigpy.types.basic import uint16_t - - result = await helpers.safe_read(self._endpoint.ias_zone, - ['zone_status'], - allow_cache=False, - only_cache=(not self._initialized)) - state = result.get('zone_status', self._state) - if isinstance(state, (int, uint16_t)): - self._state = result.get('zone_status', self._state) & 3 - - -class Remote(RestoreEntity, ZhaEntity, BinarySensorDevice): - """ZHA switch/remote controller/button.""" - - _domain = DOMAIN - - def __init__(self, **kwargs): - """Initialize Switch.""" - super().__init__(**kwargs) - self._level = 0 - from zigpy.zcl.clusters import general - self._out_listeners = { - general.OnOff.cluster_id: OnOffListener( - self, - self._out_clusters[general.OnOff.cluster_id] - ) - } - - out_clusters = kwargs.get('out_clusters') - self._zcl_reporting = {} - - if general.LevelControl.cluster_id in out_clusters: - self._out_listeners.update({ - general.LevelControl.cluster_id: LevelListener( - self, - out_clusters[general.LevelControl.cluster_id] - ) - }) - - @property - def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - return self._state - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - self._device_state_attributes.update({ - 'level': self._state and self._level or 0 - }) - return self._device_state_attributes - - @property - def zcl_reporting_config(self): - """Return ZCL attribute reporting configuration.""" - return self._zcl_reporting - - def move_level(self, change): - """Increment the level, setting state if appropriate.""" - if not self._state and change > 0: - self._level = 0 - self._level = min(255, max(0, self._level + change)) - self._state = bool(self._level) - self.async_schedule_update_ha_state() - - def set_level(self, level): - """Set the level, setting state if appropriate.""" - self._level = level - self._state = bool(self._level) - self.async_schedule_update_ha_state() - - def set_state(self, state): - """Set the state.""" - self._state = state - if self._level == 0: - self._level = 255 - self.async_schedule_update_ha_state() - - async def async_configure(self): - """Bind clusters.""" - from zigpy.zcl.clusters import general - await helpers.bind_cluster( - self.entity_id, - self._out_clusters[general.OnOff.cluster_id] - ) - if general.LevelControl.cluster_id in self._out_clusters: - await helpers.bind_cluster( - self.entity_id, - self._out_clusters[general.LevelControl.cluster_id] - ) - - async def async_added_to_hass(self): - """Run when about to be added to hass.""" - await super().async_added_to_hass() - old_state = await self.async_get_last_state() - if self._state is not None or old_state is None: - return - - _LOGGER.debug("%s restoring old state: %s", self.entity_id, old_state) - if 'level' in old_state.attributes: - self._level = old_state.attributes['level'] - self._state = old_state.state == STATE_ON - - async def async_update(self): - """Retrieve latest state.""" - from zigpy.zcl.clusters.general import OnOff - result = await helpers.safe_read( - self._endpoint.out_clusters[OnOff.cluster_id], - ['on_off'], - allow_cache=False, - only_cache=(not self._initialized) - ) - self._state = result.get('on_off', self._state) - - -class BinarySensor(RestoreEntity, ZhaEntity, BinarySensorDevice): - """ZHA switch.""" +class BinarySensor(ZhaEntity, BinarySensorDevice): + """ZHA BinarySensor.""" _domain = DOMAIN _device_class = None - value_attribute = 0 - def __init__(self, device_class, **kwargs): + def __init__(self, **kwargs): """Initialize the ZHA binary sensor.""" super().__init__(**kwargs) - self._device_class = device_class - self._cluster = list(kwargs['in_clusters'].values())[0] + self._device_state_attributes = {} + self._zone_listener = self.cluster_listeners.get(LISTENER_ZONE) + self._on_off_listener = self.cluster_listeners.get(LISTENER_ON_OFF) + self._level_listener = self.cluster_listeners.get(LISTENER_LEVEL) + self._attr_listener = self.cluster_listeners.get(LISTENER_ATTRIBUTE) + self._zha_sensor_type = kwargs[SENSOR_TYPE] + self._level = None - def attribute_updated(self, attribute, value): - """Handle attribute update from device.""" - _LOGGER.debug("Attribute updated: %s %s %s", self, attribute, value) - if attribute == self.value_attribute: - self._state = bool(value) - self.async_schedule_update_ha_state() + async def _determine_device_class(self): + """Determine the device class for this binary sensor.""" + device_class_supplier = DEVICE_CLASS_REGISTRY.get( + self._zha_sensor_type) + if callable(device_class_supplier): + listener = self.cluster_listeners.get(self._zha_sensor_type) + if listener is None: + return None + return await device_class_supplier(listener) + return device_class_supplier async def async_added_to_hass(self): """Run when about to be added to hass.""" + self._device_class = await self._determine_device_class() await super().async_added_to_hass() - old_state = await self.async_get_last_state() - if self._state is not None or old_state is None: - return - - _LOGGER.debug("%s restoring old state: %s", self.entity_id, old_state) - self._state = old_state.state == STATE_ON - - @property - def cluster(self): - """Zigbee cluster for this entity.""" - return self._cluster - - @property - def zcl_reporting_config(self): - """ZHA reporting configuration.""" - return {self.cluster: {self.value_attribute: REPORT_CONFIG_IMMEDIATE}} + if self._level_listener: + await self.async_accept_signal( + self._level_listener, SIGNAL_SET_LEVEL, self.set_level) + await self.async_accept_signal( + self._level_listener, SIGNAL_MOVE_LEVEL, self.move_level) + if self._on_off_listener: + await self.async_accept_signal( + self._on_off_listener, SIGNAL_ATTR_UPDATED, + self.async_set_state) + if self._zone_listener: + await self.async_accept_signal( + self._zone_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) + if self._attr_listener: + await self.async_accept_signal( + self._attr_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) @property def is_on(self) -> bool: @@ -315,3 +136,32 @@ class BinarySensor(RestoreEntity, ZhaEntity, BinarySensorDevice): def device_class(self) -> str: """Return device class from component DEVICE_CLASSES.""" return self._device_class + + def async_set_state(self, state): + """Set the state.""" + self._state = bool(state) + self.async_schedule_update_ha_state() + + def move_level(self, change): + """Increment the level, setting state if appropriate.""" + level = self._level or 0 + if not self._state and change > 0: + level = 0 + self._level = min(254, max(0, level + change)) + self._state = bool(self._level) + self.async_schedule_update_ha_state() + + def set_level(self, level): + """Set the level, setting state if appropriate.""" + self._level = level + self._state = bool(level) + self.async_schedule_update_ha_state() + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self._level_listener is not None: + self._device_state_attributes.update({ + ATTR_LEVEL: self._state and self._level or 0 + }) + return self._device_state_attributes diff --git a/homeassistant/components/zha/core/__init__.py b/homeassistant/components/zha/core/__init__.py index 47e6ed2b0ee..e7443e7e0b7 100644 --- a/homeassistant/components/zha/core/__init__.py +++ b/homeassistant/components/zha/core/__init__.py @@ -4,3 +4,10 @@ Core module for Zigbee Home Automation. For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ + +# flake8: noqa +from .device import ZHADevice +from .gateway import ZHAGateway +from .listeners import ( + ClusterListener, AttributeListener, OnOffListener, LevelListener, + IASZoneListener, ActivePowerListener, BatteryListener, EventRelayListener) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 3069ebf02db..cb3a311c985 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -55,10 +55,38 @@ IEEE = 'ieee' MODEL = 'model' NAME = 'name' +SENSOR_TYPE = 'sensor_type' +HUMIDITY = 'humidity' +TEMPERATURE = 'temperature' +ILLUMINANCE = 'illuminance' +PRESSURE = 'pressure' +METERING = 'metering' +ELECTRICAL_MEASUREMENT = 'electrical_measurement' +POWER_CONFIGURATION = 'power_configuration' +GENERIC = 'generic' +UNKNOWN = 'unknown' +OPENING = 'opening' +ZONE = 'zone' +OCCUPANCY = 'occupancy' + +ATTR_LEVEL = 'level' + +LISTENER_ON_OFF = 'on_off' +LISTENER_ATTRIBUTE = 'attribute' +LISTENER_COLOR = 'color' +LISTENER_FAN = 'fan' +LISTENER_LEVEL = ATTR_LEVEL +LISTENER_ZONE = 'zone' +LISTENER_ACTIVE_POWER = 'active_power' LISTENER_BATTERY = 'battery' +LISTENER_EVENT_RELAY = 'event_relay' SIGNAL_ATTR_UPDATED = 'attribute_updated' +SIGNAL_MOVE_LEVEL = "move_level" +SIGNAL_SET_LEVEL = "set_level" +SIGNAL_STATE_ATTR = "update_state_attribute" SIGNAL_AVAILABLE = 'available' +SIGNAL_REMOVE = 'remove' class RadioType(enum.Enum): @@ -78,9 +106,10 @@ DISCOVERY_KEY = 'zha_discovery_info' DEVICE_CLASS = {} SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {} +CLUSTER_REPORT_CONFIGS = {} CUSTOM_CLUSTER_MAPPINGS = {} COMPONENT_CLUSTERS = {} -EVENTABLE_CLUSTERS = [] +EVENT_RELAY_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 c7dabced24b..292f9817671 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -14,7 +14,7 @@ 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 + ATTR_ENDPOINT_ID, IEEE, MODEL, NAME, UNKNOWN ) from .listeners import EventRelayListener @@ -30,11 +30,14 @@ class ZHADevice: 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._manufacturer = UNKNOWN + self._model = UNKNOWN + ept_id = next((ept_id for ept_id in endpoint_ids if ept_id != 0), None) + if ept_id is not None: + 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.cluster_listeners = {} self._relay_listeners = [] self._all_listeners = [] self._name = "{} {}".format( @@ -101,21 +104,11 @@ class ZHADevice: """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.""" @@ -157,17 +150,13 @@ class ZHADevice: """Add cluster listener to device.""" # only keep 1 power listener if cluster_listener.name is LISTENER_BATTERY and \ - LISTENER_BATTERY in self._cluster_listeners: + 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) + self.cluster_listeners[cluster_listener.name] = cluster_listener async def async_configure(self): """Configure the device.""" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 479b2f79b26..2722f6720ce 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -5,7 +5,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +import asyncio import collections +import itertools import logging from homeassistant import const as ha_const from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -13,15 +15,27 @@ from homeassistant.helpers.entity_component import EntityComponent from . import const as zha_const from .const import ( COMPONENTS, CONF_DEVICE_CONFIG, DATA_ZHA, DATA_ZHA_CORE_COMPONENT, DOMAIN, - ZHA_DISCOVERY_NEW, EVENTABLE_CLUSTERS, DATA_ZHA_CORE_EVENTS, DEVICE_CLASS, - SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - CUSTOM_CLUSTER_MAPPINGS, COMPONENT_CLUSTERS) + ZHA_DISCOVERY_NEW, DEVICE_CLASS, SINGLE_INPUT_CLUSTER_DEVICE_CLASS, + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, COMPONENT_CLUSTERS, HUMIDITY, + TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, + POWER_CONFIGURATION, GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, + LISTENER_BATTERY, 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) +from .device import ZHADevice from ..device_entity import ZhaDeviceEntity -from ..event import ZhaEvent, ZhaRelayEvent +from .listeners import ( + LISTENER_REGISTRY, AttributeListener, EventRelayListener, ZDOListener) from .helpers import convert_ieee _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = {} +BINARY_SENSOR_TYPES = {} +EntityReference = collections.namedtuple( + 'EntityReference', 'reference_id zha_device cluster_listeners device_info') + class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" @@ -31,16 +45,9 @@ class ZHAGateway: self._hass = hass self._config = config self._component = EntityComponent(_LOGGER, DOMAIN, hass) + self._devices = {} self._device_registry = collections.defaultdict(list) - self._events = {} - establish_device_mappings() - - for component in COMPONENTS: - hass.data[DATA_ZHA][component] = ( - hass.data[DATA_ZHA].get(component, {}) - ) hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component - hass.data[DATA_ZHA][DATA_ZHA_CORE_EVENTS] = self._events def device_joined(self, device): """Handle device joined. @@ -67,197 +74,310 @@ class ZHAGateway: def device_removed(self, device): """Handle device being removed from the network.""" - for device_entity in self._device_registry[device.ieee]: - self._hass.async_create_task(device_entity.async_remove()) - 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 - - device_manufacturer = device_model = None - - for endpoint_id, endpoint in device.endpoints.items(): - if endpoint_id == 0: # ZDO - continue - - if endpoint.manufacturer is not None: - device_manufacturer = endpoint.manufacturer - if endpoint.model is not None: - device_model = endpoint.model - - component = None - profile_clusters = ([], []) - device_key = "{}-{}".format(device.ieee, endpoint_id) - node_config = {} - if CONF_DEVICE_CONFIG in self._config: - node_config = self._config[CONF_DEVICE_CONFIG].get( - device_key, {} - ) - - if endpoint.profile_id in zigpy.profiles.PROFILES: - profile = zigpy.profiles.PROFILES[endpoint.profile_id] - if zha_const.DEVICE_CLASS.get(endpoint.profile_id, - {}).get(endpoint.device_type, - None): - profile_clusters = profile.CLUSTERS[endpoint.device_type] - profile_info = zha_const.DEVICE_CLASS[endpoint.profile_id] - component = profile_info[endpoint.device_type] - - if ha_const.CONF_TYPE in node_config: - component = node_config[ha_const.CONF_TYPE] - profile_clusters = zha_const.COMPONENT_CLUSTERS[component] - - if component: - in_clusters = [endpoint.in_clusters[c] - for c in profile_clusters[0] - if c in endpoint.in_clusters] - out_clusters = [endpoint.out_clusters[c] - for c in profile_clusters[1] - if c in endpoint.out_clusters] - discovery_info = { - 'application_listener': self, - 'endpoint': endpoint, - 'in_clusters': {c.cluster_id: c for c in in_clusters}, - 'out_clusters': {c.cluster_id: c for c in out_clusters}, - 'manufacturer': endpoint.manufacturer, - 'model': endpoint.model, - 'new_join': join, - 'unique_id': device_key, - } - - if join: - async_dispatcher_send( - self._hass, - ZHA_DISCOVERY_NEW.format(component), - discovery_info - ) - else: - self._hass.data[DATA_ZHA][component][device_key] = ( - discovery_info - ) - - for cluster in endpoint.in_clusters.values(): - await self._attempt_single_cluster_device( - endpoint, - cluster, - profile_clusters[0], - device_key, - zha_const.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, - 'in_clusters', - join, - ) - - for cluster in endpoint.out_clusters.values(): - await self._attempt_single_cluster_device( - endpoint, - cluster, - profile_clusters[1], - device_key, - zha_const.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - 'out_clusters', - join, - ) - - endpoint_entity = ZhaDeviceEntity( - device, - device_manufacturer, - device_model, - self, - ) - await self._component.async_add_entities([endpoint_entity]) - - def register_entity(self, ieee, entity_obj): - """Record the creation of a hass entity associated with ieee.""" - self._device_registry[ieee].append(entity_obj) - - async def _attempt_single_cluster_device(self, endpoint, cluster, - profile_clusters, device_key, - device_classes, discovery_attr, - is_new_join): - """Try to set up an entity from a "bare" cluster.""" - if cluster.cluster_id in EVENTABLE_CLUSTERS: - if cluster.endpoint.device.ieee not in self._events: - self._events.update({cluster.endpoint.device.ieee: []}) - from zigpy.zcl.clusters.general import OnOff, LevelControl - if discovery_attr == 'out_clusters' and \ - (cluster.cluster_id == OnOff.cluster_id or - cluster.cluster_id == LevelControl.cluster_id): - self._events[cluster.endpoint.device.ieee].append( - ZhaRelayEvent(self._hass, cluster) - ) - else: - self._events[cluster.endpoint.device.ieee].append(ZhaEvent( - self._hass, - cluster - )) - - if cluster.cluster_id in profile_clusters: - return - - component = sub_component = None - for cluster_type, candidate_component in device_classes.items(): - if isinstance(cluster, cluster_type): - component = candidate_component - break - - for signature, comp in zha_const.CUSTOM_CLUSTER_MAPPINGS.items(): - if (isinstance(endpoint.device, signature[0]) and - cluster.cluster_id == signature[1]): - component = comp[0] - sub_component = comp[1] - break - - if component is None: - return - - cluster_key = "{}-{}".format(device_key, cluster.cluster_id) - discovery_info = { - 'application_listener': self, - 'endpoint': endpoint, - 'in_clusters': {}, - 'out_clusters': {}, - 'manufacturer': endpoint.manufacturer, - 'model': endpoint.model, - 'new_join': is_new_join, - 'unique_id': cluster_key, - 'entity_suffix': '_{}'.format(cluster.cluster_id), - } - discovery_info[discovery_attr] = {cluster.cluster_id: cluster} - if sub_component: - discovery_info.update({'sub_component': sub_component}) - - if is_new_join: + device = self._devices.pop(device.ieee, None) + self._device_registry.pop(device.ieee, None) + if device is not None: + self._hass.async_create_task(device.async_unsub_dispatcher()) async_dispatcher_send( self._hass, - ZHA_DISCOVERY_NEW.format(component), - discovery_info + "{}_{}".format(SIGNAL_REMOVE, str(device.ieee)) ) - else: - self._hass.data[DATA_ZHA][component][cluster_key] = discovery_info + + def get_device(self, ieee_str): + """Return ZHADevice for given ieee.""" + ieee = convert_ieee(ieee_str) + return self._devices.get(ieee) + + def get_entity_reference(self, entity_id): + """Return entity reference for given entity_id if found.""" + for entity_reference in itertools.chain.from_iterable( + self.device_registry.values()): + if entity_id == entity_reference.reference_id: + return entity_reference + + @property + def devices(self): + """Return devices.""" + return self._devices + + @property + def device_registry(self): + """Return entities by ieee.""" + return self._device_registry + + def register_entity_reference( + self, ieee, reference_id, zha_device, cluster_listeners, + device_info): + """Record the creation of a hass entity associated with ieee.""" + self._device_registry[ieee].append( + EntityReference( + reference_id=reference_id, + zha_device=zha_device, + cluster_listeners=cluster_listeners, + device_info=device_info + ) + ) + + async def _get_or_create_device(self, zigpy_device): + """Get or create a ZHA device.""" + zha_device = self._devices.get(zigpy_device.ieee) + if zha_device is None: + zha_device = ZHADevice(self._hass, zigpy_device, self) + self._devices[zigpy_device.ieee] = zha_device + return zha_device + + async def accept_zigbee_messages(self, _service_or_event): + """Allow devices to accept zigbee messages.""" + accept_messages_calls = [] + for device in self.devices.values(): + accept_messages_calls.append(device.async_accept_messages()) + await asyncio.gather(*accept_messages_calls) + + async def async_device_initialized(self, device, is_new_join): + """Handle device joined and basic information discovered (async).""" + zha_device = await self._get_or_create_device(device) + discovery_infos = [] + endpoint_tasks = [] + for endpoint_id, endpoint in device.endpoints.items(): + endpoint_tasks.append(self._async_process_endpoint( + endpoint_id, endpoint, discovery_infos, device, zha_device, + is_new_join + )) + await asyncio.gather(*endpoint_tasks) + + await zha_device.async_initialize(not is_new_join) + + discovery_tasks = [] + for discovery_info in discovery_infos: + discovery_tasks.append(_dispatch_discovery_info( + self._hass, + is_new_join, + discovery_info + )) + await asyncio.gather(*discovery_tasks) + + device_entity = _create_device_entity(zha_device) + await self._component.async_add_entities([device_entity]) + + async def _async_process_endpoint( + self, endpoint_id, endpoint, discovery_infos, device, zha_device, + is_new_join): + """Process an endpoint on a zigpy device.""" + import zigpy.profiles + + if endpoint_id == 0: # ZDO + await _create_cluster_listener( + endpoint, + zha_device, + is_new_join, + listener_class=ZDOListener + ) + return + + component = None + profile_clusters = ([], []) + device_key = "{}-{}".format(device.ieee, endpoint_id) + node_config = {} + if CONF_DEVICE_CONFIG in self._config: + node_config = self._config[CONF_DEVICE_CONFIG].get( + device_key, {} + ) + + if endpoint.profile_id in zigpy.profiles.PROFILES: + profile = zigpy.profiles.PROFILES[endpoint.profile_id] + if zha_const.DEVICE_CLASS.get(endpoint.profile_id, + {}).get(endpoint.device_type, + None): + profile_clusters = profile.CLUSTERS[endpoint.device_type] + profile_info = zha_const.DEVICE_CLASS[endpoint.profile_id] + component = profile_info[endpoint.device_type] + + if ha_const.CONF_TYPE in node_config: + component = node_config[ha_const.CONF_TYPE] + profile_clusters = zha_const.COMPONENT_CLUSTERS[component] + + if component and component in COMPONENTS: + profile_match = await _handle_profile_match( + self._hass, endpoint, profile_clusters, zha_device, + component, device_key, is_new_join) + discovery_infos.append(profile_match) + + discovery_infos.extend(await _handle_single_cluster_matches( + self._hass, + endpoint, + zha_device, + profile_clusters, + device_key, + is_new_join + )) + + +async def _create_cluster_listener(cluster, zha_device, is_new_join, + listeners=None, listener_class=None): + """Create a cluster listener and attach it to a device.""" + if listener_class is None: + listener_class = LISTENER_REGISTRY.get(cluster.cluster_id, + AttributeListener) + listener = listener_class(cluster, zha_device) + if is_new_join: + await listener.async_configure() + zha_device.add_cluster_listener(listener) + if listeners is not None: + listeners.append(listener) + + +async def _dispatch_discovery_info(hass, is_new_join, discovery_info): + """Dispatch or store discovery information.""" + component = discovery_info['component'] + if is_new_join: + async_dispatcher_send( + hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info + ) + else: + hass.data[DATA_ZHA][component][discovery_info['unique_id']] = \ + discovery_info + + +async def _handle_profile_match(hass, endpoint, profile_clusters, zha_device, + component, device_key, is_new_join): + """Dispatch a profile match to the appropriate HA component.""" + in_clusters = [endpoint.in_clusters[c] + for c in profile_clusters[0] + if c in endpoint.in_clusters] + out_clusters = [endpoint.out_clusters[c] + for c in profile_clusters[1] + if c in endpoint.out_clusters] + + listeners = [] + cluster_tasks = [] + + for cluster in in_clusters: + cluster_tasks.append(_create_cluster_listener( + cluster, zha_device, is_new_join, listeners=listeners)) + + for cluster in out_clusters: + cluster_tasks.append(_create_cluster_listener( + cluster, zha_device, is_new_join, listeners=listeners)) + + await asyncio.gather(*cluster_tasks) + + discovery_info = { + 'unique_id': device_key, + 'zha_device': zha_device, + 'listeners': listeners, + 'component': component + } + + if component == 'binary_sensor': + discovery_info.update({SENSOR_TYPE: UNKNOWN}) + cluster_ids = [] + cluster_ids.extend(profile_clusters[0]) + cluster_ids.extend(profile_clusters[1]) + for cluster_id in cluster_ids: + if cluster_id in BINARY_SENSOR_TYPES: + discovery_info.update({ + SENSOR_TYPE: BINARY_SENSOR_TYPES.get( + cluster_id, UNKNOWN) + }) + break + + return discovery_info + + +async def _handle_single_cluster_matches(hass, endpoint, zha_device, + profile_clusters, device_key, + is_new_join): + """Dispatch single cluster matches to HA components.""" + cluster_matches = [] + cluster_match_tasks = [] + event_listener_tasks = [] + for cluster in endpoint.in_clusters.values(): + if cluster.cluster_id not in profile_clusters[0]: + cluster_match_tasks.append(_handle_single_cluster_match( + hass, + zha_device, + cluster, + device_key, + zha_const.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, + is_new_join, + )) + + for cluster in endpoint.out_clusters.values(): + if cluster.cluster_id not in profile_clusters[1]: + cluster_match_tasks.append(_handle_single_cluster_match( + hass, + zha_device, + cluster, + device_key, + zha_const.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, + is_new_join, + )) + + if cluster.cluster_id in EVENT_RELAY_CLUSTERS: + event_listener_tasks.append(_create_cluster_listener( + cluster, + zha_device, + is_new_join, + listener_class=EventRelayListener + )) + await asyncio.gather(*event_listener_tasks) + cluster_match_results = await asyncio.gather(*cluster_match_tasks) + for cluster_match in cluster_match_results: + if cluster_match is not None: + cluster_matches.append(cluster_match) + return cluster_matches + + +async def _handle_single_cluster_match(hass, zha_device, cluster, device_key, + device_classes, is_new_join): + """Dispatch a single cluster match to a HA component.""" + component = None # sub_component = None + for cluster_type, candidate_component in device_classes.items(): + if isinstance(cluster, cluster_type): + component = candidate_component + break + + if component is None or component not in COMPONENTS: + return + listeners = [] + await _create_cluster_listener(cluster, zha_device, is_new_join, + listeners=listeners) + # don't actually create entities for PowerConfiguration + # find a better way to do this without abusing single cluster reg + from zigpy.zcl.clusters.general import PowerConfiguration + if cluster.cluster_id == PowerConfiguration.cluster_id: + return + + cluster_key = "{}-{}".format(device_key, cluster.cluster_id) + discovery_info = { + 'unique_id': cluster_key, + 'zha_device': zha_device, + 'listeners': listeners, + 'entity_suffix': '_{}'.format(cluster.cluster_id), + 'component': component + } + + if component == 'sensor': + discovery_info.update({ + SENSOR_TYPE: SENSOR_TYPES.get(cluster.cluster_id, GENERIC) + }) + if component == 'binary_sensor': + discovery_info.update({ + SENSOR_TYPE: BINARY_SENSOR_TYPES.get(cluster.cluster_id, UNKNOWN) + }) + + return discovery_info + + +def _create_device_entity(zha_device): + """Create ZHADeviceEntity.""" + device_entity_listeners = [] + if LISTENER_BATTERY in zha_device.cluster_listeners: + listener = zha_device.cluster_listeners.get(LISTENER_BATTERY) + device_entity_listeners.append(listener) + return ZhaDeviceEntity(zha_device, device_entity_listeners) def establish_device_mappings(): @@ -266,19 +386,16 @@ def establish_device_mappings(): These cannot be module level, as importing bellows must be done in a in a function. """ - from zigpy import zcl, quirks + from zigpy import zcl from zigpy.profiles import PROFILES, zha, zll - from ..sensor import RelativeHumiditySensor if zha.PROFILE_ID not in DEVICE_CLASS: DEVICE_CLASS[zha.PROFILE_ID] = {} if zll.PROFILE_ID not in DEVICE_CLASS: DEVICE_CLASS[zll.PROFILE_ID] = {} - EVENTABLE_CLUSTERS.append(zcl.clusters.general.AnalogInput.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.MultistateInput.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) + EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) + EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', @@ -293,6 +410,7 @@ def establish_device_mappings(): zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', }) + DEVICE_CLASS[zll.PROFILE_ID].update({ zll.DeviceType.ON_OFF_LIGHT: 'light', zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', @@ -321,14 +439,97 @@ def establish_device_mappings(): zcl.clusters.measurement.OccupancySensing: 'binary_sensor', zcl.clusters.hvac.Fan: 'fan', }) + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'binary_sensor', }) - # A map of device/cluster to component/sub-component - CUSTOM_CLUSTER_MAPPINGS.update({ - (quirks.smartthings.SmartthingsTemperatureHumiditySensor, 64581): - ('sensor', RelativeHumiditySensor) + SENSOR_TYPES.update({ + zcl.clusters.measurement.RelativeHumidity.cluster_id: HUMIDITY, + zcl.clusters.measurement.TemperatureMeasurement.cluster_id: + TEMPERATURE, + zcl.clusters.measurement.PressureMeasurement.cluster_id: PRESSURE, + zcl.clusters.measurement.IlluminanceMeasurement.cluster_id: + ILLUMINANCE, + zcl.clusters.smartenergy.Metering.cluster_id: METERING, + zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: + ELECTRICAL_MEASUREMENT, + zcl.clusters.general.PowerConfiguration.cluster_id: + POWER_CONFIGURATION, + }) + + BINARY_SENSOR_TYPES.update({ + zcl.clusters.measurement.OccupancySensing.cluster_id: OCCUPANCY, + zcl.clusters.security.IasZone.cluster_id: ZONE, + zcl.clusters.general.OnOff.cluster_id: OPENING + }) + + CLUSTER_REPORT_CONFIGS.update({ + zcl.clusters.general.OnOff.cluster_id: [{ + 'attr': 'on_off', + 'config': REPORT_CONFIG_IMMEDIATE + }], + zcl.clusters.general.LevelControl.cluster_id: [{ + 'attr': 'current_level', + 'config': REPORT_CONFIG_ASAP + }], + zcl.clusters.lighting.Color.cluster_id: [{ + 'attr': 'current_x', + 'config': REPORT_CONFIG_DEFAULT + }, { + 'attr': 'current_y', + 'config': REPORT_CONFIG_DEFAULT + }, { + 'attr': 'color_temperature', + 'config': REPORT_CONFIG_DEFAULT + }], + zcl.clusters.measurement.RelativeHumidity.cluster_id: [{ + 'attr': 'measured_value', + 'config': ( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + 50 + ) + }], + zcl.clusters.measurement.TemperatureMeasurement.cluster_id: [{ + 'attr': 'measured_value', + 'config': ( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + 50 + ) + }], + zcl.clusters.measurement.PressureMeasurement.cluster_id: [{ + 'attr': 'measured_value', + 'config': REPORT_CONFIG_DEFAULT + }], + zcl.clusters.measurement.IlluminanceMeasurement.cluster_id: [{ + 'attr': 'measured_value', + 'config': REPORT_CONFIG_DEFAULT + }], + zcl.clusters.smartenergy.Metering.cluster_id: [{ + 'attr': 'instantaneous_demand', + 'config': REPORT_CONFIG_DEFAULT + }], + zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: [{ + 'attr': 'active_power', + 'config': REPORT_CONFIG_DEFAULT + }], + zcl.clusters.general.PowerConfiguration.cluster_id: [{ + 'attr': 'battery_voltage', + 'config': REPORT_CONFIG_DEFAULT + }, { + 'attr': 'battery_percentage_remaining', + 'config': REPORT_CONFIG_DEFAULT + }], + zcl.clusters.measurement.OccupancySensing.cluster_id: [{ + 'attr': 'occupancy', + 'config': REPORT_CONFIG_IMMEDIATE + }], + zcl.clusters.hvac.Fan.cluster_id: [{ + 'attr': 'fan_mode', + 'config': REPORT_CONFIG_OP + }], }) # A map of hass components to all Zigbee clusters it could use diff --git a/homeassistant/components/zha/core/listeners.py b/homeassistant/components/zha/core/listeners.py index 4f60ea83d6f..916319b2d98 100644 --- a/homeassistant/components/zha/core/listeners.py +++ b/homeassistant/components/zha/core/listeners.py @@ -5,20 +5,48 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +import asyncio +from enum import Enum +from functools import wraps import logging +from random import uniform from homeassistant.core import callback -from .const import SIGNAL_ATTR_UPDATED +from homeassistant.helpers.dispatcher import async_dispatcher_send +from .helpers import ( + bind_configure_reporting, construct_unique_id, + safe_read, get_attr_id_by_name) +from .const import ( + CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, + SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_STATE_ATTR, ATTR_LEVEL +) + +LISTENER_REGISTRY = {} _LOGGER = logging.getLogger(__name__) -def parse_and_log_command(entity_id, cluster, tsn, command_id, args): +def populate_listener_registry(): + """Populate the listener registry.""" + from zigpy import zcl + LISTENER_REGISTRY.update({ + zcl.clusters.general.OnOff.cluster_id: OnOffListener, + zcl.clusters.general.LevelControl.cluster_id: LevelListener, + zcl.clusters.lighting.Color.cluster_id: ColorListener, + zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: + ActivePowerListener, + zcl.clusters.general.PowerConfiguration.cluster_id: BatteryListener, + zcl.clusters.security.IasZone.cluster_id: IASZoneListener, + zcl.clusters.hvac.Fan.cluster_id: FanListener, + }) + + +def parse_and_log_command(unique_id, cluster, tsn, command_id, args): """Parse and log a zigbee cluster command.""" cmd = cluster.server_commands.get(command_id, [command_id])[0] _LOGGER.debug( "%s: received '%s' command with %s args on cluster_id '%s' tsn '%s'", - entity_id, + unique_id, cmd, args, cluster.cluster_id, @@ -27,40 +55,214 @@ def parse_and_log_command(entity_id, cluster, tsn, command_id, args): return cmd +def decorate_command(listener, command): + """Wrap a cluster command to make it safe.""" + @wraps(command) + async def wrapper(*args, **kwds): + from zigpy.zcl.foundation import Status + from zigpy.exceptions import DeliveryError + try: + result = await command(*args, **kwds) + _LOGGER.debug("%s: executed command: %s %s %s %s", + listener.unique_id, + command.__name__, + "{}: {}".format("with args", args), + "{}: {}".format("with kwargs", kwds), + "{}: {}".format("and result", result)) + return result[1] is Status.SUCCESS + except DeliveryError: + _LOGGER.debug("%s: command failed: %s", listener.unique_id, + command.__name__) + return False + return wrapper + + +class ListenerStatus(Enum): + """Status of a listener.""" + + CREATED = 1 + CONFIGURED = 2 + INITIALIZED = 3 + LISTENING = 4 + + class ClusterListener: """Listener for a Zigbee cluster.""" - def __init__(self, entity, cluster): + def __init__(self, cluster, device): """Initialize ClusterListener.""" - self._entity = entity self._cluster = cluster + self._zha_device = device + self._unique_id = construct_unique_id(cluster) + self._report_config = CLUSTER_REPORT_CONFIGS.get( + self._cluster.cluster_id, + [{'attr': 0, 'config': REPORT_CONFIG_DEFAULT}] + ) + self._status = ListenerStatus.CREATED + @property + def unique_id(self): + """Return the unique id for this listener.""" + return self._unique_id + + @property + def cluster(self): + """Return the zigpy cluster for this listener.""" + return self._cluster + + @property + def device(self): + """Return the device this listener is linked to.""" + return self._zha_device + + @property + def status(self): + """Return the status of the listener.""" + return self._status + + def set_report_config(self, report_config): + """Set the reporting configuration.""" + self._report_config = report_config + + async def async_configure(self): + """Set cluster binding and attribute reporting.""" + manufacturer = None + manufacturer_code = self._zha_device.manufacturer_code + if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: + manufacturer = manufacturer_code + + skip_bind = False # bind cluster only for the 1st configured attr + for report_config in self._report_config: + attr = report_config.get('attr') + min_report_interval, max_report_interval, change = \ + report_config.get('config') + await bind_configure_reporting( + self._unique_id, self.cluster, attr, + min_report=min_report_interval, + max_report=max_report_interval, + reportable_change=change, + skip_bind=skip_bind, + manufacturer=manufacturer + ) + skip_bind = True + await asyncio.sleep(uniform(0.1, 0.5)) + _LOGGER.debug( + "%s: finished listener configuration", + self._unique_id + ) + self._status = ListenerStatus.CONFIGURED + + async def async_initialize(self, from_cache): + """Initialize listener.""" + self._status = ListenerStatus.INITIALIZED + + async def accept_messages(self): + """Attach to the cluster so we can receive messages.""" + self._cluster.add_listener(self) + self._status = ListenerStatus.LISTENING + + @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" pass + @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" pass + @callback def zdo_command(self, *args, **kwargs): """Handle ZDO commands on this cluster.""" pass + @callback def zha_send_event(self, cluster, command, args): - """Relay entity events to hass.""" - pass # don't let entities fire events + """Relay events to hass.""" + self._zha_device.hass.bus.async_fire( + 'zha_event', + { + 'unique_id': self._unique_id, + 'command': command, + 'args': args + } + ) + + async def async_update(self): + """Retrieve latest state from cluster.""" + pass + + async def get_attribute_value(self, attribute, from_cache=True): + """Get the value for an attribute.""" + result = await safe_read( + self._cluster, + [attribute], + allow_cache=from_cache, + only_cache=from_cache + ) + return result.get(attribute) + + def __getattr__(self, name): + """Get attribute or a decorated cluster command.""" + if hasattr(self._cluster, name) and callable( + getattr(self._cluster, name)): + command = getattr(self._cluster, name) + command.__name__ = name + return decorate_command( + self, + command + ) + return self.__getattribute__(name) + + +class AttributeListener(ClusterListener): + """Listener for the attribute reports cluster.""" + + name = 'attribute' + + def __init__(self, cluster, device): + """Initialize AttributeListener.""" + super().__init__(cluster, device) + attr = self._report_config[0].get('attr') + if isinstance(attr, str): + self._value_attribute = get_attr_id_by_name(self.cluster, attr) + else: + self._value_attribute = attr + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + if attrid == self._value_attribute: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.get_attribute_value( + self._report_config[0].get('attr'), from_cache=from_cache) + await super().async_initialize(from_cache) class OnOffListener(ClusterListener): """Listener for the OnOff Zigbee cluster.""" + name = 'on_off' + ON_OFF = 0 + def __init__(self, cluster, device): + """Initialize ClusterListener.""" + super().__init__(cluster, device) + self._state = None + + @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command( - self._entity.entity_id, + self.unique_id, self._cluster, tsn, command_id, @@ -68,27 +270,42 @@ class OnOffListener(ClusterListener): ) if cmd in ('off', 'off_with_effect'): - self._entity.set_state(False) + self.attribute_updated(self.ON_OFF, False) elif cmd in ('on', 'on_with_recall_global_scene', 'on_with_timed_off'): - self._entity.set_state(True) + self.attribute_updated(self.ON_OFF, True) elif cmd == 'toggle': - self._entity.set_state(not self._entity.is_on) + self.attribute_updated(self.ON_OFF, not bool(self._state)) + @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == self.ON_OFF: - self._entity.set_state(bool(value)) + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + self._state = bool(value) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + self._state = bool( + await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)) + await super().async_initialize(from_cache) class LevelListener(ClusterListener): """Listener for the LevelControl Zigbee cluster.""" + name = ATTR_LEVEL + CURRENT_LEVEL = 0 + @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command( - self._entity.entity_id, + self.unique_id, self._cluster, tsn, command_id, @@ -96,21 +313,190 @@ class LevelListener(ClusterListener): ) if cmd in ('move_to_level', 'move_to_level_with_on_off'): - self._entity.set_level(args[0]) + self.dispatch_level_change(SIGNAL_SET_LEVEL, args[0]) elif cmd in ('move', 'move_with_on_off'): # We should dim slowly -- for now, just step once rate = args[1] if args[0] == 0xff: rate = 10 # Should read default move rate - self._entity.move_level(-rate if args[0] else rate) + self.dispatch_level_change( + SIGNAL_MOVE_LEVEL, -rate if args[0] else rate) elif cmd in ('step', 'step_with_on_off'): # Step (technically may change on/off) - self._entity.move_level(-args[1] if args[0] else args[1]) + self.dispatch_level_change( + SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1]) + @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" + _LOGGER.debug("%s: received attribute: %s update with value: %i", + self.unique_id, attrid, value) if attrid == self.CURRENT_LEVEL: - self._entity.set_level(value) + self.dispatch_level_change(SIGNAL_SET_LEVEL, value) + + def dispatch_level_change(self, command, level): + """Dispatch level change.""" + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, command), + level + ) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.get_attribute_value( + self.CURRENT_LEVEL, from_cache=from_cache) + await super().async_initialize(from_cache) + + +class IASZoneListener(ClusterListener): + """Listener for the IASZone Zigbee cluster.""" + + name = 'zone' + + def __init__(self, cluster, device): + """Initialize IASZoneListener.""" + super().__init__(cluster, device) + self._cluster.add_listener(self) + self._status = ListenerStatus.LISTENING + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + if command_id == 0: + state = args[0] & 3 + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + state + ) + _LOGGER.debug("Updated alarm state: %s", state) + elif command_id == 1: + _LOGGER.debug("Enroll requested") + res = self._cluster.enroll_response(0, 0) + self._zha_device.hass.async_create_task(res) + + async def async_configure(self): + """Configure IAS device.""" + from zigpy.exceptions import DeliveryError + _LOGGER.debug("%s: started IASZoneListener configuration", + self._unique_id) + try: + res = await self._cluster.bind() + _LOGGER.debug( + "%s: bound '%s' cluster: %s", + self.unique_id, self._cluster.ep_attribute, res[0] + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: Failed to bind '%s' cluster: %s", + self.unique_id, self._cluster.ep_attribute, str(ex) + ) + + ieee = self._cluster.endpoint.device.application.ieee + + try: + res = await self._cluster.write_attributes({'cie_addr': ieee}) + _LOGGER.debug( + "%s: wrote cie_addr: %s to '%s' cluster: %s", + self.unique_id, str(ieee), self._cluster.ep_attribute, + res[0] + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: Failed to write cie_addr: %s to '%s' cluster: %s", + self.unique_id, str(ieee), self._cluster.ep_attribute, str(ex) + ) + _LOGGER.debug("%s: finished IASZoneListener configuration", + self._unique_id) + + await self.get_attribute_value('zone_type', from_cache=False) + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + if attrid == 2: + value = value & 3 + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.get_attribute_value('zone_status', from_cache=from_cache) + await self.get_attribute_value('zone_state', from_cache=from_cache) + await super().async_initialize(from_cache) + + async def accept_messages(self): + """Attach to the cluster so we can receive messages.""" + self._status = ListenerStatus.LISTENING + + +class ActivePowerListener(AttributeListener): + """Listener that polls active power level.""" + + name = 'active_power' + + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("%s async_update", self.unique_id) + + # This is a polling listener. Don't allow cache. + result = await self.get_attribute_value( + 'active_power', from_cache=False) + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + result + ) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.get_attribute_value( + 'active_power', from_cache=from_cache) + await super().async_initialize(from_cache) + + +class BatteryListener(ClusterListener): + """Listener that polls active power level.""" + + name = 'battery' + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + attr = self._report_config[1].get('attr') + if isinstance(attr, str): + attr_id = get_attr_id_by_name(self.cluster, attr) + else: + attr_id = attr + if attrid == attr_id: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_STATE_ATTR), + 'battery_level', + value + ) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.async_read_state(from_cache) + await super().async_initialize(from_cache) + + async def async_update(self): + """Retrieve latest state.""" + await self.async_read_state(True) + + async def async_read_state(self, from_cache): + """Read data from the cluster.""" + await self.get_attribute_value( + 'battery_size', from_cache=from_cache) + await self.get_attribute_value( + 'battery_percentage_remaining', from_cache=from_cache) + await self.get_attribute_value( + 'active_power', from_cache=from_cache) class EventRelayListener(ClusterListener): @@ -143,3 +529,137 @@ class EventRelayListener(ClusterListener): self._cluster.server_commands.get(command_id)[0], args ) + + +class ColorListener(ClusterListener): + """Color listener.""" + + name = 'color' + + CAPABILITIES_COLOR_XY = 0x08 + CAPABILITIES_COLOR_TEMP = 0x10 + UNSUPPORTED_ATTRIBUTE = 0x86 + + def __init__(self, cluster, device): + """Initialize ClusterListener.""" + super().__init__(cluster, device) + self._color_capabilities = None + + def get_color_capabilities(self): + """Return the color capabilities.""" + return self._color_capabilities + + async def async_initialize(self, from_cache): + """Initialize listener.""" + capabilities = await self.get_attribute_value( + 'color_capabilities', from_cache=from_cache) + + if capabilities is None: + # ZCL Version 4 devices don't support the color_capabilities + # attribute. In this version XY support is mandatory, but we + # need to probe to determine if the device supports color + # temperature. + capabilities = self.CAPABILITIES_COLOR_XY + result = await self.get_attribute_value( + 'color_temperature', from_cache=from_cache) + + if result is not self.UNSUPPORTED_ATTRIBUTE: + capabilities |= self.CAPABILITIES_COLOR_TEMP + self._color_capabilities = capabilities + await super().async_initialize(from_cache) + + +class FanListener(ClusterListener): + """Fan listener.""" + + name = 'fan' + + _value_attribute = 0 + + async def async_set_speed(self, value) -> None: + """Set the speed of the fan.""" + from zigpy.exceptions import DeliveryError + try: + await self.cluster.write_attributes({'fan_mode': value}) + except DeliveryError as ex: + _LOGGER.error("%s: Could not set speed: %s", self.unique_id, ex) + return + + async def async_update(self): + """Retrieve latest state.""" + result = await self.get_attribute_value('fan_mode', from_cache=True) + + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + result + ) + + def attribute_updated(self, attrid, value): + """Handle attribute update from fan cluster.""" + attr_name = self.cluster.attributes.get(attrid, [attrid])[0] + _LOGGER.debug("%s: Attribute report '%s'[%s] = %s", + self.unique_id, self.cluster.name, attr_name, value) + if attrid == self._value_attribute: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.get_attribute_value( + self._value_attribute, from_cache=from_cache) + await super().async_initialize(from_cache) + + +class ZDOListener: + """Listener for ZDO events.""" + + name = 'zdo' + + def __init__(self, cluster, device): + """Initialize ClusterListener.""" + self._cluster = cluster + self._zha_device = device + self._status = ListenerStatus.CREATED + self._unique_id = "{}_ZDO".format(device.name) + + @property + def unique_id(self): + """Return the unique id for this listener.""" + return self._unique_id + + @property + def cluster(self): + """Return the aigpy cluster for this listener.""" + return self._cluster + + @property + def status(self): + """Return the status of the listener.""" + return self._status + + @callback + def device_announce(self, zigpy_device): + """Device announce handler.""" + pass + + @callback + def permit_duration(self, duration): + """Permit handler.""" + pass + + async def accept_messages(self): + """Attach to the cluster so we can receive messages.""" + self._cluster.add_listener(self) + self._status = ListenerStatus.LISTENING + + async def async_initialize(self, from_cache): + """Initialize listener.""" + self._status = ListenerStatus.INITIALIZED + + async def async_configure(self): + """Configure listener.""" + self._status = ListenerStatus.CONFIGURED diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index 2d2a5d76b81..cf2156b76c3 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -5,78 +5,134 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +import logging import time -from homeassistant.helpers import entity from homeassistant.util import slugify +from .entity import ZhaEntity +from .const import LISTENER_BATTERY, SIGNAL_STATE_ATTR + +_LOGGER = logging.getLogger(__name__) + +BATTERY_SIZES = { + 0: 'No battery', + 1: 'Built in', + 2: 'Other', + 3: 'AA', + 4: 'AAA', + 5: 'C', + 6: 'D', + 7: 'CR2', + 8: 'CR123A', + 9: 'CR2450', + 10: 'CR2032', + 11: 'CR1632', + 255: 'Unknown' +} -class ZhaDeviceEntity(entity.Entity): +class ZhaDeviceEntity(ZhaEntity): """A base class for ZHA devices.""" - def __init__(self, device, manufacturer, model, application_listener, - keepalive_interval=7200, **kwargs): + def __init__(self, zha_device, listeners, keepalive_interval=7200, + **kwargs): """Init ZHA endpoint entity.""" - self._device_state_attributes = { - 'nwk': '0x{0:04x}'.format(device.nwk), - 'ieee': str(device.ieee), - 'lqi': device.lqi, - 'rssi': device.rssi, - } - - ieee = device.ieee + ieee = zha_device.ieee ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) - if manufacturer is not None and model is not None: - self._unique_id = "{}_{}_{}".format( - slugify(manufacturer), - slugify(model), + unique_id = None + if zha_device.manufacturer is not None and \ + zha_device.model is not None: + unique_id = "{}_{}_{}".format( + slugify(zha_device.manufacturer), + slugify(zha_device.model), ieeetail, ) - self._device_state_attributes['friendly_name'] = "{} {}".format( - manufacturer, - model, - ) else: - self._unique_id = str(ieeetail) + unique_id = str(ieeetail) + + kwargs['component'] = 'zha' + super().__init__(unique_id, zha_device, listeners, skip_entity_id=True, + **kwargs) - self._device = device - self._state = 'offline' self._keepalive_interval = keepalive_interval - - application_listener.register_entity(ieee, self) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id + self._device_state_attributes.update({ + 'nwk': '0x{0:04x}'.format(zha_device.nwk), + 'ieee': str(zha_device.ieee), + 'lqi': zha_device.lqi, + 'rssi': zha_device.rssi, + }) + self._should_poll = True + self._battery_listener = self.cluster_listeners.get(LISTENER_BATTERY) @property def state(self) -> str: """Return the state of the entity.""" return self._state + @property + def available(self): + """Return True if device is available.""" + return self._zha_device.available + @property def device_state_attributes(self): """Return device specific state attributes.""" update_time = None - if self._device.last_seen is not None and self._state == 'offline': - time_struct = time.localtime(self._device.last_seen) + device = self._zha_device + if device.last_seen is not None and not self.available: + time_struct = time.localtime(device.last_seen) update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct) self._device_state_attributes['last_seen'] = update_time if ('last_seen' in self._device_state_attributes and - self._state != 'offline'): + self.available): del self._device_state_attributes['last_seen'] - self._device_state_attributes['lqi'] = self._device.lqi - self._device_state_attributes['rssi'] = self._device.rssi + self._device_state_attributes['lqi'] = device.lqi + self._device_state_attributes['rssi'] = device.rssi return self._device_state_attributes + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if self._battery_listener: + await self.async_accept_signal( + self._battery_listener, SIGNAL_STATE_ATTR, + self.async_update_state_attribute) + # only do this on add to HA because it is static + await self._async_init_battery_values() + async def async_update(self): """Handle polling.""" - if self._device.last_seen is None: - self._state = 'offline' + if self._zha_device.last_seen is None: + self._zha_device.update_available(False) else: - difference = time.time() - self._device.last_seen + difference = time.time() - self._zha_device.last_seen if difference > self._keepalive_interval: - self._state = 'offline' + self._zha_device.update_available(False) + self._state = None else: + self._zha_device.update_available(True) self._state = 'online' + if self._battery_listener: + await self.async_get_latest_battery_reading() + + async def _async_init_battery_values(self): + """Get initial battery level and battery info from listener cache.""" + battery_size = await self._battery_listener.get_attribute_value( + 'battery_size') + if battery_size is not None: + self._device_state_attributes['battery_size'] = BATTERY_SIZES.get( + battery_size, 'Unknown') + + battery_quantity = await self._battery_listener.get_attribute_value( + 'battery_quantity') + if battery_quantity is not None: + self._device_state_attributes['battery_quantity'] = \ + battery_quantity + await self.async_get_latest_battery_reading() + + async def async_get_latest_battery_reading(self): + """Get the latest battery reading from listeners cache.""" + battery = await self._battery_listener.get_attribute_value( + 'battery_percentage_remaining') + if battery is not None: + self._device_state_attributes['battery_level'] = battery diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index e112e32d592..5a78d91553f 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -4,20 +4,18 @@ Entity 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 random import uniform -from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import callback +import logging + from homeassistant.helpers import entity from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import slugify + from .core.const import ( - 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 .core.helpers import bind_configure_reporting + DOMAIN, ATTR_MANUFACTURER, DATA_ZHA, DATA_ZHA_BRIDGE_ID, MODEL, NAME, + SIGNAL_REMOVE +) _LOGGER = logging.getLogger(__name__) @@ -29,287 +27,155 @@ class ZhaEntity(entity.Entity): _domain = None # Must be overridden by subclasses - def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, - model, application_listener, unique_id, new_join=False, - **kwargs): + def __init__(self, unique_id, zha_device, listeners, + skip_entity_id=False, **kwargs): """Init ZHA entity.""" - self._device_state_attributes = {} - self._name = None - ieee = endpoint.device.ieee - ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) - if manufacturer and model is not None: - self.entity_id = "{}.{}_{}_{}_{}{}".format( - self._domain, - slugify(manufacturer), - slugify(model), - ieeetail, - endpoint.endpoint_id, - kwargs.get(ENTITY_SUFFIX, ''), - ) - self._name = "{} {}".format(manufacturer, model) - else: - self.entity_id = "{}.zha_{}_{}{}".format( - self._domain, - ieeetail, - endpoint.endpoint_id, - kwargs.get(ENTITY_SUFFIX, ''), - ) - - self._endpoint = endpoint - self._in_clusters = in_clusters - self._out_clusters = out_clusters - self._new_join = new_join - self._state = None + self._force_update = False + self._should_poll = False self._unique_id = unique_id - - # Normally the entity itself is the listener. Sub-classes may set this - # to a dict of cluster ID -> listener to receive messages for specific - # clusters separately - self._in_listeners = {} - self._out_listeners = {} - - self._initialized = False - 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 + self._name = None + if zha_device.manufacturer and zha_device.model is not None: + self._name = "{} {}".format( + zha_device.manufacturer, + zha_device.model ) - _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. - - It is now safe to update the entity state - """ - for cluster_id, cluster in self._in_clusters.items(): - cluster.add_listener(self._in_listeners.get(cluster_id, self)) - for cluster_id, cluster in self._out_clusters.items(): - cluster.add_listener(self._out_listeners.get(cluster_id, self)) - - self._endpoint.device.zdo.add_listener(self) - - if self._new_join: - self.hass.async_create_task(self.async_configure()) - - self._initialized = True - - async def async_configure(self): - """Set cluster binding and attribute reporting.""" - for cluster_key, attrs in self.zcl_reporting_config.items(): - cluster = self._get_cluster_from_report_config(cluster_key) - if cluster is None: - continue - - manufacturer = None - if cluster.cluster_id >= 0xfc00 and self.manufacturer_code: - manufacturer = self.manufacturer_code - - skip_bind = False # bind cluster only for the 1st configured attr - for attr, details in attrs.items(): - min_report_interval, max_report_interval, change = details - await bind_configure_reporting( - self.entity_id, cluster, attr, - min_report=min_report_interval, - max_report=max_report_interval, - reportable_change=change, - skip_bind=skip_bind, - manufacturer=manufacturer + if not skip_entity_id: + ieee = zha_device.ieee + ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) + if zha_device.manufacturer and zha_device.model is not None: + self.entity_id = "{}.{}_{}_{}_{}{}".format( + self._domain, + slugify(zha_device.manufacturer), + slugify(zha_device.model), + ieeetail, + listeners[0].cluster.endpoint.endpoint_id, + kwargs.get(ENTITY_SUFFIX, ''), ) - skip_bind = True - await asyncio.sleep(uniform(0.1, 0.5)) - _LOGGER.debug("%s: finished configuration", self.entity_id) - - def _get_cluster_from_report_config(self, cluster_key): - """Parse an entry from zcl_reporting_config dict.""" - from zigpy.zcl import Cluster as Zcl_Cluster - - cluster = None - if isinstance(cluster_key, Zcl_Cluster): - cluster = cluster_key - elif isinstance(cluster_key, str): - cluster = getattr(self._endpoint, cluster_key, None) - elif isinstance(cluster_key, int): - if cluster_key in self._in_clusters: - cluster = self._in_clusters[cluster_key] - elif cluster_key in self._out_clusters: - cluster = self._out_clusters[cluster_key] - elif issubclass(cluster_key, Zcl_Cluster): - cluster_id = cluster_key.cluster_id - if cluster_id in self._in_clusters: - cluster = self._in_clusters[cluster_id] - elif cluster_id in self._out_clusters: - cluster = self._out_clusters[cluster_id] - return cluster + else: + self.entity_id = "{}.zha_{}_{}{}".format( + self._domain, + ieeetail, + listeners[0].cluster.endpoint.endpoint_id, + kwargs.get(ENTITY_SUFFIX, ''), + ) + self._state = None + self._device_state_attributes = {} + self._zha_device = zha_device + self.cluster_listeners = {} + # this will get flipped to false once we enable the feature after the + # reorg is merged + self._available = True + self._component = kwargs['component'] + self._unsubs = [] + for listener in listeners: + self.cluster_listeners[listener.name] = listener @property def name(self): """Return Entity's default name.""" return self._name - @property - def zcl_reporting_config(self): - """Return a dict of ZCL attribute reporting configuration. - - { - Cluster_Class: { - attr_id: (min_report_interval, max_report_interval, change), - attr_name: (min_rep_interval, max_rep_interval, change) - } - Cluster_Instance: { - attr_id: (min_report_interval, max_report_interval, change), - attr_name: (min_rep_interval, max_rep_interval, change) - } - cluster_id: { - attr_id: (min_report_interval, max_report_interval, change), - attr_name: (min_rep_interval, max_rep_interval, change) - } - 'cluster_name': { - attr_id: (min_report_interval, max_report_interval, change), - attr_name: (min_rep_interval, max_rep_interval, change) - } - } - """ - return {} - @property def unique_id(self) -> str: """Return a unique ID.""" return self._unique_id + @property + def zha_device(self): + """Return the zha device this entity is attached to.""" + return self._zha_device + @property def device_state_attributes(self): """Return device specific state attributes.""" return self._device_state_attributes + @property + def force_update(self) -> bool: + """Force update this entity.""" + return self._force_update + @property def should_poll(self) -> bool: - """Let ZHA handle polling.""" - return False - - @callback - def attribute_updated(self, attribute, value): - """Handle an attribute updated on this cluster.""" - pass - - @callback - def zdo_command(self, tsn, command_id, args): - """Handle a ZDO command received on this cluster.""" - pass - - @callback - def device_announce(self, device): - """Handle device_announce zdo event.""" - self.async_schedule_update_ha_state(force_refresh=True) - - @callback - def permit_duration(self, permit_duration): - """Handle permit_duration zdo event.""" - pass + """Poll state from device.""" + return self._should_poll @property def device_info(self): """Return a device description for device registry.""" - ieee = str(self._endpoint.device.ieee) + zha_device_info = self._zha_device.device_info + ieee = zha_device_info['ieee'] return { 'connections': {(CONNECTION_ZIGBEE, ieee)}, 'identifiers': {(DOMAIN, ieee)}, - ATTR_MANUFACTURER: self._endpoint.manufacturer, - 'model': self._endpoint.model, - 'name': self.name or ieee, + ATTR_MANUFACTURER: zha_device_info[ATTR_MANUFACTURER], + MODEL: zha_device_info[MODEL], + NAME: zha_device_info[NAME], 'via_hub': (DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), } - @callback - def zha_send_event(self, cluster, command, args): - """Relay entity events to hass.""" - pass # don't relay events from entities + @property + def available(self): + """Return entity availability.""" + return self._available + + def async_set_available(self, available): + """Set entity availability.""" + self._available = available + self.async_schedule_update_ha_state() + + def async_update_state_attribute(self, key, value): + """Update a single device state attribute.""" + self._device_state_attributes.update({ + key: value + }) + self.async_schedule_update_ha_state() + + def async_set_state(self, state): + """Set the entity state.""" + pass + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + await self.async_accept_signal( + None, "{}_{}".format(self.zha_device.available_signal, 'entity'), + self.async_set_available, + signal_override=True) + await self.async_accept_signal( + None, "{}_{}".format(SIGNAL_REMOVE, str(self.zha_device.ieee)), + self.async_remove, + signal_override=True + ) + self._zha_device.gateway.register_entity_reference( + self._zha_device.ieee, self.entity_id, self._zha_device, + self.cluster_listeners, self.device_info) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + for unsub in self._unsubs: + unsub() + + async def async_update(self): + """Retrieve latest state.""" + for listener in self.cluster_listeners: + if hasattr(listener, 'async_update'): + await listener.async_update() + + async def async_accept_signal(self, listener, signal, func, + signal_override=False): + """Accept a signal from a listener.""" + unsub = None + if signal_override: + unsub = async_dispatcher_connect( + self.hass, + signal, + func + ) + else: + unsub = async_dispatcher_connect( + self.hass, + "{}_{}".format(listener.unique_id, signal), + func + ) + self._unsubs.append(unsub) diff --git a/homeassistant/components/zha/event.py b/homeassistant/components/zha/event.py deleted file mode 100644 index 7828a695a7b..00000000000 --- a/homeassistant/components/zha/event.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Event for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/zha/ -""" -import logging - -from homeassistant.core import EventOrigin, callback -from homeassistant.util import slugify - -_LOGGER = logging.getLogger(__name__) - - -class ZhaEvent(): - """A base class for ZHA events.""" - - def __init__(self, hass, cluster, **kwargs): - """Init ZHA event.""" - self._hass = hass - self._cluster = cluster - cluster.add_listener(self) - ieee = cluster.endpoint.device.ieee - ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) - endpoint = cluster.endpoint - if endpoint.manufacturer and endpoint.model is not None: - self._unique_id = "{}.{}_{}_{}_{}{}".format( - 'zha_event', - slugify(endpoint.manufacturer), - slugify(endpoint.model), - ieeetail, - cluster.endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), - ) - else: - self._unique_id = "{}.zha_{}_{}{}".format( - 'zha_event', - ieeetail, - cluster.endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), - ) - - @callback - def attribute_updated(self, attribute, value): - """Handle an attribute updated on this cluster.""" - pass - - @callback - def zdo_command(self, tsn, command_id, args): - """Handle a ZDO command received on this cluster.""" - pass - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle a cluster command received on this cluster.""" - pass - - @callback - def zha_send_event(self, cluster, command, args): - """Relay entity events to hass.""" - self._hass.bus.async_fire( - 'zha_event', - { - 'unique_id': self._unique_id, - 'command': command, - 'args': args - }, - EventOrigin.remote - ) - - -class ZhaRelayEvent(ZhaEvent): - """Event relay that can be attached to zigbee clusters.""" - - @callback - def attribute_updated(self, attribute, value): - """Handle an attribute updated on this cluster.""" - self.zha_send_event( - self._cluster, - 'attribute_updated', - { - 'attribute_id': attribute, - 'attribute_name': self._cluster.attributes.get( - attribute, - ['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 - ) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index f6dbef50923..dfe3c8cdd23 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -10,9 +10,10 @@ from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, FanEntity) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .core import helpers from .core.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_OP, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, LISTENER_FAN, + SIGNAL_ATTR_UPDATED +) from .entity import ZhaEntity DEPENDENCIES = ['zha'] @@ -79,19 +80,17 @@ class ZhaFan(ZhaEntity, FanEntity): """Representation of a ZHA fan.""" _domain = DOMAIN - value_attribute = 0 # fan_mode - @property - def zcl_reporting_config(self) -> dict: - """Return a dict of attribute reporting configuration.""" - return { - self.cluster: {self.value_attribute: REPORT_CONFIG_OP} - } + def __init__(self, unique_id, zha_device, listeners, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, listeners, **kwargs) + self._fan_listener = self.cluster_listeners.get(LISTENER_FAN) - @property - def cluster(self): - """Fan ZCL Cluster.""" - return self._endpoint.fan + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + await self.async_accept_signal( + self._fan_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) @property def supported_features(self) -> int: @@ -115,6 +114,16 @@ class ZhaFan(ZhaEntity, FanEntity): return False return self._state != SPEED_OFF + @property + def device_state_attributes(self): + """Return state attributes.""" + return self.state_attributes + + def async_set_state(self, state): + """Handle state update from listener.""" + self._state = VALUE_TO_SPEED.get(state, self._state) + self.async_schedule_update_ha_state() + async def async_turn_on(self, speed: str = None, **kwargs) -> None: """Turn the entity on.""" if speed is None: @@ -128,31 +137,5 @@ class ZhaFan(ZhaEntity, FanEntity): async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan.""" - from zigpy.exceptions import DeliveryError - try: - await self._endpoint.fan.write_attributes( - {'fan_mode': SPEED_TO_VALUE[speed]} - ) - except DeliveryError as ex: - _LOGGER.error("%s: Could not set speed: %s", self.entity_id, ex) - return - - self._state = speed - self.async_schedule_update_ha_state() - - async def async_update(self): - """Retrieve latest state.""" - result = await helpers.safe_read(self.cluster, ['fan_mode'], - allow_cache=False, - only_cache=(not self._initialized)) - new_value = result.get('fan_mode', None) - self._state = VALUE_TO_SPEED.get(new_value, None) - - def attribute_updated(self, attribute, value): - """Handle attribute update from device.""" - attr_name = self.cluster.attributes.get(attribute, [attribute])[0] - _LOGGER.debug("%s: Attribute report '%s'[%s] = %s", - self.entity_id, self.cluster.name, attr_name, value) - if attribute == self.value_attribute: - self._state = VALUE_TO_SPEED.get(value, self._state) - self.async_schedule_update_ha_state() + await self._fan_listener.async_set_speed(SPEED_TO_VALUE[speed]) + self.async_set_state(speed) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 49a09112b31..1d1b4c5f921 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -9,14 +9,12 @@ import logging from homeassistant.components import light from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util -from .core import helpers -from .core.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, - REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) +from .const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, LISTENER_COLOR, + LISTENER_ON_OFF, LISTENER_LEVEL, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL + ) from .entity import ZhaEntity -from .core.listeners import ( - OnOffListener, LevelListener -) + _LOGGER = logging.getLogger(__name__) @@ -58,26 +56,6 @@ async def _async_setup_entities(hass, config_entry, async_add_entities, """Set up the ZHA lights.""" entities = [] for discovery_info in discovery_infos: - endpoint = discovery_info['endpoint'] - if hasattr(endpoint, 'light_color'): - caps = await helpers.safe_read( - endpoint.light_color, ['color_capabilities']) - discovery_info['color_capabilities'] = caps.get( - 'color_capabilities') - if discovery_info['color_capabilities'] is None: - # ZCL Version 4 devices don't support the color_capabilities - # attribute. In this version XY support is mandatory, but we - # need to probe to determine if the device supports color - # temperature. - discovery_info['color_capabilities'] = \ - CAPABILITIES_COLOR_XY - result = await helpers.safe_read( - endpoint.light_color, ['color_temperature']) - if (result.get('color_temperature') is not - UNSUPPORTED_ATTRIBUTE): - discovery_info['color_capabilities'] |= \ - CAPABILITIES_COLOR_TEMP - zha_light = Light(**discovery_info) entities.append(zha_light) @@ -89,34 +67,24 @@ class Light(ZhaEntity, light.Light): _domain = light.DOMAIN - def __init__(self, **kwargs): + def __init__(self, unique_id, zha_device, listeners, **kwargs): """Initialize the ZHA light.""" - super().__init__(**kwargs) + super().__init__(unique_id, zha_device, listeners, **kwargs) self._supported_features = 0 self._color_temp = None self._hs_color = None self._brightness = None - from zigpy.zcl.clusters.general import OnOff, LevelControl - self._in_listeners = { - OnOff.cluster_id: OnOffListener( - self, - self._in_clusters[OnOff.cluster_id] - ), - } + self._on_off_listener = self.cluster_listeners.get(LISTENER_ON_OFF) + self._level_listener = self.cluster_listeners.get(LISTENER_LEVEL) + self._color_listener = self.cluster_listeners.get(LISTENER_COLOR) - if LevelControl.cluster_id in self._in_clusters: + if self._level_listener: self._supported_features |= light.SUPPORT_BRIGHTNESS self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 - self._in_listeners.update({ - LevelControl.cluster_id: LevelListener( - self, - self._in_clusters[LevelControl.cluster_id] - ) - }) - import zigpy.zcl.clusters as zcl_clusters - if zcl_clusters.lighting.Color.cluster_id in self._in_clusters: - color_capabilities = kwargs['color_capabilities'] + + if self._color_listener: + color_capabilities = self._color_listener.get_color_capabilities() if color_capabilities & CAPABILITIES_COLOR_TEMP: self._supported_features |= light.SUPPORT_COLOR_TEMP @@ -124,131 +92,28 @@ class Light(ZhaEntity, light.Light): self._supported_features |= light.SUPPORT_COLOR self._hs_color = (0, 0) - @property - def zcl_reporting_config(self) -> dict: - """Return attribute reporting configuration.""" - return { - 'on_off': {'on_off': REPORT_CONFIG_IMMEDIATE}, - 'level': {'current_level': REPORT_CONFIG_ASAP}, - 'light_color': { - 'current_x': REPORT_CONFIG_DEFAULT, - 'current_y': REPORT_CONFIG_DEFAULT, - 'color_temperature': REPORT_CONFIG_DEFAULT, - } - } - @property def is_on(self) -> bool: """Return true if entity is on.""" if self._state is None: return False - return bool(self._state) - - def set_state(self, state): - """Set the state.""" - self._state = state - self.async_schedule_update_ha_state() - - async def async_turn_on(self, **kwargs): - """Turn the entity on.""" - from zigpy.exceptions import DeliveryError - - duration = kwargs.get(light.ATTR_TRANSITION, DEFAULT_DURATION) - duration = duration * 10 # tenths of s - if light.ATTR_COLOR_TEMP in kwargs and \ - self.supported_features & light.SUPPORT_COLOR_TEMP: - temperature = kwargs[light.ATTR_COLOR_TEMP] - try: - res = await self._endpoint.light_color.move_to_color_temp( - temperature, duration) - _LOGGER.debug("%s: moved to %i color temp: %s", - self.entity_id, temperature, res) - except DeliveryError as ex: - _LOGGER.error("%s: Couldn't change color temp: %s", - self.entity_id, ex) - return - self._color_temp = temperature - - if light.ATTR_HS_COLOR in kwargs and \ - self.supported_features & light.SUPPORT_COLOR: - self._hs_color = kwargs[light.ATTR_HS_COLOR] - xy_color = color_util.color_hs_to_xy(*self._hs_color) - try: - res = await self._endpoint.light_color.move_to_color( - int(xy_color[0] * 65535), - int(xy_color[1] * 65535), - duration, - ) - _LOGGER.debug("%s: moved XY color to (%1.2f, %1.2f): %s", - self.entity_id, xy_color[0], xy_color[1], res) - except DeliveryError as ex: - _LOGGER.error("%s: Couldn't change color temp: %s", - self.entity_id, ex) - return - - if self._brightness is not None: - brightness = kwargs.get( - light.ATTR_BRIGHTNESS, self._brightness or 255) - # Move to level with on/off: - try: - res = await self._endpoint.level.move_to_level_with_on_off( - brightness, - duration - ) - _LOGGER.debug("%s: moved to %i level with on/off: %s", - self.entity_id, brightness, res) - except DeliveryError as ex: - _LOGGER.error("%s: Couldn't change brightness level: %s", - self.entity_id, ex) - return - self._state = 1 - self._brightness = brightness - self.async_schedule_update_ha_state() - return - - try: - res = await self._endpoint.on_off.on() - _LOGGER.debug("%s was turned on: %s", self.entity_id, res) - except DeliveryError as ex: - _LOGGER.error("%s: Unable to turn the light on: %s", - self.entity_id, ex) - return - - self._state = 1 - self.async_schedule_update_ha_state() - - async def async_turn_off(self, **kwargs): - """Turn the entity off.""" - from zigpy.exceptions import DeliveryError - duration = kwargs.get(light.ATTR_TRANSITION) - try: - supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS - if duration and supports_level: - res = await self._endpoint.level.move_to_level_with_on_off( - 0, duration*10 - ) - else: - res = await self._endpoint.on_off.off() - _LOGGER.debug("%s was turned off: %s", self.entity_id, res) - except DeliveryError as ex: - _LOGGER.error("%s: Unable to turn the light off: %s", - self.entity_id, ex) - return - - self._state = 0 - self.async_schedule_update_ha_state() + return self._state @property def brightness(self): - """Return the brightness of this light between 0..255.""" + """Return the brightness of this light.""" return self._brightness + @property + def device_state_attributes(self): + """Return state attributes.""" + return self.state_attributes + def set_level(self, value): """Set the brightness of this light between 0..255.""" - if value < 0 or value > 255: - return + value = max(0, min(255, value)) self._brightness = value - self.async_schedule_update_ha_state() + self.async_set_state(value) @property def hs_color(self): @@ -265,40 +130,82 @@ class Light(ZhaEntity, light.Light): """Flag supported features.""" return self._supported_features - async def async_update(self): - """Retrieve latest state.""" - result = await helpers.safe_read(self._endpoint.on_off, ['on_off'], - allow_cache=False, - only_cache=(not self._initialized)) - self._state = result.get('on_off', self._state) + def async_set_state(self, state): + """Set the state.""" + self._state = bool(state) + self.async_schedule_update_ha_state() - if self._supported_features & light.SUPPORT_BRIGHTNESS: - result = await helpers.safe_read(self._endpoint.level, - ['current_level'], - allow_cache=False, - only_cache=( - not self._initialized - )) - self._brightness = result.get('current_level', self._brightness) + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + await self.async_accept_signal( + self._on_off_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) + if self._level_listener: + await self.async_accept_signal( + self._level_listener, SIGNAL_SET_LEVEL, self.set_level) - if self._supported_features & light.SUPPORT_COLOR_TEMP: - result = await helpers.safe_read(self._endpoint.light_color, - ['color_temperature'], - allow_cache=False, - only_cache=( - not self._initialized - )) - self._color_temp = result.get('color_temperature', - self._color_temp) + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + duration = kwargs.get(light.ATTR_TRANSITION, DEFAULT_DURATION) + duration = duration * 10 # tenths of s - if self._supported_features & light.SUPPORT_COLOR: - result = await helpers.safe_read(self._endpoint.light_color, - ['current_x', 'current_y'], - allow_cache=False, - only_cache=( - not self._initialized - )) - if 'current_x' in result and 'current_y' in result: - xy_color = (round(result['current_x']/65535, 3), - round(result['current_y']/65535, 3)) - self._hs_color = color_util.color_xy_to_hs(*xy_color) + if light.ATTR_COLOR_TEMP in kwargs and \ + self.supported_features & light.SUPPORT_COLOR_TEMP: + temperature = kwargs[light.ATTR_COLOR_TEMP] + success = await self._color_listener.move_to_color_temp( + temperature, duration) + if not success: + return + self._color_temp = temperature + + if light.ATTR_HS_COLOR in kwargs and \ + self.supported_features & light.SUPPORT_COLOR: + hs_color = kwargs[light.ATTR_HS_COLOR] + xy_color = color_util.color_hs_to_xy(*hs_color) + success = await self._color_listener.move_to_color( + int(xy_color[0] * 65535), + int(xy_color[1] * 65535), + duration, + ) + if not success: + return + self._hs_color = hs_color + + if self._brightness is not None: + brightness = kwargs.get( + light.ATTR_BRIGHTNESS, self._brightness or 255) + success = await self._level_listener.move_to_level_with_on_off( + brightness, + duration + ) + if not success: + return + self._state = True + self._brightness = brightness + self.async_schedule_update_ha_state() + return + + success = await self._on_off_listener.on() + if not success: + return + + self._state = True + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + duration = kwargs.get(light.ATTR_TRANSITION) + supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS + success = None + if duration and supports_level: + success = await self._level_listener.move_to_level_with_on_off( + 0, + duration*10 + ) + else: + success = await self._on_off_listener.off() + _LOGGER.debug("%s was turned off: %s", self.entity_id, success) + if not success: + return + self._state = False + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index ae45fad0826..ad566df00f4 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -9,11 +9,11 @@ import logging from homeassistant.components.sensor import DOMAIN from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util.temperature import convert as convert_temperature -from .core import helpers from .core.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_MIN_INT, REPORT_CONFIG_RPT_CHANGE, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE, + ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, + POWER_CONFIGURATION, GENERIC, SENSOR_TYPE, LISTENER_ATTRIBUTE, + LISTENER_ACTIVE_POWER, SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR) from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) @@ -21,6 +21,73 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['zha'] +# Formatter functions +def pass_through_formatter(value): + """No op update function.""" + return value + + +def temperature_formatter(value): + """Convert temperature data.""" + if value is None: + return None + return round(value / 100, 1) + + +def humidity_formatter(value): + """Return the state of the entity.""" + if value is None: + return None + return round(float(value) / 100, 1) + + +def active_power_formatter(value): + """Return the state of the entity.""" + if value is None: + return None + return round(float(value) / 10, 1) + + +def pressure_formatter(value): + """Return the state of the entity.""" + if value is None: + return None + + return round(float(value)) + + +FORMATTER_FUNC_REGISTRY = { + HUMIDITY: humidity_formatter, + TEMPERATURE: temperature_formatter, + PRESSURE: pressure_formatter, + ELECTRICAL_MEASUREMENT: active_power_formatter, + GENERIC: pass_through_formatter, +} + +UNIT_REGISTRY = { + HUMIDITY: '%', + TEMPERATURE: TEMP_CELSIUS, + PRESSURE: 'hPa', + ILLUMINANCE: 'lx', + METERING: 'W', + ELECTRICAL_MEASUREMENT: 'W', + POWER_CONFIGURATION: '%', + GENERIC: None +} + +LISTENER_REGISTRY = { + ELECTRICAL_MEASUREMENT: LISTENER_ACTIVE_POWER, +} + +POLLING_REGISTRY = { + ELECTRICAL_MEASUREMENT: True +} + +FORCE_UPDATE_REGISTRY = { + ELECTRICAL_MEASUREMENT: True +} + + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up Zigbee Home Automation sensors.""" @@ -56,279 +123,59 @@ async def _async_setup_entities(hass, config_entry, async_add_entities, async def make_sensor(discovery_info): """Create ZHA sensors factory.""" - from zigpy.zcl.clusters.measurement import ( - RelativeHumidity, TemperatureMeasurement, PressureMeasurement, - IlluminanceMeasurement - ) - from zigpy.zcl.clusters.smartenergy import Metering - from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement - from zigpy.zcl.clusters.general import PowerConfiguration - in_clusters = discovery_info['in_clusters'] - if 'sub_component' in discovery_info: - sensor = discovery_info['sub_component'](**discovery_info) - elif RelativeHumidity.cluster_id in in_clusters: - sensor = RelativeHumiditySensor(**discovery_info) - elif PowerConfiguration.cluster_id in in_clusters: - sensor = GenericBatterySensor(**discovery_info) - elif TemperatureMeasurement.cluster_id in in_clusters: - sensor = TemperatureSensor(**discovery_info) - elif PressureMeasurement.cluster_id in in_clusters: - sensor = PressureSensor(**discovery_info) - elif IlluminanceMeasurement.cluster_id in in_clusters: - sensor = IlluminanceMeasurementSensor(**discovery_info) - elif Metering.cluster_id in in_clusters: - sensor = MeteringSensor(**discovery_info) - elif ElectricalMeasurement.cluster_id in in_clusters: - sensor = ElectricalMeasurementSensor(**discovery_info) - return sensor - else: - sensor = Sensor(**discovery_info) - - return sensor + return Sensor(**discovery_info) class Sensor(ZhaEntity): """Base ZHA sensor.""" _domain = DOMAIN - value_attribute = 0 - min_report_interval = REPORT_CONFIG_MIN_INT - max_report_interval = REPORT_CONFIG_MAX_INT - min_reportable_change = REPORT_CONFIG_RPT_CHANGE - report_config = (min_report_interval, max_report_interval, - min_reportable_change) - def __init__(self, **kwargs): - """Init ZHA Sensor instance.""" - super().__init__(**kwargs) - self._cluster = list(kwargs['in_clusters'].values())[0] + def __init__(self, unique_id, zha_device, listeners, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, listeners, **kwargs) + sensor_type = kwargs.get(SENSOR_TYPE, GENERIC) + self._unit = UNIT_REGISTRY.get(sensor_type) + self._formatter_function = FORMATTER_FUNC_REGISTRY.get( + sensor_type, + pass_through_formatter + ) + self._force_update = FORCE_UPDATE_REGISTRY.get( + sensor_type, + False + ) + self._should_poll = POLLING_REGISTRY.get( + sensor_type, + False + ) + self._listener = self.cluster_listeners.get( + LISTENER_REGISTRY.get(sensor_type, LISTENER_ATTRIBUTE) + ) + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + await self.async_accept_signal( + self._listener, SIGNAL_ATTR_UPDATED, self.async_set_state) + await self.async_accept_signal( + self._listener, SIGNAL_STATE_ATTR, + self.async_update_state_attribute) @property - def zcl_reporting_config(self) -> dict: - """Return a dict of attribute reporting configuration.""" - return { - self.cluster: {self.value_attribute: self.report_config} - } - - @property - def cluster(self): - """Return Sensor's cluster.""" - return self._cluster + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit @property def state(self) -> str: """Return the state of the entity.""" + if self._state is None: + return None if isinstance(self._state, float): return str(round(self._state, 2)) return self._state - def attribute_updated(self, attribute, value): - """Handle attribute update from device.""" - _LOGGER.debug("Attribute updated: %s %s %s", self, attribute, value) - if attribute == self.value_attribute: - self._state = value - self.async_schedule_update_ha_state() - - async def async_update(self): - """Retrieve latest state.""" - result = await helpers.safe_read( - self.cluster, - [self.value_attribute], - allow_cache=False, - only_cache=(not self._initialized) - ) - self._state = result.get(self.value_attribute, self._state) - - -class GenericBatterySensor(Sensor): - """ZHA generic battery sensor.""" - - report_attribute = 32 - value_attribute = 33 - battery_sizes = { - 0: 'No battery', - 1: 'Built in', - 2: 'Other', - 3: 'AA', - 4: 'AAA', - 5: 'C', - 6: 'D', - 7: 'CR2', - 8: 'CR123A', - 9: 'CR2450', - 10: 'CR2032', - 11: 'CR1632', - 255: 'Unknown' - } - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return '%' - - @property - def zcl_reporting_config(self) -> dict: - """Return a dict of attribute reporting configuration.""" - return { - self.cluster: { - self.value_attribute: self.report_config, - self.report_attribute: self.report_config - } - } - - async def async_update(self): - """Retrieve latest state.""" - _LOGGER.debug("%s async_update", self.entity_id) - - result = await helpers.safe_read( - self._endpoint.power, - [ - 'battery_size', - 'battery_quantity', - 'battery_percentage_remaining' - ], - allow_cache=False, - only_cache=(not self._initialized) - ) - self._device_state_attributes['battery_size'] = self.battery_sizes.get( - result.get('battery_size', 255), 'Unknown') - self._device_state_attributes['battery_quantity'] = result.get( - 'battery_quantity', 'Unknown') - self._state = result.get('battery_percentage_remaining', self._state) - - @property - def state(self): - """Return the state of the entity.""" - if self._state == 'unknown' or self._state is None: - return None - - return self._state - - -class TemperatureSensor(Sensor): - """ZHA temperature sensor.""" - - min_reportable_change = 50 # 0.5'C - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self.hass.config.units.temperature_unit - - @property - def state(self): - """Return the state of the entity.""" - if self._state is None: - return None - celsius = self._state / 100 - return round(convert_temperature(celsius, - TEMP_CELSIUS, - self.unit_of_measurement), - 1) - - -class RelativeHumiditySensor(Sensor): - """ZHA relative humidity sensor.""" - - min_reportable_change = 50 # 0.5% - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return '%' - - @property - def state(self): - """Return the state of the entity.""" - if self._state is None: - return None - - return round(float(self._state) / 100, 1) - - -class PressureSensor(Sensor): - """ZHA pressure sensor.""" - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return 'hPa' - - @property - def state(self): - """Return the state of the entity.""" - if self._state is None: - return None - - return round(float(self._state)) - - -class IlluminanceMeasurementSensor(Sensor): - """ZHA lux sensor.""" - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return 'lx' - - @property - def state(self): - """Return the state of the entity.""" - return self._state - - -class MeteringSensor(Sensor): - """ZHA Metering sensor.""" - - value_attribute = 1024 - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return 'W' - - @property - def state(self): - """Return the state of the entity.""" - if self._state is None: - return None - - return round(float(self._state)) - - -class ElectricalMeasurementSensor(Sensor): - """ZHA Electrical Measurement sensor.""" - - value_attribute = 1291 - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return 'W' - - @property - def force_update(self) -> bool: - """Force update this entity.""" - return True - - @property - def state(self): - """Return the state of the entity.""" - if self._state is None: - return None - - return round(float(self._state) / 10, 1) - - @property - def should_poll(self) -> bool: - """Poll state from device.""" - return True - - async def async_update(self): - """Retrieve latest state.""" - _LOGGER.debug("%s async_update", self.entity_id) - - result = await helpers.safe_read( - self.cluster, ['active_power'], - allow_cache=False, only_cache=(not self._initialized)) - self._state = result.get('active_power', self._state) + def async_set_state(self, state): + """Handle state update from listener.""" + self._state = self._formatter_function(state) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 09c20acd088..4eee3d5da35 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -8,9 +8,10 @@ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .core import helpers from .core.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, LISTENER_ON_OFF, + SIGNAL_ATTR_UPDATED +) from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) @@ -55,69 +56,39 @@ class Switch(ZhaEntity, SwitchDevice): """ZHA switch.""" _domain = DOMAIN - value_attribute = 0 - def attribute_updated(self, attribute, value): - """Handle attribute update from device.""" - cluster = self._endpoint.on_off - attr_name = cluster.attributes.get(attribute, [attribute])[0] - _LOGGER.debug("%s: Attribute '%s' on cluster '%s' updated to %s", - self.entity_id, attr_name, cluster.ep_attribute, value) - if attribute == self.value_attribute: - self._state = value - self.async_schedule_update_ha_state() - - @property - def zcl_reporting_config(self) -> dict: - """Retrun a dict of attribute reporting configuration.""" - return { - self.cluster: {'on_off': REPORT_CONFIG_IMMEDIATE} - } - - @property - def cluster(self): - """Entity's cluster.""" - return self._endpoint.on_off + def __init__(self, **kwargs): + """Initialize the ZHA switch.""" + super().__init__(**kwargs) + self._on_off_listener = self.cluster_listeners.get(LISTENER_ON_OFF) @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" if self._state is None: return False - return bool(self._state) + return self._state async def async_turn_on(self, **kwargs): """Turn the entity on.""" - from zigpy.exceptions import DeliveryError - try: - res = await self._endpoint.on_off.on() - _LOGGER.debug("%s: turned 'on': %s", self.entity_id, res[1]) - except DeliveryError as ex: - _LOGGER.error("%s: Unable to turn the switch on: %s", - self.entity_id, ex) - return - - self._state = 1 - self.async_schedule_update_ha_state() + await self._on_off_listener.on() async def async_turn_off(self, **kwargs): """Turn the entity off.""" - from zigpy.exceptions import DeliveryError - try: - res = await self._endpoint.on_off.off() - _LOGGER.debug("%s: turned 'off': %s", self.entity_id, res[1]) - except DeliveryError as ex: - _LOGGER.error("%s: Unable to turn the switch off: %s", - self.entity_id, ex) - return + await self._on_off_listener.off() - self._state = 0 + def async_set_state(self, state): + """Handle state update from listener.""" + self._state = bool(state) self.async_schedule_update_ha_state() - async def async_update(self): - """Retrieve latest state.""" - result = await helpers.safe_read(self.cluster, - ['on_off'], - allow_cache=False, - only_cache=(not self._initialized)) - self._state = result.get('on_off', self._state) + @property + def device_state_attributes(self): + """Return state attributes.""" + return self.state_attributes + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + await self.async_accept_signal( + self._on_off_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 624c6a02964..c806b1a2217 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -3,9 +3,12 @@ from unittest.mock import patch import pytest from homeassistant import config_entries from homeassistant.components.zha.core.const import ( - DOMAIN, DATA_ZHA + DOMAIN, DATA_ZHA, COMPONENTS ) from homeassistant.components.zha.core.gateway import ZHAGateway +from homeassistant.components.zha.core.gateway import establish_device_mappings +from homeassistant.components.zha.core.listeners \ + import populate_listener_registry from .common import async_setup_entry @@ -25,6 +28,12 @@ def zha_gateway_fixture(hass): Create a ZHAGateway object that can be used to interact with as if we had a real zigbee network running. """ + populate_listener_registry() + establish_device_mappings() + for component in COMPONENTS: + hass.data[DATA_ZHA][component] = ( + hass.data[DATA_ZHA].get(component, {}) + ) return ZHAGateway(hass, {}) diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 7c0c8b5350f..ba723987042 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -48,6 +48,7 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): # load up binary_sensor domain await hass.config_entries.async_forward_entry_setup( config_entry, DOMAIN) + await zha_gateway.accept_zigbee_messages({}) await hass.async_block_till_done() # on off binary_sensor diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index c19225bf310..14da94bdf52 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -26,6 +26,7 @@ async def test_fan(hass, config_entry, zha_gateway): # load up fan domain await hass.config_entries.async_forward_entry_setup( config_entry, DOMAIN) + await zha_gateway.accept_zigbee_messages({}) await hass.async_block_till_done() cluster = zigpy_device.endpoints.get(1).fan diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index d9063b4885a..e94e53c293d 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -40,6 +40,7 @@ async def test_light(hass, config_entry, zha_gateway): # load up light domain await hass.config_entries.async_forward_entry_setup( config_entry, DOMAIN) + await zha_gateway.accept_zigbee_messages({}) await hass.async_block_till_done() # on off light diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 3933f416e3d..18d2e152beb 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -92,6 +92,7 @@ async def async_build_devices(hass, zha_gateway, config_entry, cluster_ids): # load up sensor domain await hass.config_entries.async_forward_entry_setup( config_entry, DOMAIN) + await zha_gateway.accept_zigbee_messages({}) await hass.async_block_till_done() # put the other relevant info in the device info dict diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index d3415bde59b..4e6ff6da6ba 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -24,6 +24,7 @@ async def test_switch(hass, config_entry, zha_gateway): # load up switch domain await hass.config_entries.async_forward_entry_setup( config_entry, DOMAIN) + await zha_gateway.accept_zigbee_messages({}) await hass.async_block_till_done() cluster = zigpy_device.endpoints.get(1).on_off @@ -44,6 +45,7 @@ async def test_switch(hass, config_entry, zha_gateway): await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + # turn on from HA with patch( 'zigpy.zcl.Cluster.request', return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): @@ -55,6 +57,7 @@ async def test_switch(hass, config_entry, zha_gateway): assert cluster.request.call_args == call( False, ON, (), expect_reply=True, manufacturer=None) + # turn off from HA with patch( 'zigpy.zcl.Cluster.request', return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): @@ -66,5 +69,6 @@ async def test_switch(hass, config_entry, zha_gateway): assert cluster.request.call_args == call( False, OFF, (), expect_reply=True, manufacturer=None) + # test joining a new switch to the network and HA await async_test_device_join( hass, zha_gateway, OnOff.cluster_id, DOMAIN, expected_state=STATE_OFF)