hass-core/homeassistant/components/zha/__init__.py
Russell Cloran 46ce26eb7a zha: Try multiple reads to get manufacturer/model (#8308)
Some devices don't seem to return the information properly when asked for
multiple attributes in one read. This separates out the reads if it didn't
work as expected the first time.

Because this data is cached in bellows, I don't expect any extra reads in
the "happy" case.
2017-07-06 23:02:22 -07:00

308 lines
10 KiB
Python

"""
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 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
REQUIREMENTS = ['bellows==0.2.7']
DOMAIN = 'zha'
CONF_USB_PATH = 'usb_path'
CONF_DATABASE = 'database_path'
CONF_DEVICE_CONFIG = 'device_config'
DATA_DEVICE_CONFIG = 'zha_device_config'
DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({
vol.Optional(ha_const.CONF_TYPE): cv.string,
})
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
CONF_USB_PATH: cv.string,
CONF_DATABASE: cv.string,
vol.Optional(CONF_DEVICE_CONFIG, default={}):
vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}),
})
}, extra=vol.ALLOW_EXTRA)
ATTR_DURATION = 'duration'
SERVICE_PERMIT = 'permit'
SERVICE_DESCRIPTIONS = {
SERVICE_PERMIT: {
"description": "Allow nodes to join the Zigbee network",
"fields": {
"duration": {
"description": "Time to permit joins, in seconds",
"example": "60",
},
},
},
}
SERVICE_SCHEMAS = {
SERVICE_PERMIT: vol.Schema({
vol.Optional(ATTR_DURATION, default=60):
vol.All(vol.Coerce(int), vol.Range(1, 254)),
}),
}
# ZigBee definitions
CENTICELSIUS = 'C-100'
# Key in hass.data dict containing discovery info
DISCOVERY_KEY = 'zha_discovery_info'
# Internal definitions
APPLICATION_CONTROLLER = None
_LOGGER = logging.getLogger(__name__)
@asyncio.coroutine
def async_setup(hass, config):
"""Set up ZHA.
Will automatically load components to support devices found on the network.
"""
global APPLICATION_CONTROLLER
import bellows.ezsp
from bellows.zigbee.application import ControllerApplication
ezsp_ = bellows.ezsp.EZSP()
usb_path = config[DOMAIN].get(CONF_USB_PATH)
yield from ezsp_.connect(usb_path)
database = config[DOMAIN].get(CONF_DATABASE)
APPLICATION_CONTROLLER = ControllerApplication(ezsp_, database)
listener = ApplicationListener(hass, config)
APPLICATION_CONTROLLER.add_listener(listener)
yield from APPLICATION_CONTROLLER.startup(auto_form=True)
for device in APPLICATION_CONTROLLER.devices.values():
hass.async_add_job(listener.async_device_initialized(device, False))
@asyncio.coroutine
def permit(service):
"""Allow devices to join this network."""
duration = service.data.get(ATTR_DURATION)
_LOGGER.info("Permitting joins for %ss", duration)
yield from APPLICATION_CONTROLLER.permit(duration)
hass.services.async_register(DOMAIN, SERVICE_PERMIT, permit,
SERVICE_DESCRIPTIONS[SERVICE_PERMIT],
SERVICE_SCHEMAS[SERVICE_PERMIT])
return True
class ApplicationListener:
"""All handlers for events that happen on the ZigBee application."""
def __init__(self, hass, config):
"""Initialize the listener."""
self._hass = hass
self._config = config
hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {})
def device_joined(self, device):
"""Handle device joined.
At this point, no information about the device is known other than its
address
"""
# Wait for device_initialized, instead
pass
def device_initialized(self, device):
"""Handle device joined and basic information discovered."""
self._hass.async_add_job(self.async_device_initialized(device, True))
@asyncio.coroutine
def async_device_initialized(self, device, join):
"""Handle device joined and basic information discovered (async)."""
import bellows.zigbee.profiles
import homeassistant.components.zha.const as zha_const
zha_const.populate_data()
for endpoint_id, endpoint in device.endpoints.items():
if endpoint_id == 0: # ZDO
continue
discovered_info = yield from _discover_endpoint_info(endpoint)
component = None
used_clusters = []
device_key = '%s-%s' % (str(device.ieee), endpoint_id)
node_config = self._config[DOMAIN][CONF_DEVICE_CONFIG].get(
device_key, {})
if endpoint.profile_id in bellows.zigbee.profiles.PROFILES:
profile = bellows.zigbee.profiles.PROFILES[endpoint.profile_id]
if zha_const.DEVICE_CLASS.get(endpoint.profile_id,
{}).get(endpoint.device_type,
None):
used_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]
used_clusters = zha_const.COMPONENT_CLUSTERS[component]
if component:
clusters = [endpoint.clusters[c] for c in used_clusters if c in
endpoint.clusters]
discovery_info = {
'endpoint': endpoint,
'clusters': clusters,
'new_join': join,
}
discovery_info.update(discovered_info)
self._hass.data[DISCOVERY_KEY][device_key] = discovery_info
yield from discovery.async_load_platform(
self._hass,
component,
DOMAIN,
{'discovery_key': device_key},
self._config,
)
for cluster_id, cluster in endpoint.clusters.items():
cluster_type = type(cluster)
if cluster_id in used_clusters:
continue
if cluster_type not in zha_const.SINGLE_CLUSTER_DEVICE_CLASS:
continue
component = zha_const.SINGLE_CLUSTER_DEVICE_CLASS[cluster_type]
discovery_info = {
'endpoint': endpoint,
'clusters': [cluster],
'new_join': join,
}
discovery_info.update(discovered_info)
cluster_key = '%s-%s' % (device_key, cluster_id)
self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info
yield from discovery.async_load_platform(
self._hass,
component,
DOMAIN,
{'discovery_key': cluster_key},
self._config,
)
class Entity(entity.Entity):
"""A base class for ZHA entities."""
_domain = None # Must be overriden by subclasses
def __init__(self, endpoint, clusters, manufacturer, model, **kwargs):
"""Init ZHA entity."""
self._device_state_attributes = {}
ieeetail = ''.join([
'%02x' % (o, ) for o in endpoint.device.ieee[-4:]
])
if manufacturer and model is not None:
self.entity_id = '%s.%s_%s_%s_%s' % (
self._domain,
slugify(manufacturer),
slugify(model),
ieeetail,
endpoint.endpoint_id,
)
self._device_state_attributes['friendly_name'] = '%s %s' % (
manufacturer,
model,
)
else:
self.entity_id = "%s.zha_%s_%s" % (
self._domain,
ieeetail,
endpoint.endpoint_id,
)
for cluster in clusters:
cluster.add_listener(self)
self._endpoint = endpoint
self._clusters = {c.cluster_id: c for c in clusters}
self._state = ha_const.STATE_UNKNOWN
def attribute_updated(self, attribute, value):
"""Handle an attribute updated on this cluster."""
pass
def zdo_command(self, aps_frame, tsn, command_id, args):
"""Handle a ZDO command received on this cluster."""
pass
@property
def device_state_attributes(self):
"""Return device specific state attributes."""
return self._device_state_attributes
@asyncio.coroutine
def _discover_endpoint_info(endpoint):
"""Find some basic information about an endpoint."""
extra_info = {
'manufacturer': None,
'model': None,
}
if 0 not in endpoint.clusters:
return extra_info
@asyncio.coroutine
def read(attributes):
"""Read attributes and update extra_info convenience function."""
result, _ = yield from endpoint.clusters[0].read_attributes(
attributes,
allow_cache=True,
)
extra_info.update(result)
yield from read(['manufacturer', 'model'])
if extra_info['manufacturer'] is None or extra_info['model'] is None:
# Some devices fail at returning multiple results. Attempt separately.
yield from read(['manufacturer'])
yield from read(['model'])
for key, value in extra_info.items():
if isinstance(value, bytes):
try:
extra_info[key] = value.decode('ascii').strip()
except UnicodeDecodeError:
# Unsure what the best behaviour here is. Unset the key?
pass
return extra_info
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, {})
discovery_info = all_discovery_info.get(discovery_key, None)
return discovery_info