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
This commit is contained in:
damarco 2018-11-27 21:21:25 +01:00 committed by Paulus Schoutsen
parent 43676fcaf4
commit 052d305243
14 changed files with 567 additions and 135 deletions

View File

@ -9,6 +9,10 @@ import logging
from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha.entities import ZhaEntity
from homeassistant.components.zha import helpers 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__) _LOGGER = logging.getLogger(__name__)
@ -27,23 +31,43 @@ CLASS_MAPPING = {
async def async_setup_platform(hass, config, async_add_entities, async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None): discovery_info=None):
"""Set up the Zigbee Home Automation binary sensors.""" """Old way of setting up Zigbee Home Automation binary sensors."""
discovery_info = helpers.get_discovery_info(hass, discovery_info) pass
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)
async def _async_setup_iaszone(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities):
discovery_info): """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 device_class = None
from zigpy.zcl.clusters.security import IasZone from zigpy.zcl.clusters.security import IasZone
cluster = discovery_info['in_clusters'][IasZone.cluster_id] 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 # If we fail to read from the device, use a non-specific class
pass pass
sensor = BinarySensor(device_class, **discovery_info) return BinarySensor(device_class, **discovery_info)
async_add_entities([sensor], update_before_add=True)
async def _async_setup_remote(hass, config, async_add_entities, async def _async_setup_remote(discovery_info):
discovery_info):
remote = Remote(**discovery_info) remote = Remote(**discovery_info)
if discovery_info['new_join']: if discovery_info['new_join']:
@ -84,7 +105,7 @@ async def _async_setup_remote(hass, config, async_add_entities,
reportable_change=1 reportable_change=1
) )
async_add_entities([remote], update_before_add=True) return remote
class BinarySensor(ZhaEntity, BinarySensorDevice): class BinarySensor(ZhaEntity, BinarySensorDevice):

View File

@ -7,6 +7,10 @@ at https://home-assistant.io/components/fan.zha/
import logging import logging
from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha.entities import ZhaEntity
from homeassistant.components.zha import helpers 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 ( from homeassistant.components.fan import (
DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH,
SUPPORT_SET_SPEED) 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, async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None): discovery_info=None):
"""Set up the Zigbee Home Automation fans.""" """Old way of setting up Zigbee Home Automation fans."""
discovery_info = helpers.get_discovery_info(hass, discovery_info) pass
if discovery_info is None:
return
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): class ZhaFan(ZhaEntity, FanEntity):

View File

@ -8,6 +8,10 @@ import logging
from homeassistant.components import light from homeassistant.components import light
from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha.entities import ZhaEntity
from homeassistant.components.zha import helpers 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 import homeassistant.util.color as color_util
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -24,27 +28,54 @@ UNSUPPORTED_ATTRIBUTE = 0x86
async def async_setup_platform(hass, config, async_add_entities, async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None): discovery_info=None):
"""Set up the Zigbee Home Automation lights.""" """Old way of setting up Zigbee Home Automation lights."""
discovery_info = helpers.get_discovery_info(hass, discovery_info) pass
if discovery_info is None:
return
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): class Light(ZhaEntity, light.Light):

View File

@ -9,6 +9,10 @@ import logging
from homeassistant.components.sensor import DOMAIN from homeassistant.components.sensor import DOMAIN
from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha.entities import ZhaEntity
from homeassistant.components.zha import helpers 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.const import TEMP_CELSIUS
from homeassistant.util.temperature import convert as convert_temperature 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, async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None): discovery_info=None):
"""Set up Zigbee Home Automation sensors.""" """Old way of setting up Zigbee Home Automation sensors."""
discovery_info = helpers.get_discovery_info(hass, discovery_info) pass
if discovery_info is None:
return
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): async def make_sensor(discovery_info):

View File

@ -6,9 +6,13 @@ at https://home-assistant.io/components/switch.zha/
""" """
import logging import logging
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components.switch import DOMAIN, SwitchDevice
from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha.entities import ZhaEntity
from homeassistant.components.zha import helpers from homeassistant.components.zha import helpers
from homeassistant.components.zha.const import (
ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -17,24 +21,44 @@ DEPENDENCIES = ['zha']
async def async_setup_platform(hass, config, async_add_entities, async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None): 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 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) async_add_entities(entities, update_before_add=True)
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)
class Switch(ZhaEntity, SwitchDevice): class Switch(ZhaEntity, SwitchDevice):

View File

@ -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."
}
}
}

View File

@ -5,51 +5,47 @@ For more details about this component, please refer to the documentation at
https://home-assistant.io/components/zha/ https://home-assistant.io/components/zha/
""" """
import collections import collections
import enum
import logging import logging
import os
import voluptuous as vol import voluptuous as vol
import homeassistant.helpers.config_validation as cv 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.helpers.entity_component import EntityComponent
from homeassistant.components.zha.entities import ZhaDeviceEntity 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 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 = [ REQUIREMENTS = [
'bellows==0.7.0', 'bellows==0.7.0',
'zigpy==0.2.0', 'zigpy==0.2.0',
'zigpy-xbee==0.1.1', '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({ DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({
vol.Optional(ha_const.CONF_TYPE): cv.string, vol.Optional(ha_const.CONF_TYPE): cv.string,
}) })
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: 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, CONF_USB_PATH: cv.string,
vol.Optional(CONF_BAUDRATE, default=57600): cv.positive_int, vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int,
CONF_DATABASE: cv.string, vol.Optional(CONF_DATABASE): cv.string,
vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Optional(CONF_DEVICE_CONFIG, default={}):
vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}), vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}),
}) })
@ -73,8 +69,6 @@ SERVICE_SCHEMAS = {
# Zigbee definitions # Zigbee definitions
CENTICELSIUS = 'C-100' CENTICELSIUS = 'C-100'
# Key in hass.data dict containing discovery info
DISCOVERY_KEY = 'zha_discovery_info'
# Internal definitions # Internal definitions
APPLICATION_CONTROLLER = None APPLICATION_CONTROLLER = None
@ -82,27 +76,58 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup(hass, config): 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. """Set up ZHA.
Will automatically load components to support devices found on the network. Will automatically load components to support devices found on the network.
""" """
global APPLICATION_CONTROLLER global APPLICATION_CONTROLLER
usb_path = config[DOMAIN].get(CONF_USB_PATH) hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {})
baudrate = config[DOMAIN].get(CONF_BAUDRATE) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = []
radio_type = config[DOMAIN].get(CONF_RADIO_TYPE)
if radio_type == RadioType.ezsp: 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 import bellows.ezsp
from bellows.zigbee.application import ControllerApplication from bellows.zigbee.application import ControllerApplication
radio = bellows.ezsp.EZSP() radio = bellows.ezsp.EZSP()
elif radio_type == RadioType.xbee: elif radio_type == RadioType.xbee.name:
import zigpy_xbee.api import zigpy_xbee.api
from zigpy_xbee.zigbee.application import ControllerApplication from zigpy_xbee.zigbee.application import ControllerApplication
radio = zigpy_xbee.api.XBee() radio = zigpy_xbee.api.XBee()
await radio.connect(usb_path, baudrate) 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) APPLICATION_CONTROLLER = ControllerApplication(radio, database)
listener = ApplicationListener(hass, config) listener = ApplicationListener(hass, config)
APPLICATION_CONTROLLER.add_listener(listener) APPLICATION_CONTROLLER.add_listener(listener)
@ -112,6 +137,14 @@ async def async_setup(hass, config):
hass.async_create_task( hass.async_create_task(
listener.async_device_initialized(device, False)) 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): async def permit(service):
"""Allow devices to join this network.""" """Allow devices to join this network."""
duration = service.data.get(ATTR_DURATION) duration = service.data.get(ATTR_DURATION)
@ -132,6 +165,37 @@ async def async_setup(hass, config):
hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove,
schema=SERVICE_SCHEMAS[SERVICE_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 return True
@ -144,9 +208,14 @@ class ApplicationListener:
self._config = config self._config = config
self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._component = EntityComponent(_LOGGER, DOMAIN, hass)
self._device_registry = collections.defaultdict(list) self._device_registry = collections.defaultdict(list)
hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {})
zha_const.populate_data() 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): def device_joined(self, device):
"""Handle device joined. """Handle device joined.
@ -193,8 +262,11 @@ class ApplicationListener:
component = None component = None
profile_clusters = ([], []) profile_clusters = ([], [])
device_key = "{}-{}".format(device.ieee, endpoint_id) device_key = "{}-{}".format(device.ieee, endpoint_id)
node_config = self._config[DOMAIN][CONF_DEVICE_CONFIG].get( node_config = {}
device_key, {}) 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: if endpoint.profile_id in zigpy.profiles.PROFILES:
profile = zigpy.profiles.PROFILES[endpoint.profile_id] profile = zigpy.profiles.PROFILES[endpoint.profile_id]
@ -226,15 +298,17 @@ class ApplicationListener:
'new_join': join, 'new_join': join,
'unique_id': device_key, 'unique_id': device_key,
} }
self._hass.data[DISCOVERY_KEY][device_key] = discovery_info
await discovery.async_load_platform( if join:
self._hass, async_dispatcher_send(
component, self._hass,
DOMAIN, ZHA_DISCOVERY_NEW.format(component),
{'discovery_key': device_key}, discovery_info
self._config, )
) else:
self._hass.data[DATA_ZHA][component][device_key] = (
discovery_info
)
for cluster in endpoint.in_clusters.values(): for cluster in endpoint.in_clusters.values():
await self._attempt_single_cluster_device( await self._attempt_single_cluster_device(
@ -309,12 +383,12 @@ class ApplicationListener:
discovery_info[discovery_attr] = {cluster.cluster_id: cluster} discovery_info[discovery_attr] = {cluster.cluster_id: cluster}
if sub_component: if sub_component:
discovery_info.update({'sub_component': sub_component}) discovery_info.update({'sub_component': sub_component})
self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info
await discovery.async_load_platform( if is_new_join:
self._hass, async_dispatcher_send(
component, self._hass,
DOMAIN, ZHA_DISCOVERY_NEW.format(component),
{'discovery_key': cluster_key}, discovery_info
self._config, )
) else:
self._hass.data[DATA_ZHA][component][cluster_key] = discovery_info

View File

@ -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
)

View File

@ -1,4 +1,51 @@
"""All constants related to the ZHA component.""" """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' DISCOVERY_KEY = 'zha_discovery_info'
DEVICE_CLASS = {} DEVICE_CLASS = {}

View File

@ -5,28 +5,12 @@ For more details about this component, please refer to the documentation at
https://home-assistant.io/components/zha/ https://home-assistant.io/components/zha/
""" """
import logging import logging
import asyncio
from .const import RadioType, DEFAULT_BAUDRATE
_LOGGER = logging.getLogger(__name__) _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): async def safe_read(cluster, attributes, allow_cache=True, only_cache=False):
"""Swallow all exceptions from network read. """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", "%s: failed to set reporting for '%s' attr on '%s' cluster: %s",
entity_id, attr_name, cluster_name, str(ex) 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

View File

@ -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."
}
}
}

View File

@ -158,6 +158,7 @@ FLOWS = [
'twilio', 'twilio',
'unifi', 'unifi',
'upnp', 'upnp',
'zha',
'zone', 'zone',
'zwave' 'zwave'
] ]

View File

@ -0,0 +1 @@
"""Tests for the ZHA component."""

View File

@ -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'