diff --git a/.coveragerc b/.coveragerc index c2448f4143a..365a3a23fc1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -154,6 +154,10 @@ omit = homeassistant/components/tado.py homeassistant/components/*/tado.py + homeassistant/components/zha/__init__.py + homeassistant/components/zha/const.py + homeassistant/components/*/zha.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/nx584.py diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py new file mode 100644 index 00000000000..c12aa38bc24 --- /dev/null +++ b/homeassistant/components/binary_sensor/zha.py @@ -0,0 +1,89 @@ +""" +Binary sensors on Zigbee Home Automation networks. + +For more details on this platform, please refer to the documentation +at https://home-assistant.io/components/binary_sensor.zha/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice +from homeassistant.components import zha + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['zha'] + +# ZigBee Cluster Library Zone Type to Home Assistant device class +CLASS_MAPPING = { + 0x000d: 'motion', + 0x0015: 'opening', + 0x0028: 'smoke', + 0x002a: 'moisture', + 0x002b: 'gas', + 0x002d: 'vibration', +} + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup Zigbee Home Automation binary sensors.""" + discovery_info = zha.get_discovery_info(hass, discovery_info) + if discovery_info is None: + return + + from bellows.zigbee.zcl.clusters.security import IasZone + + clusters = discovery_info['clusters'] + + device_class = None + cluster = [c for c in clusters if isinstance(c, IasZone)][0] + if discovery_info['new_join']: + yield from cluster.bind() + ieee = cluster.endpoint.device.application.ieee + yield from cluster.write_attributes({'cie_addr': ieee}) + + try: + zone_type = yield from 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 + + sensor = BinarySensor(device_class, **discovery_info) + async_add_devices([sensor]) + + +class BinarySensor(zha.Entity, BinarySensorDevice): + """ZHA Binary Sensor.""" + + _domain = DOMAIN + + def __init__(self, device_class, **kwargs): + """Initialize ZHA binary sensor.""" + super().__init__(**kwargs) + self._device_class = device_class + from bellows.zigbee.zcl.clusters.security import IasZone + self._ias_zone_cluster = self._clusters[IasZone.cluster_id] + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self._state == 'unknown': + 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, aps_frame, 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.schedule_update_ha_state() + elif command_id == 1: + _LOGGER.debug("Enroll requested") + self.hass.add_job(self._ias_zone_cluster.enroll_response(0, 0)) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py new file mode 100644 index 00000000000..928850ad512 --- /dev/null +++ b/homeassistant/components/light/zha.py @@ -0,0 +1,132 @@ +""" +Lights on Zigbee Home Automation networks. + +For more details on this platform, please refer to the documentation +at https://home-assistant.io/components/light.zha/ +""" +import asyncio +import logging + +from homeassistant.components import light, zha +from homeassistant.util.color import HASS_COLOR_MIN, color_RGB_to_xy + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['zha'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup Zigbee Home Automation lights.""" + discovery_info = zha.get_discovery_info(hass, discovery_info) + if discovery_info is None: + return + + endpoint = discovery_info['endpoint'] + try: + primaries = yield from endpoint.light_color['num_primaries'] + discovery_info['num_primaries'] = primaries + except (AttributeError, KeyError): + pass + + async_add_devices([Light(**discovery_info)]) + + +class Light(zha.Entity, light.Light): + """ZHA or ZLL light.""" + + _domain = light.DOMAIN + + def __init__(self, **kwargs): + """Initialize ZHA light.""" + super().__init__(**kwargs) + self._supported_features = 0 + self._color_temp = None + self._xy_color = None + self._brightness = None + + import bellows.zigbee.zcl.clusters as zcl_clusters + if zcl_clusters.general.LevelControl.cluster_id in self._clusters: + self._supported_features |= light.SUPPORT_BRIGHTNESS + self._brightness = 0 + if zcl_clusters.lighting.Color.cluster_id in self._clusters: + # Not sure all color lights necessarily support this directly + # Should we emulate it? + self._supported_features |= light.SUPPORT_COLOR_TEMP + self._color_temp = HASS_COLOR_MIN + # Silly heuristic, not sure if it works widely + if kwargs.get('num_primaries', 1) >= 3: + self._supported_features |= light.SUPPORT_XY_COLOR + self._supported_features |= light.SUPPORT_RGB_COLOR + self._xy_color = (1.0, 1.0) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self._state == 'unknown': + return False + return bool(self._state) + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the entity on.""" + duration = 5 # tenths of s + if light.ATTR_COLOR_TEMP in kwargs: + temperature = kwargs[light.ATTR_COLOR_TEMP] + yield from self._endpoint.light_color.move_to_color_temp( + temperature, duration) + self._color_temp = temperature + + if light.ATTR_XY_COLOR in kwargs: + self._xy_color = kwargs[light.ATTR_XY_COLOR] + elif light.ATTR_RGB_COLOR in kwargs: + xyb = color_RGB_to_xy( + *(int(val) for val in kwargs[light.ATTR_RGB_COLOR])) + self._xy_color = (xyb[0], xyb[1]) + self._brightness = xyb[2] + if light.ATTR_XY_COLOR in kwargs or light.ATTR_RGB_COLOR in kwargs: + yield from self._endpoint.light_color.move_to_color( + int(self._xy_color[0] * 65535), + int(self._xy_color[1] * 65535), + duration, + ) + + if self._brightness is not None: + brightness = kwargs.get('brightness', self._brightness or 255) + self._brightness = brightness + # Move to level with on/off: + yield from self._endpoint.level.move_to_level_with_on_off( + brightness, + duration + ) + self._state = 1 + return + + yield from self._endpoint.on_off.on() + self._state = 1 + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the entity off.""" + yield from self._endpoint.on_off.off() + self._state = 0 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def xy_color(self): + """Return the XY color value [float, float].""" + return self._xy_color + + @property + def color_temp(self): + """Return the CT color value in mireds.""" + return self._color_temp + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py new file mode 100644 index 00000000000..ef1fe36873b --- /dev/null +++ b/homeassistant/components/sensor/zha.py @@ -0,0 +1,99 @@ +""" +Sensors on Zigbee Home Automation networks. + +For more details on this platform, please refer to the documentation +at https://home-assistant.io/components/sensor.zha/ +""" +import asyncio +import logging + +from homeassistant.components.sensor import DOMAIN +from homeassistant.components import zha +from homeassistant.const import TEMP_CELSIUS +from homeassistant.util.temperature import convert as convert_temperature + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['zha'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup Zigbee Home Automation sensors.""" + discovery_info = zha.get_discovery_info(hass, discovery_info) + if discovery_info is None: + return + + sensor = yield from make_sensor(discovery_info) + async_add_devices([sensor]) + + +@asyncio.coroutine +def make_sensor(discovery_info): + """Factory function for ZHA sensors.""" + from bellows.zigbee import zcl + if isinstance(discovery_info['clusters'][0], + zcl.clusters.measurement.TemperatureMeasurement): + sensor = TemperatureSensor(**discovery_info) + else: + sensor = Sensor(**discovery_info) + + clusters = discovery_info['clusters'] + attr = sensor.value_attribute + if discovery_info['new_join']: + cluster = clusters[0] + yield from cluster.bind() + yield from cluster.configure_reporting( + attr, + 300, + 600, + sensor.min_reportable_change, + ) + + return sensor + + +class Sensor(zha.Entity): + """Base ZHA sensor.""" + + _domain = DOMAIN + value_attribute = 0 + min_reportable_change = 1 + + def __init__(self, **kwargs): + """Initialize ZHA sensor.""" + super().__init__(**kwargs) + + @property + def state(self) -> str: + """Return the state of the entity.""" + 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.schedule_update_ha_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 entityy.""" + return self.hass.config.units.temperature_unit + + @property + def state(self): + """Return the state of the entity.""" + if self._state == 'unknown': + return 'unknown' + celsius = round(float(self._state) / 100, 1) + return convert_temperature(celsius, TEMP_CELSIUS, + self.unit_of_measurement) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py new file mode 100644 index 00000000000..fb327d3ddd9 --- /dev/null +++ b/homeassistant/components/switch/zha.py @@ -0,0 +1,49 @@ +""" +Switches on Zigbee Home Automation networks. + +For more details on this platform, please refer to the documentation +at https://home-assistant.io/components/switch.zha/ +""" +import asyncio +import logging + +from homeassistant.components.switch import DOMAIN, SwitchDevice +from homeassistant.components import zha + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['zha'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Zigbee Home Automation switches.""" + discovery_info = zha.get_discovery_info(hass, discovery_info) + if discovery_info is None: + return + + add_devices([Switch(**discovery_info)]) + + +class Switch(zha.Entity, SwitchDevice): + """ZHA switch.""" + + _domain = DOMAIN + + @property + def is_on(self) -> bool: + """Return if the switch is on based on the statemachine.""" + if self._state == 'unknown': + return False + return bool(self._state) + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the entity on.""" + yield from self._endpoint.on_off.on() + self._state = 1 + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the entity off.""" + yield from self._endpoint.on_off.off() + self._state = 0 diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py new file mode 100644 index 00000000000..e9e21b634d1 --- /dev/null +++ b/homeassistant/components/zha/__init__.py @@ -0,0 +1,301 @@ +""" +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 + +from homeassistant import const as ha_const +from homeassistant.helpers import discovery, entity +from homeassistant.util import slugify +import homeassistant.helpers.config_validation as cv + + +# Definitions for interfacing with the rest of HA +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): + """Setup 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: + """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): + """Initialize 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 + + result, _ = yield from endpoint.clusters[0].read_attributes( + ['manufacturer', 'model'], + allow_cache=True, + ) + extra_info.update(result) + + for key, value in extra_info.items(): + if isinstance(value, bytes): + try: + extra_info[key] = value.decode('ascii') + 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 diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py new file mode 100644 index 00000000000..5e2dfb12d6f --- /dev/null +++ b/homeassistant/components/zha/const.py @@ -0,0 +1,52 @@ +"""Constants related to the zha component.""" + +# Populated by populate_data() when zha component is initialized +DEVICE_CLASS = {} +SINGLE_CLUSTER_DEVICE_CLASS = {} +COMPONENT_CLUSTERS = {} + + +def populate_data(): + """Populate data using constants from bellows. + + These cannot be module level, as importing bellows must be done in a + in a function. + """ + from bellows.zigbee import zcl + from bellows.zigbee.profiles import PROFILES, zha, zll + + DEVICE_CLASS[zha.PROFILE_ID] = { + zha.DeviceType.ON_OFF_SWITCH: 'switch', + zha.DeviceType.SMART_PLUG: 'switch', + + zha.DeviceType.ON_OFF_LIGHT: 'light', + zha.DeviceType.DIMMABLE_LIGHT: 'light', + zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', + zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'light', + zha.DeviceType.DIMMER_SWITCH: 'light', + zha.DeviceType.COLOR_DIMMER_SWITCH: 'light', + } + DEVICE_CLASS[zll.PROFILE_ID] = { + zll.DeviceType.ON_OFF_LIGHT: 'light', + zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', + zll.DeviceType.DIMMABLE_LIGHT: 'light', + zll.DeviceType.DIMMABLE_PLUGIN_UNIT: 'light', + zll.DeviceType.COLOR_LIGHT: 'light', + zll.DeviceType.EXTENDED_COLOR_LIGHT: 'light', + zll.DeviceType.COLOR_TEMPERATURE_LIGHT: 'light', + } + + SINGLE_CLUSTER_DEVICE_CLASS.update({ + zcl.clusters.general.OnOff: 'switch', + zcl.clusters.measurement.TemperatureMeasurement: 'sensor', + zcl.clusters.security.IasZone: 'binary_sensor', + }) + + # A map of hass components to all Zigbee clusters it could use + for profile_id, classes in DEVICE_CLASS.items(): + profile = PROFILES[profile_id] + for device_type, component in classes.items(): + if component not in COMPONENT_CLUSTERS: + COMPONENT_CLUSTERS[component] = set() + clusters = profile.CLUSTERS[device_type] + COMPONENT_CLUSTERS[component].update(clusters) diff --git a/requirements_all.txt b/requirements_all.txt index b13ee19dc79..5fde3397faa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -83,6 +83,9 @@ batinfo==0.4.2 # homeassistant.components.sensor.scrape beautifulsoup4==4.5.3 +# homeassistant.components.zha +bellows==0.2.7 + # homeassistant.components.blink blinkpy==0.5.2