From 052d305243b893c4f797fc242f28ed5044c631c3 Mon Sep 17 00:00:00 2001 From: damarco Date: Tue, 27 Nov 2018 21:21:25 +0100 Subject: [PATCH] Add config entry for ZHA (#18352) * Add support for zha config entries * Add support for zha config entries * Fix node_config retrieval * Dynamically load discovered entities * Restore device config support * Refactor loading of entities * Remove device registry support * Send discovery_info directly * Clean up discovery_info in hass.data * Update tests * Clean up rebase * Simplify config flow * Address comments * Fix config path and zigpy check timeout * Remove device entities when unloading config entry --- homeassistant/components/binary_sensor/zha.py | 63 ++++--- homeassistant/components/fan/zha.py | 37 +++- homeassistant/components/light/zha.py | 69 +++++-- homeassistant/components/sensor/zha.py | 38 +++- homeassistant/components/switch/zha.py | 56 ++++-- .../components/zha/.translations/en.json | 21 +++ homeassistant/components/zha/__init__.py | 174 +++++++++++++----- homeassistant/components/zha/config_flow.py | 57 ++++++ homeassistant/components/zha/const.py | 47 +++++ homeassistant/components/zha/helpers.py | 40 ++-- homeassistant/components/zha/strings.json | 21 +++ homeassistant/config_entries.py | 1 + tests/components/zha/__init__.py | 1 + tests/components/zha/test_config_flow.py | 77 ++++++++ 14 files changed, 567 insertions(+), 135 deletions(-) create mode 100644 homeassistant/components/zha/.translations/en.json create mode 100644 homeassistant/components/zha/config_flow.py create mode 100644 homeassistant/components/zha/strings.json create mode 100644 tests/components/zha/__init__.py create mode 100644 tests/components/zha/test_config_flow.py diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index c1ced3766c9..087e7963c00 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -9,6 +9,10 @@ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) _LOGGER = logging.getLogger(__name__) @@ -27,23 +31,43 @@ 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 = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return - - from zigpy.zcl.clusters.general import OnOff - from zigpy.zcl.clusters.security import IasZone - if IasZone.cluster_id in discovery_info['in_clusters']: - await _async_setup_iaszone(hass, config, async_add_entities, - discovery_info) - elif OnOff.cluster_id in discovery_info['out_clusters']: - await _async_setup_remote(hass, config, async_add_entities, - discovery_info) + """Old way of setting up Zigbee Home Automation binary sensors.""" + pass -async def _async_setup_iaszone(hass, config, async_add_entities, - discovery_info): +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation binary sensor from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + binary_sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if binary_sensors is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + binary_sensors.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA binary sensors.""" + entities = [] + for discovery_info in discovery_infos: + from zigpy.zcl.clusters.general import OnOff + from zigpy.zcl.clusters.security import IasZone + if IasZone.cluster_id in discovery_info['in_clusters']: + entities.append(await _async_setup_iaszone(discovery_info)) + elif OnOff.cluster_id in discovery_info['out_clusters']: + entities.append(await _async_setup_remote(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] @@ -59,13 +83,10 @@ async def _async_setup_iaszone(hass, config, async_add_entities, # If we fail to read from the device, use a non-specific class pass - sensor = BinarySensor(device_class, **discovery_info) - async_add_entities([sensor], update_before_add=True) + return BinarySensor(device_class, **discovery_info) -async def _async_setup_remote(hass, config, async_add_entities, - discovery_info): - +async def _async_setup_remote(discovery_info): remote = Remote(**discovery_info) if discovery_info['new_join']: @@ -84,7 +105,7 @@ async def _async_setup_remote(hass, config, async_add_entities, reportable_change=1 ) - async_add_entities([remote], update_before_add=True) + return remote class BinarySensor(ZhaEntity, BinarySensorDevice): diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index d948ba2ff5b..4f8254672a8 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -7,6 +7,10 @@ at https://home-assistant.io/components/fan.zha/ import logging from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) from homeassistant.components.fan import ( DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) @@ -40,12 +44,35 @@ 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 = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation fans.""" + pass - async_add_entities([ZhaFan(**discovery_info)], update_before_add=True) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation fan from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + fans = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if fans is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + fans.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA fans.""" + entities = [] + for discovery_info in discovery_infos: + entities.append(ZhaFan(**discovery_info)) + + async_add_entities(entities, update_before_add=True) class ZhaFan(ZhaEntity, FanEntity): diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 20c9faf2514..67b65edb0a6 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -8,6 +8,10 @@ import logging from homeassistant.components import light from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -24,27 +28,54 @@ 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 = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation lights.""" + pass - 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 - async_add_entities([Light(**discovery_info)], update_before_add=True) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation light from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(light.DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + lights = hass.data.get(DATA_ZHA, {}).get(light.DOMAIN) + if lights is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + lights.values()) + del hass.data[DATA_ZHA][light.DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """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 + entities.append(Light(**discovery_info)) + + async_add_entities(entities, update_before_add=True) class Light(ZhaEntity, light.Light): diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 993b247a439..97432b2512f 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -9,6 +9,10 @@ import logging from homeassistant.components.sensor import DOMAIN from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) from homeassistant.const import TEMP_CELSIUS from homeassistant.util.temperature import convert as convert_temperature @@ -19,13 +23,35 @@ DEPENDENCIES = ['zha'] async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up Zigbee Home Automation sensors.""" - discovery_info = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation sensors.""" + pass - sensor = await make_sensor(discovery_info) - async_add_entities([sensor], update_before_add=True) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation sensor from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if sensors is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + sensors.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA sensors.""" + entities = [] + for discovery_info in discovery_infos: + entities.append(await make_sensor(discovery_info)) + + async_add_entities(entities, update_before_add=True) async def make_sensor(discovery_info): diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index b184d7baa5c..d34ca5e71ba 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -6,9 +6,13 @@ at https://home-assistant.io/components/switch.zha/ """ import logging +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) _LOGGER = logging.getLogger(__name__) @@ -17,24 +21,44 @@ DEPENDENCIES = ['zha'] async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation switches.""" + """Old way of setting up Zigbee Home Automation switches.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation switch from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + switches = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if switches is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + switches.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA switches.""" from zigpy.zcl.clusters.general import OnOff + entities = [] + for discovery_info in discovery_infos: + switch = Switch(**discovery_info) + if discovery_info['new_join']: + in_clusters = discovery_info['in_clusters'] + cluster = in_clusters[OnOff.cluster_id] + await helpers.configure_reporting( + switch.entity_id, cluster, switch.value_attribute, + min_report=0, max_report=600, reportable_change=1 + ) + entities.append(switch) - discovery_info = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return - - switch = Switch(**discovery_info) - - if discovery_info['new_join']: - in_clusters = discovery_info['in_clusters'] - cluster = in_clusters[OnOff.cluster_id] - await helpers.configure_reporting( - switch.entity_id, cluster, switch.value_attribute, - min_report=0, max_report=600, reportable_change=1 - ) - - async_add_entities([switch], update_before_add=True) + async_add_entities(entities, update_before_add=True) class Switch(ZhaEntity, SwitchDevice): diff --git a/homeassistant/components/zha/.translations/en.json b/homeassistant/components/zha/.translations/en.json new file mode 100644 index 00000000000..b6d7948c0b3 --- /dev/null +++ b/homeassistant/components/zha/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "ZHA", + "step": { + "user": { + "title": "ZHA", + "description": "", + "data": { + "usb_path": "USB Device Path", + "radio_type": "Radio Type" + } + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of ZHA is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to ZHA device." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e54b7f7f657..0fc2b978fbb 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,51 +5,47 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ import collections -import enum import logging +import os import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant import const as ha_const -from homeassistant.helpers import discovery from homeassistant.helpers.entity_component import EntityComponent from homeassistant.components.zha.entities import ZhaDeviceEntity +from homeassistant import config_entries, const as ha_const +from homeassistant.helpers.dispatcher import async_dispatcher_send from . import const as zha_const +# Loading the config flow file will register the flow +from . import config_flow # noqa # pylint: disable=unused-import +from .const import ( + DOMAIN, COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_RADIO_TYPE, + CONF_USB_PATH, CONF_DEVICE_CONFIG, ZHA_DISCOVERY_NEW, DATA_ZHA, + DATA_ZHA_CONFIG, DATA_ZHA_BRIDGE_ID, DATA_ZHA_RADIO, DATA_ZHA_DISPATCHERS, + DATA_ZHA_CORE_COMPONENT, DEFAULT_RADIO_TYPE, DEFAULT_DATABASE_NAME, + DEFAULT_BAUDRATE, RadioType +) + REQUIREMENTS = [ 'bellows==0.7.0', 'zigpy==0.2.0', 'zigpy-xbee==0.1.1', ] -DOMAIN = 'zha' - - -class RadioType(enum.Enum): - """Possible options for radio type in config.""" - - ezsp = 'ezsp' - xbee = 'xbee' - - -CONF_BAUDRATE = 'baudrate' -CONF_DATABASE = 'database_path' -CONF_DEVICE_CONFIG = 'device_config' -CONF_RADIO_TYPE = 'radio_type' -CONF_USB_PATH = 'usb_path' -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({ - vol.Optional(CONF_RADIO_TYPE, default='ezsp'): cv.enum(RadioType), + vol.Optional( + CONF_RADIO_TYPE, + default=DEFAULT_RADIO_TYPE + ): cv.enum(RadioType), CONF_USB_PATH: cv.string, - vol.Optional(CONF_BAUDRATE, default=57600): cv.positive_int, - CONF_DATABASE: cv.string, + vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int, + vol.Optional(CONF_DATABASE): cv.string, vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}), }) @@ -73,8 +69,6 @@ SERVICE_SCHEMAS = { # Zigbee definitions CENTICELSIUS = 'C-100' -# Key in hass.data dict containing discovery info -DISCOVERY_KEY = 'zha_discovery_info' # Internal definitions APPLICATION_CONTROLLER = None @@ -82,27 +76,58 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass, config): + """Set up ZHA from config.""" + hass.data[DATA_ZHA] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + data={ + CONF_USB_PATH: conf[CONF_USB_PATH], + CONF_RADIO_TYPE: conf.get(CONF_RADIO_TYPE).value + } + )) + return True + + +async def async_setup_entry(hass, config_entry): """Set up ZHA. Will automatically load components to support devices found on the network. """ global APPLICATION_CONTROLLER - usb_path = config[DOMAIN].get(CONF_USB_PATH) - baudrate = config[DOMAIN].get(CONF_BAUDRATE) - radio_type = config[DOMAIN].get(CONF_RADIO_TYPE) - if radio_type == RadioType.ezsp: + 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, {}) + + usb_path = config_entry.data.get(CONF_USB_PATH) + baudrate = config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE) + radio_type = config_entry.data.get(CONF_RADIO_TYPE) + if radio_type == RadioType.ezsp.name: import bellows.ezsp from bellows.zigbee.application import ControllerApplication radio = bellows.ezsp.EZSP() - elif radio_type == RadioType.xbee: + elif radio_type == RadioType.xbee.name: import zigpy_xbee.api from zigpy_xbee.zigbee.application import ControllerApplication radio = zigpy_xbee.api.XBee() await radio.connect(usb_path, baudrate) + hass.data[DATA_ZHA][DATA_ZHA_RADIO] = radio - database = config[DOMAIN].get(CONF_DATABASE) + if CONF_DATABASE in config: + database = config[CONF_DATABASE] + else: + database = os.path.join(hass.config.config_dir, DEFAULT_DATABASE_NAME) APPLICATION_CONTROLLER = ControllerApplication(radio, database) listener = ApplicationListener(hass, config) APPLICATION_CONTROLLER.add_listener(listener) @@ -112,6 +137,14 @@ async def async_setup(hass, config): hass.async_create_task( listener.async_device_initialized(device, False)) + 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( + config_entry, component) + ) + async def permit(service): """Allow devices to join this network.""" duration = service.data.get(ATTR_DURATION) @@ -132,6 +165,37 @@ async def async_setup(hass, config): hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[SERVICE_REMOVE]) + def zha_shutdown(event): + """Close radio.""" + hass.data[DATA_ZHA][DATA_ZHA_RADIO].close() + + hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, zha_shutdown) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload ZHA config entry.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + + dispatchers = hass.data[DATA_ZHA].get(DATA_ZHA_DISPATCHERS, []) + for unsub_dispatcher in dispatchers: + unsub_dispatcher() + + for component in COMPONENTS: + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + + # clean up device entities + component = hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] + entity_ids = [entity.entity_id for entity in component.entities] + for entity_id in entity_ids: + await component.async_remove_entity(entity_id) + + _LOGGER.debug("Closing zha radio") + hass.data[DATA_ZHA][DATA_ZHA_RADIO].close() + + del hass.data[DATA_ZHA] return True @@ -144,9 +208,14 @@ class ApplicationListener: self._config = config 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() + 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 + def device_joined(self, device): """Handle device joined. @@ -193,8 +262,11 @@ class ApplicationListener: component = None profile_clusters = ([], []) device_key = "{}-{}".format(device.ieee, endpoint_id) - node_config = self._config[DOMAIN][CONF_DEVICE_CONFIG].get( - device_key, {}) + 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] @@ -226,15 +298,17 @@ class ApplicationListener: 'new_join': join, 'unique_id': device_key, } - self._hass.data[DISCOVERY_KEY][device_key] = discovery_info - await discovery.async_load_platform( - self._hass, - component, - DOMAIN, - {'discovery_key': device_key}, - self._config, - ) + 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( @@ -309,12 +383,12 @@ class ApplicationListener: discovery_info[discovery_attr] = {cluster.cluster_id: cluster} if sub_component: discovery_info.update({'sub_component': sub_component}) - self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info - await discovery.async_load_platform( - self._hass, - component, - DOMAIN, - {'discovery_key': cluster_key}, - self._config, - ) + if is_new_join: + async_dispatcher_send( + self._hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info + ) + else: + self._hass.data[DATA_ZHA][component][cluster_key] = discovery_info diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py new file mode 100644 index 00000000000..fa45194ea3f --- /dev/null +++ b/homeassistant/components/zha/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for ZHA.""" +import os +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from .helpers import check_zigpy_connection +from .const import ( + DOMAIN, CONF_RADIO_TYPE, CONF_USB_PATH, DEFAULT_DATABASE_NAME, RadioType +) + + +@config_entries.HANDLERS.register(DOMAIN) +class ZhaFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle a zha config flow start.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + errors = {} + + fields = OrderedDict() + fields[vol.Required(CONF_USB_PATH)] = str + fields[vol.Optional(CONF_RADIO_TYPE, default='ezsp')] = vol.In( + RadioType.list() + ) + + if user_input is not None: + database = os.path.join(self.hass.config.config_dir, + DEFAULT_DATABASE_NAME) + test = await check_zigpy_connection(user_input[CONF_USB_PATH], + user_input[CONF_RADIO_TYPE], + database) + if test: + return self.async_create_entry( + title=user_input[CONF_USB_PATH], data=user_input) + errors['base'] = 'cannot_connect' + + return self.async_show_form( + step_id='user', data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_import(self, import_info): + """Handle a zha config import.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + return self.async_create_entry( + title=import_info[CONF_USB_PATH], + data=import_info + ) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 2a7e35ff517..9efa847b50c 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -1,4 +1,51 @@ """All constants related to the ZHA component.""" +import enum + +DOMAIN = 'zha' + +BAUD_RATES = [ + 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000 +] + +DATA_ZHA = 'zha' +DATA_ZHA_CONFIG = 'config' +DATA_ZHA_BRIDGE_ID = 'zha_bridge_id' +DATA_ZHA_RADIO = 'zha_radio' +DATA_ZHA_DISPATCHERS = 'zha_dispatchers' +DATA_ZHA_CORE_COMPONENT = 'zha_core_component' +ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}' + +COMPONENTS = [ + 'binary_sensor', + 'fan', + 'light', + 'sensor', + 'switch', +] + +CONF_BAUDRATE = 'baudrate' +CONF_DATABASE = 'database_path' +CONF_DEVICE_CONFIG = 'device_config' +CONF_RADIO_TYPE = 'radio_type' +CONF_USB_PATH = 'usb_path' +DATA_DEVICE_CONFIG = 'zha_device_config' + +DEFAULT_RADIO_TYPE = 'ezsp' +DEFAULT_BAUDRATE = 57600 +DEFAULT_DATABASE_NAME = 'zigbee.db' + + +class RadioType(enum.Enum): + """Possible options for radio type.""" + + ezsp = 'ezsp' + xbee = 'xbee' + + @classmethod + def list(cls): + """Return list of enum's values.""" + return [e.value for e in RadioType] + DISCOVERY_KEY = 'zha_discovery_info' DEVICE_CLASS = {} diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 9d07f546b7f..f3e1a27dca2 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -5,28 +5,12 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ import logging +import asyncio +from .const import RadioType, DEFAULT_BAUDRATE _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. @@ -82,3 +66,23 @@ async def configure_reporting(entity_id, cluster, attr, skip_bind=False, "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", entity_id, attr_name, cluster_name, str(ex) ) + + +async def check_zigpy_connection(usb_path, radio_type, database_path): + """Test zigpy radio connection.""" + if radio_type == RadioType.ezsp.name: + import bellows.ezsp + from bellows.zigbee.application import ControllerApplication + radio = bellows.ezsp.EZSP() + elif radio_type == RadioType.xbee.name: + import zigpy_xbee.api + from zigpy_xbee.zigbee.application import ControllerApplication + radio = zigpy_xbee.api.XBee() + try: + await radio.connect(usb_path, DEFAULT_BAUDRATE) + controller = ControllerApplication(radio, database_path) + await asyncio.wait_for(controller.startup(auto_form=True), timeout=30) + radio.close() + except Exception: # pylint: disable=broad-except + return False + return True diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json new file mode 100644 index 00000000000..b6d7948c0b3 --- /dev/null +++ b/homeassistant/components/zha/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "ZHA", + "step": { + "user": { + "title": "ZHA", + "description": "", + "data": { + "usb_path": "USB Device Path", + "radio_type": "Radio Type" + } + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of ZHA is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to ZHA device." + } + } +} \ No newline at end of file diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 42bc8b089da..acfa10acdef 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -158,6 +158,7 @@ FLOWS = [ 'twilio', 'unifi', 'upnp', + 'zha', 'zone', 'zwave' ] diff --git a/tests/components/zha/__init__.py b/tests/components/zha/__init__.py new file mode 100644 index 00000000000..23d26b50312 --- /dev/null +++ b/tests/components/zha/__init__.py @@ -0,0 +1 @@ +"""Tests for the ZHA component.""" diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py new file mode 100644 index 00000000000..e46f1849fa1 --- /dev/null +++ b/tests/components/zha/test_config_flow.py @@ -0,0 +1,77 @@ +"""Tests for ZHA config flow.""" +from asynctest import patch +from homeassistant.components.zha import config_flow +from homeassistant.components.zha.const import DOMAIN +from tests.common import MockConfigEntry + + +async def test_user_flow(hass): + """Test that config flow works.""" + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + with patch('homeassistant.components.zha.config_flow' + '.check_zigpy_connection', return_value=False): + result = await flow.async_step_user( + user_input={'usb_path': '/dev/ttyUSB1', 'radio_type': 'ezsp'}) + + assert result['errors'] == {'base': 'cannot_connect'} + + with patch('homeassistant.components.zha.config_flow' + '.check_zigpy_connection', return_value=True): + result = await flow.async_step_user( + user_input={'usb_path': '/dev/ttyUSB1', 'radio_type': 'ezsp'}) + + assert result['type'] == 'create_entry' + assert result['title'] == '/dev/ttyUSB1' + assert result['data'] == { + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'ezsp' + } + + +async def test_user_flow_existing_config_entry(hass): + """Test if config entry already exists.""" + MockConfigEntry(domain=DOMAIN, data={ + 'usb_path': '/dev/ttyUSB1' + }).add_to_hass(hass) + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_user() + + assert result['type'] == 'abort' + + +async def test_import_flow(hass): + """Test import from configuration.yaml .""" + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == '/dev/ttyUSB1' + assert result['data'] == { + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee' + } + + +async def test_import_flow_existing_config_entry(hass): + """Test import from configuration.yaml .""" + MockConfigEntry(domain=DOMAIN, data={ + 'usb_path': '/dev/ttyUSB1' + }).add_to_hass(hass) + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee', + }) + + assert result['type'] == 'abort'