diff --git a/.coveragerc b/.coveragerc index 2a6446092e5..7fa418f0b46 100644 --- a/.coveragerc +++ b/.coveragerc @@ -400,6 +400,8 @@ omit = homeassistant/components/zha/__init__.py homeassistant/components/zha/const.py + homeassistant/components/zha/entities/* + homeassistant/components/zha/helpers.py homeassistant/components/*/zha.py homeassistant/components/zigbee.py diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index 9365ba42cc1..c1ced3766c9 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -7,7 +7,8 @@ at https://home-assistant.io/components/binary_sensor.zha/ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice -from homeassistant.components import zha +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers _LOGGER = logging.getLogger(__name__) @@ -27,7 +28,7 @@ CLASS_MAPPING = { async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Zigbee Home Automation binary sensors.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return @@ -72,13 +73,13 @@ async def _async_setup_remote(hass, config, async_add_entities, out_clusters = discovery_info['out_clusters'] if OnOff.cluster_id in out_clusters: cluster = out_clusters[OnOff.cluster_id] - await zha.configure_reporting( + await helpers.configure_reporting( remote.entity_id, cluster, 0, min_report=0, max_report=600, reportable_change=1 ) if LevelControl.cluster_id in out_clusters: cluster = out_clusters[LevelControl.cluster_id] - await zha.configure_reporting( + await helpers.configure_reporting( remote.entity_id, cluster, 0, min_report=1, max_report=600, reportable_change=1 ) @@ -86,7 +87,7 @@ async def _async_setup_remote(hass, config, async_add_entities, async_add_entities([remote], update_before_add=True) -class BinarySensor(zha.Entity, BinarySensorDevice): +class BinarySensor(ZhaEntity, BinarySensorDevice): """The ZHA Binary Sensor.""" _domain = DOMAIN @@ -130,16 +131,16 @@ class BinarySensor(zha.Entity, BinarySensorDevice): """Retrieve latest state.""" from zigpy.types.basic import uint16_t - result = await zha.safe_read(self._endpoint.ias_zone, - ['zone_status'], - allow_cache=False, - only_cache=(not self._initialized)) + 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(zha.Entity, BinarySensorDevice): +class Remote(ZhaEntity, BinarySensorDevice): """ZHA switch/remote controller/button.""" _domain = DOMAIN @@ -252,7 +253,7 @@ class Remote(zha.Entity, BinarySensorDevice): async def async_update(self): """Retrieve latest state.""" from zigpy.zcl.clusters.general import OnOff - result = await zha.safe_read( + result = await helpers.safe_read( self._endpoint.out_clusters[OnOff.cluster_id], ['on_off'], allow_cache=False, diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index b5615f18d73..d948ba2ff5b 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -5,7 +5,8 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/fan.zha/ """ import logging -from homeassistant.components import zha +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers from homeassistant.components.fan import ( DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) @@ -40,14 +41,14 @@ SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)} async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Zigbee Home Automation fans.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return async_add_entities([ZhaFan(**discovery_info)], update_before_add=True) -class ZhaFan(zha.Entity, FanEntity): +class ZhaFan(ZhaEntity, FanEntity): """Representation of a ZHA fan.""" _domain = DOMAIN @@ -101,9 +102,9 @@ class ZhaFan(zha.Entity, FanEntity): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.fan, ['fan_mode'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.fan, ['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) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 56a1e9e5169..20c9faf2514 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -5,7 +5,9 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/light.zha/ """ import logging -from homeassistant.components import light, zha +from homeassistant.components import light +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -23,13 +25,13 @@ UNSUPPORTED_ATTRIBUTE = 0x86 async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Zigbee Home Automation lights.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return endpoint = discovery_info['endpoint'] if hasattr(endpoint, 'light_color'): - caps = await zha.safe_read( + 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: @@ -37,7 +39,7 @@ async def async_setup_platform(hass, config, async_add_entities, # 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 zha.safe_read( + 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 @@ -45,7 +47,7 @@ async def async_setup_platform(hass, config, async_add_entities, async_add_entities([Light(**discovery_info)], update_before_add=True) -class Light(zha.Entity, light.Light): +class Light(ZhaEntity, light.Light): """Representation of a ZHA or ZLL light.""" _domain = light.DOMAIN @@ -181,31 +183,37 @@ class Light(zha.Entity, light.Light): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.on_off, ['on_off'], - allow_cache=False, - only_cache=(not self._initialized)) + 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) if self._supported_features & light.SUPPORT_BRIGHTNESS: - result = await zha.safe_read(self._endpoint.level, - ['current_level'], - allow_cache=False, - only_cache=(not self._initialized)) + 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) if self._supported_features & light.SUPPORT_COLOR_TEMP: - result = await zha.safe_read(self._endpoint.light_color, - ['color_temperature'], - allow_cache=False, - only_cache=(not self._initialized)) + 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) if self._supported_features & light.SUPPORT_COLOR: - result = await zha.safe_read(self._endpoint.light_color, - ['current_x', 'current_y'], - allow_cache=False, - only_cache=(not self._initialized)) + 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)) diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 9a9de0d6cf2..993b247a439 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -7,7 +7,8 @@ at https://home-assistant.io/components/sensor.zha/ import logging from homeassistant.components.sensor import DOMAIN -from homeassistant.components import zha +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers from homeassistant.const import TEMP_CELSIUS from homeassistant.util.temperature import convert as convert_temperature @@ -19,7 +20,7 @@ DEPENDENCIES = ['zha'] async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up Zigbee Home Automation sensors.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return @@ -56,7 +57,7 @@ async def make_sensor(discovery_info): if discovery_info['new_join']: cluster = list(in_clusters.values())[0] - await zha.configure_reporting( + await helpers.configure_reporting( sensor.entity_id, cluster, sensor.value_attribute, reportable_change=sensor.min_reportable_change ) @@ -64,7 +65,7 @@ async def make_sensor(discovery_info): return sensor -class Sensor(zha.Entity): +class Sensor(ZhaEntity): """Base ZHA sensor.""" _domain = DOMAIN @@ -92,7 +93,7 @@ class Sensor(zha.Entity): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read( + result = await helpers.safe_read( list(self._in_clusters.values())[0], [self.value_attribute], allow_cache=False, @@ -224,7 +225,7 @@ class ElectricalMeasurementSensor(Sensor): """Retrieve latest state.""" _LOGGER.debug("%s async_update", self.entity_id) - result = await zha.safe_read( + result = await helpers.safe_read( self._endpoint.electrical_measurement, ['active_power'], allow_cache=False, only_cache=(not self._initialized)) self._state = result.get('active_power', self._state) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 68a94cc1ca5..b184d7baa5c 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -7,7 +7,8 @@ at https://home-assistant.io/components/switch.zha/ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.components import zha +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers _LOGGER = logging.getLogger(__name__) @@ -19,7 +20,7 @@ async def async_setup_platform(hass, config, async_add_entities, """Set up the Zigbee Home Automation switches.""" from zigpy.zcl.clusters.general import OnOff - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return @@ -28,7 +29,7 @@ async def async_setup_platform(hass, config, async_add_entities, if discovery_info['new_join']: in_clusters = discovery_info['in_clusters'] cluster = in_clusters[OnOff.cluster_id] - await zha.configure_reporting( + await helpers.configure_reporting( switch.entity_id, cluster, switch.value_attribute, min_report=0, max_report=600, reportable_change=1 ) @@ -36,7 +37,7 @@ async def async_setup_platform(hass, config, async_add_entities, async_add_entities([switch], update_before_add=True) -class Switch(zha.Entity, SwitchDevice): +class Switch(ZhaEntity, SwitchDevice): """ZHA switch.""" _domain = DOMAIN @@ -94,8 +95,8 @@ class Switch(zha.Entity, SwitchDevice): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.on_off, - ['on_off'], - allow_cache=False, - only_cache=(not self._initialized)) + 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) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 228e589ab01..e54b7f7f657 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -7,15 +7,15 @@ https://home-assistant.io/components/zha/ import collections import enum import logging -import time import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant import const as ha_const -from homeassistant.helpers import discovery, entity -from homeassistant.util import slugify +from homeassistant.helpers import discovery from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.zha.entities import ZhaDeviceEntity +from . import const as zha_const REQUIREMENTS = [ 'bellows==0.7.0', @@ -145,6 +145,7 @@ class ApplicationListener: self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._device_registry = collections.defaultdict(list) hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {}) + zha_const.populate_data() def device_joined(self, device): """Handle device joined. @@ -177,8 +178,6 @@ class ApplicationListener: async def async_device_initialized(self, device, join): """Handle device joined and basic information discovered (async).""" import zigpy.profiles - import homeassistant.components.zha.const as zha_const - zha_const.populate_data() device_manufacturer = device_model = None @@ -276,7 +275,6 @@ class ApplicationListener: device_classes, discovery_attr, is_new_join): """Try to set up an entity from a "bare" cluster.""" - import homeassistant.components.zha.const as zha_const if cluster.cluster_id in profile_clusters: return @@ -320,226 +318,3 @@ class ApplicationListener: {'discovery_key': cluster_key}, self._config, ) - - -class Entity(entity.Entity): - """A base class for ZHA entities.""" - - _domain = None # Must be overridden by subclasses - - def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, - model, application_listener, unique_id, **kwargs): - """Init ZHA entity.""" - self._device_state_attributes = {} - 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._device_state_attributes['friendly_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._state = None - 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 - application_listener.register_entity(ieee, self) - - 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._initialized = True - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - return self._device_state_attributes - - def attribute_updated(self, attribute, value): - """Handle an attribute updated on this cluster.""" - pass - - def zdo_command(self, tsn, command_id, args): - """Handle a ZDO command received on this cluster.""" - pass - - -class ZhaDeviceEntity(entity.Entity): - """A base class for ZHA devices.""" - - def __init__(self, device, manufacturer, model, application_listener, - 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 - 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), - ieeetail, - ) - self._device_state_attributes['friendly_name'] = "{} {}".format( - manufacturer, - model, - ) - else: - self._unique_id = str(ieeetail) - - 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 - - @property - def state(self) -> str: - """Return the state of the entity.""" - return self._state - - @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) - 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'): - del self._device_state_attributes['last_seen'] - self._device_state_attributes['lqi'] = self._device.lqi - self._device_state_attributes['rssi'] = self._device.rssi - return self._device_state_attributes - - async def async_update(self): - """Handle polling.""" - if self._device.last_seen is None: - self._state = 'offline' - else: - difference = time.time() - self._device.last_seen - if difference > self._keepalive_interval: - self._state = 'offline' - else: - self._state = 'online' - - -def get_discovery_info(hass, discovery_info): - """Get the full discovery info for a device. - - Some of the info that needs to be passed to platforms is not JSON - serializable, so it cannot be put in the discovery_info dictionary. This - component places that info we need to pass to the platform in hass.data, - and this function is a helper for platforms to retrieve the complete - discovery info. - """ - if discovery_info is None: - return - - discovery_key = discovery_info.get('discovery_key', None) - all_discovery_info = hass.data.get(DISCOVERY_KEY, {}) - return all_discovery_info.get(discovery_key, None) - - -async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): - """Swallow all exceptions from network read. - - If we throw during initialization, setup fails. Rather have an entity that - exists, but is in a maybe wrong state, than no entity. This method should - probably only be used during initialization. - """ - try: - result, _ = await cluster.read_attributes( - attributes, - allow_cache=allow_cache, - only_cache=only_cache - ) - return result - except Exception: # pylint: disable=broad-except - return {} - - -async def configure_reporting(entity_id, cluster, attr, skip_bind=False, - min_report=300, max_report=900, - reportable_change=1): - """Configure attribute reporting for a cluster. - - while swallowing the DeliverError exceptions in case of unreachable - devices. - """ - from zigpy.exceptions import DeliveryError - - attr_name = cluster.attributes.get(attr, [attr])[0] - cluster_name = cluster.ep_attribute - if not skip_bind: - try: - res = await cluster.bind() - _LOGGER.debug( - "%s: bound '%s' cluster: %s", entity_id, cluster_name, res[0] - ) - except DeliveryError as ex: - _LOGGER.debug( - "%s: Failed to bind '%s' cluster: %s", - entity_id, cluster_name, str(ex) - ) - - try: - res = await cluster.configure_reporting(attr, min_report, - max_report, reportable_change) - _LOGGER.debug( - "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", - entity_id, attr_name, cluster_name, min_report, max_report, - reportable_change, res - ) - except DeliveryError as ex: - _LOGGER.debug( - "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", - entity_id, attr_name, cluster_name, str(ex) - ) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 88dee57aa70..2a7e35ff517 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -1,5 +1,6 @@ """All constants related to the ZHA component.""" +DISCOVERY_KEY = 'zha_discovery_info' DEVICE_CLASS = {} SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {} @@ -17,7 +18,12 @@ def populate_data(): from zigpy.profiles import PROFILES, zha, zll from homeassistant.components.sensor import zha as sensor_zha - DEVICE_CLASS[zha.PROFILE_ID] = { + 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] = {} + + DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', @@ -29,8 +35,8 @@ def populate_data(): zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', - } - DEVICE_CLASS[zll.PROFILE_ID] = { + }) + DEVICE_CLASS[zll.PROFILE_ID].update({ zll.DeviceType.ON_OFF_LIGHT: 'light', zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', zll.DeviceType.DIMMABLE_LIGHT: 'light', @@ -43,7 +49,7 @@ def populate_data(): zll.DeviceType.CONTROLLER: 'binary_sensor', zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', - } + }) SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'switch', diff --git a/homeassistant/components/zha/entities/__init__.py b/homeassistant/components/zha/entities/__init__.py new file mode 100644 index 00000000000..d5e52e9277f --- /dev/null +++ b/homeassistant/components/zha/entities/__init__.py @@ -0,0 +1,10 @@ +""" +Entities 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 .entity import ZhaEntity +from .device_entity import ZhaDeviceEntity diff --git a/homeassistant/components/zha/entities/device_entity.py b/homeassistant/components/zha/entities/device_entity.py new file mode 100644 index 00000000000..1a10f249489 --- /dev/null +++ b/homeassistant/components/zha/entities/device_entity.py @@ -0,0 +1,81 @@ +""" +Device entity for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" + +import time +from homeassistant.helpers import entity +from homeassistant.util import slugify + + +class ZhaDeviceEntity(entity.Entity): + """A base class for ZHA devices.""" + + def __init__(self, device, manufacturer, model, application_listener, + 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 + 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), + ieeetail, + ) + self._device_state_attributes['friendly_name'] = "{} {}".format( + manufacturer, + model, + ) + else: + self._unique_id = str(ieeetail) + + 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 + + @property + def state(self) -> str: + """Return the state of the entity.""" + return self._state + + @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) + 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'): + del self._device_state_attributes['last_seen'] + self._device_state_attributes['lqi'] = self._device.lqi + self._device_state_attributes['rssi'] = self._device.rssi + return self._device_state_attributes + + async def async_update(self): + """Handle polling.""" + if self._device.last_seen is None: + self._state = 'offline' + else: + difference = time.time() - self._device.last_seen + if difference > self._keepalive_interval: + self._state = 'offline' + else: + self._state = 'online' diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entities/entity.py new file mode 100644 index 00000000000..a16f29f447a --- /dev/null +++ b/homeassistant/components/zha/entities/entity.py @@ -0,0 +1,89 @@ +""" +Entity for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +from homeassistant.helpers import entity +from homeassistant.util import slugify +from homeassistant.core import callback + + +class ZhaEntity(entity.Entity): + """A base class for ZHA entities.""" + + _domain = None # Must be overridden by subclasses + + def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, + model, application_listener, unique_id, **kwargs): + """Init ZHA entity.""" + self._device_state_attributes = {} + 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._device_state_attributes['friendly_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._state = None + 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 + application_listener.register_entity(ieee, self) + + 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._initialized = True + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return self._device_state_attributes + + @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 diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py new file mode 100644 index 00000000000..9d07f546b7f --- /dev/null +++ b/homeassistant/components/zha/helpers.py @@ -0,0 +1,84 @@ +""" +Helpers for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) + + +def get_discovery_info(hass, discovery_info): + """Get the full discovery info for a device. + + Some of the info that needs to be passed to platforms is not JSON + serializable, so it cannot be put in the discovery_info dictionary. This + component places that info we need to pass to the platform in hass.data, + and this function is a helper for platforms to retrieve the complete + discovery info. + """ + if discovery_info is None: + return + + import homeassistant.components.zha.const as zha_const + discovery_key = discovery_info.get('discovery_key', None) + all_discovery_info = hass.data.get(zha_const.DISCOVERY_KEY, {}) + return all_discovery_info.get(discovery_key, None) + + +async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): + """Swallow all exceptions from network read. + + If we throw during initialization, setup fails. Rather have an entity that + exists, but is in a maybe wrong state, than no entity. This method should + probably only be used during initialization. + """ + try: + result, _ = await cluster.read_attributes( + attributes, + allow_cache=allow_cache, + only_cache=only_cache + ) + return result + except Exception: # pylint: disable=broad-except + return {} + + +async def configure_reporting(entity_id, cluster, attr, skip_bind=False, + min_report=300, max_report=900, + reportable_change=1): + """Configure attribute reporting for a cluster. + + while swallowing the DeliverError exceptions in case of unreachable + devices. + """ + from zigpy.exceptions import DeliveryError + + attr_name = cluster.attributes.get(attr, [attr])[0] + cluster_name = cluster.ep_attribute + if not skip_bind: + try: + res = await cluster.bind() + _LOGGER.debug( + "%s: bound '%s' cluster: %s", entity_id, cluster_name, res[0] + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: Failed to bind '%s' cluster: %s", + entity_id, cluster_name, str(ex) + ) + + try: + res = await cluster.configure_reporting(attr, min_report, + max_report, reportable_change) + _LOGGER.debug( + "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", + entity_id, attr_name, cluster_name, min_report, max_report, + reportable_change, res + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", + entity_id, attr_name, cluster_name, str(ex) + )