From 4f5f5c989025a04e75298ca37bedf445dc8e95af Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 9 Apr 2020 13:16:42 -0400 Subject: [PATCH 01/23] Start gateway using new zigpy init. Update config entry data import. Use new zigpy startup. Fix config entry import without zha config section. Auto form Zigbee network. --- homeassistant/components/zha/__init__.py | 40 ++++++++------- homeassistant/components/zha/config_flow.py | 26 +++++----- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/gateway.py | 52 ++++++++------------ 4 files changed, 58 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 9e59b63adb4eae..e75ff48915e351 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -4,6 +4,7 @@ import logging import voluptuous as vol +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries, const as ha_const import homeassistant.helpers.config_validation as cv @@ -21,37 +22,38 @@ CONF_ENABLE_QUIRKS, CONF_RADIO_TYPE, CONF_USB_PATH, + CONF_ZIGPY, DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_DISPATCHERS, DATA_ZHA_GATEWAY, DATA_ZHA_PLATFORM_LOADED, - DEFAULT_BAUDRATE, - DEFAULT_RADIO_TYPE, DOMAIN, SIGNAL_ADD_ENTITIES, - RadioType, ) from .core.discovery import GROUP_PROBE DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(ha_const.CONF_TYPE): cv.string}) - +ZHA_CONFIG_SCHEMA = { + vol.Optional(CONF_BAUDRATE): cv.positive_int, + vol.Optional(CONF_DATABASE): cv.string, + vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema( + {cv.string: DEVICE_CONFIG_SCHEMA_ENTRY} + ), + vol.Optional(CONF_ENABLE_QUIRKS, default=True): cv.boolean, + vol.Optional(CONF_ZIGPY): dict, +} CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( - { - vol.Optional(CONF_RADIO_TYPE, default=DEFAULT_RADIO_TYPE): cv.enum( - RadioType - ), - CONF_USB_PATH: 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} - ), - vol.Optional(CONF_ENABLE_QUIRKS, default=True): cv.boolean, - } - ) + vol.All( + cv.deprecated(CONF_USB_PATH, invalidation_version="0.112"), + cv.deprecated(CONF_BAUDRATE, invalidation_version="0.112"), + cv.deprecated(CONF_RADIO_TYPE, invalidation_version="0.112"), + ZHA_CONFIG_SCHEMA, + ), + extra=vol.ALLOW_EXTRA, + ), }, extra=vol.ALLOW_EXTRA, ) @@ -79,8 +81,8 @@ async def async_setup(hass, config): DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ - CONF_USB_PATH: conf[CONF_USB_PATH], - CONF_RADIO_TYPE: conf.get(CONF_RADIO_TYPE).value, + CONF_DEVICE: conf[CONF_DEVICE], + CONF_RADIO_TYPE: conf[CONF_RADIO_TYPE].value, }, ) ) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5ee0d0ee9bbf21..d34e6a0fb91d77 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -4,17 +4,15 @@ import os import voluptuous as vol +from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries from .core.const import ( CONF_RADIO_TYPE, - CONF_USB_PATH, CONTROLLER, - DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DOMAIN, - ZHA_GW_RADIO, RadioType, ) from .core.registries import RADIO_TYPES @@ -35,17 +33,21 @@ async def async_step_user(self, user_input=None): errors = {} fields = OrderedDict() - fields[vol.Required(CONF_USB_PATH)] = str + fields[vol.Required(CONF_DEVICE_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 + user_input[CONF_DEVICE_PATH], user_input[CONF_RADIO_TYPE], database ) if test: return self.async_create_entry( - title=user_input[CONF_USB_PATH], data=user_input + title=user_input[CONF_DEVICE_PATH], + data={ + CONF_DEVICE: {CONF_DEVICE_PATH: user_input[CONF_DEVICE_PATH]}, + CONF_RADIO_TYPE: user_input[CONF_RADIO_TYPE], + }, ) errors["base"] = "cannot_connect" @@ -59,21 +61,23 @@ async def async_step_import(self, import_info): return self.async_abort(reason="single_instance_allowed") return self.async_create_entry( - title=import_info[CONF_USB_PATH], data=import_info + title=import_info[CONF_DEVICE][CONF_DEVICE_PATH], data=import_info ) async def check_zigpy_connection(usb_path, radio_type, database_path): """Test zigpy radio connection.""" try: - radio = RADIO_TYPES[radio_type][ZHA_GW_RADIO]() controller_application = RADIO_TYPES[radio_type][CONTROLLER] except KeyError: return False try: - await radio.connect(usb_path, DEFAULT_BAUDRATE) - controller = controller_application(radio, database_path) - await asyncio.wait_for(controller.startup(auto_form=True), timeout=30) + config = controller_application.SCHEMA( + {CONF_DEVICE: {CONF_DEVICE_PATH: usb_path}, CONF_DATABASE: database_path} + ) + controller = await asyncio.wait_for( + controller_application.new(config), timeout=30 + ) await controller.shutdown() except Exception: # pylint: disable=broad-except return False diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index b181b848f04753..e74f5592908b34 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -94,6 +94,7 @@ CONF_ENABLE_QUIRKS = "enable_quirks" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" +CONF_ZIGPY = "zigpy_config" CONTROLLER = "controller" DATA_DEVICE_CONFIG = "zha_device_config" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index b8efdf873b133f..c340ab99473f5b 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -10,6 +10,7 @@ from typing import List, Optional from serial import SerialException +from zigpy.config import CONF_DEVICE import zigpy.device as zigpy_dev from homeassistant.components.system_log import LogEntry, _figure_out_source @@ -33,10 +34,9 @@ ATTR_NWK, ATTR_SIGNATURE, ATTR_TYPE, - CONF_BAUDRATE, CONF_DATABASE, CONF_RADIO_TYPE, - CONF_USB_PATH, + CONF_ZIGPY, CONTROLLER, DATA_ZHA, DATA_ZHA_BRIDGE_ID, @@ -52,7 +52,6 @@ DEBUG_LEVEL_ORIGINAL, DEBUG_LEVELS, DEBUG_RELAY_LOGGERS, - DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DOMAIN, SIGNAL_ADD_ENTITIES, @@ -74,7 +73,6 @@ ZHA_GW_MSG_LOG_ENTRY, ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_RAW_INIT, - ZHA_GW_RADIO, ZHA_GW_RADIO_DESCRIPTION, ) from .device import DeviceStatus, ZHADevice @@ -125,43 +123,35 @@ async def async_initialize(self): self.ha_device_registry = await get_dev_reg(self._hass) self.ha_entity_registry = await get_ent_reg(self._hass) - usb_path = self._config_entry.data.get(CONF_USB_PATH) - baudrate = self._config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE) - radio_type = self._config_entry.data.get(CONF_RADIO_TYPE) + radio_type = self._config_entry.data[CONF_RADIO_TYPE] - radio_details = RADIO_TYPES[radio_type] - radio = radio_details[ZHA_GW_RADIO]() - self.radio_description = radio_details[ZHA_GW_RADIO_DESCRIPTION] - try: - await radio.connect(usb_path, baudrate) - except (SerialException, OSError) as exception: - _LOGGER.error("Couldn't open serial port for ZHA: %s", str(exception)) - raise ConfigEntryNotReady - - if CONF_DATABASE in self._config: - database = self._config[CONF_DATABASE] - else: - database = os.path.join(self._hass.config.config_dir, DEFAULT_DATABASE_NAME) + app_controller_cls = RADIO_TYPES[radio_type][CONTROLLER] + self.radio_description = RADIO_TYPES[radio_type][ZHA_GW_RADIO_DESCRIPTION] - self.application_controller = radio_details[CONTROLLER](radio, database) - apply_application_controller_patch(self) - self.application_controller.add_listener(self) - self.application_controller.groups.add_listener(self) + app_config = self._config.get(CONF_ZIGPY, {}) + database = self._config.get( + CONF_DATABASE, + os.path.join(self._hass.config.config_dir, DEFAULT_DATABASE_NAME), + ) + app_config[CONF_DATABASE] = database + app_config[CONF_DEVICE] = self._config_entry.data[CONF_DEVICE] + app_config = app_controller_cls.SCHEMA(app_config) try: - res = await self.application_controller.startup(auto_form=True) - if res is False: - await self.application_controller.shutdown() - raise ConfigEntryNotReady - except asyncio.TimeoutError as exception: + self.application_controller = await app_controller_cls.new( + app_config, auto_form=True, start_radio=True + ) + except (asyncio.TimeoutError, SerialException, OSError) as exception: _LOGGER.error( "Couldn't start %s coordinator", - radio_details[ZHA_GW_RADIO_DESCRIPTION], + self.radio_description, exc_info=exception, ) - radio.close() raise ConfigEntryNotReady from exception + apply_application_controller_patch(self) + self.application_controller.add_listener(self) + self.application_controller.groups.add_listener(self) self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str( self.application_controller.ieee From 3a7475f9aae73786ca29d98e12325b92469fc766 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Sun, 12 Apr 2020 15:39:17 -0400 Subject: [PATCH 02/23] Migrate config entry. --- homeassistant/components/zha/__init__.py | 27 +++++++++++++++++++++ homeassistant/components/zha/config_flow.py | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e75ff48915e351..b2a77b18a72d85 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -15,6 +15,7 @@ from . import api from .core import ZHAGateway from .core.const import ( + BAUD_RATES, COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, @@ -30,6 +31,7 @@ DATA_ZHA_PLATFORM_LOADED, DOMAIN, SIGNAL_ADD_ENTITIES, + RadioType, ) from .core.discovery import GROUP_PROBE @@ -163,3 +165,28 @@ async def async_load_entities(hass: HomeAssistantType) -> None: if isinstance(res, Exception): _LOGGER.warning("Couldn't setup zha platform: %s", res) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) + + +async def async_migrate_entry( + hass: HomeAssistantType, config_entry: config_entries.ConfigEntry +): + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + config_entry.data = { + CONF_RADIO_TYPE: config_entry.data[CONF_RADIO_TYPE], + CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]}, + } + + baudrate = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}).get(CONF_BAUDRATE) + if ( + config_entry.data[CONF_RADIO_TYPE] != RadioType.deconz + and baudrate in BAUD_RATES + ): + config_entry.data[CONF_DEVICE][CONF_BAUDRATE] = baudrate + + config_entry.version = 2 + + _LOGGER.info("Migration to version %s successful", config_entry.version) + return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index d34e6a0fb91d77..cff6549cde025d 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -22,7 +22,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow): """Handle a config flow.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH async def async_step_user(self, user_input=None): From 0183b26015c856fbfd9709b2806bc53609f2545e Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Sun, 12 Apr 2020 19:20:17 -0400 Subject: [PATCH 03/23] New ZHA config entry flow. Use lightweight probe() method for ZHA config entry validation when available. Failback to old behavior of setting up Zigpy app if radio lib does not provide probing. --- homeassistant/components/zha/config_flow.py | 122 ++++++++++++-------- homeassistant/components/zha/core/const.py | 4 +- homeassistant/components/zha/strings.json | 16 ++- 3 files changed, 91 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index cff6549cde025d..67522665c29904 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,59 +1,58 @@ """Config flow for ZHA.""" -import asyncio -from collections import OrderedDict -import os +from typing import Any, Dict, Optional +import serial.tools.list_ports import voluptuous as vol -from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries -from .core.const import ( - CONF_RADIO_TYPE, - CONTROLLER, - DEFAULT_DATABASE_NAME, - DOMAIN, - RadioType, -) +from .core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN, RadioType from .core.registries import RADIO_TYPES +CONF_MANUAL_PATH = "Enter Manually" -@config_entries.HANDLERS.register(DOMAIN) -class ZhaFlowHandler(config_entries.ConfigFlow): + +class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + def __init__(self): + """Initialize flow instance.""" + self._device_path = None + self._radio_type = None + 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_DEVICE_PATH)] = str - fields[vol.Optional(CONF_RADIO_TYPE, default="ezsp")] = vol.In(RadioType.list()) + ports = serial.tools.list_ports.comports() + list_of_ports = [ + f"{p}, s/n: {p.serial_number} - {p.manufacturer}" for p in ports + ] + list_of_ports.append(CONF_MANUAL_PATH) 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_DEVICE_PATH], user_input[CONF_RADIO_TYPE], database - ) - if test: + user_selection = user_input[CONF_DEVICE_PATH] + if user_selection == CONF_MANUAL_PATH: + return await self.async_step_pick_radio() + + dev_path = ports[list_of_ports.index(user_selection)].device + auto_detected_data = await self.detect_radios(dev_path) + if auto_detected_data is not None: return self.async_create_entry( - title=user_input[CONF_DEVICE_PATH], - data={ - CONF_DEVICE: {CONF_DEVICE_PATH: user_input[CONF_DEVICE_PATH]}, - CONF_RADIO_TYPE: user_input[CONF_RADIO_TYPE], - }, + title=user_selection, data=auto_detected_data, ) - errors["base"] = "cannot_connect" - return self.async_show_form( - step_id="user", data_schema=vol.Schema(fields), errors=errors - ) + # did not detect anything + self._device_path = dev_path + return await self.async_step_pick_radio() + + schema = vol.Schema({vol.Required(CONF_DEVICE_PATH): vol.In(list_of_ports)}) + return self.async_show_form(step_id="user", data_schema=schema) async def async_step_import(self, import_info): """Handle a zha config import.""" @@ -64,21 +63,50 @@ async def async_step_import(self, import_info): title=import_info[CONF_DEVICE][CONF_DEVICE_PATH], data=import_info ) + async def async_step_pick_radio(self, user_input=None): + """Select radio type.""" -async def check_zigpy_connection(usb_path, radio_type, database_path): - """Test zigpy radio connection.""" - try: - controller_application = RADIO_TYPES[radio_type][CONTROLLER] - except KeyError: - return False - try: - config = controller_application.SCHEMA( - {CONF_DEVICE: {CONF_DEVICE_PATH: usb_path}, CONF_DATABASE: database_path} + if user_input is not None: + self._radio_type = user_input[CONF_RADIO_TYPE] + return await self.async_step_port_config() + + schema = {vol.Required(CONF_RADIO_TYPE): vol.In(sorted(RadioType.list()))} + return self.async_show_form( + step_id="pick_radio", data_schema=vol.Schema(schema), ) - controller = await asyncio.wait_for( - controller_application.new(config), timeout=30 + + async def async_step_port_config(self, user_input=None): + """Enter port settings specific for this type of radio.""" + errors = {} + app_cls = RADIO_TYPES[self._radio_type][CONTROLLER] + + if user_input is not None: + self._device_path = user_input.get(CONF_DEVICE_PATH) + if await app_cls.probe(user_input): + return self.async_create_entry( + title=user_input[CONF_DEVICE_PATH], + data={CONF_DEVICE: user_input, CONF_RADIO_TYPE: self._radio_type}, + ) + errors["base"] = "cannot_connect" + + schema = { + vol.Required( + CONF_DEVICE_PATH, default=self._device_path or vol.UNDEFINED + ): str + } + return self.async_show_form( + step_id="port_config", + data_schema=app_cls.SCHEMA_DEVICE.extend(schema), + errors=errors, ) - await controller.shutdown() - except Exception: # pylint: disable=broad-except - return False - return True + + @staticmethod + async def detect_radios(dev_path: str) -> Optional[Dict[str, Any]]: + """Probe all radio types on the device port.""" + for radio in RadioType.list(): + app_cls = RADIO_TYPES[radio][CONTROLLER] + dev_config = app_cls.SCHEMA_DEVICE({CONF_DEVICE_PATH: dev_path}) + if await app_cls.probe(dev_config): + return {CONF_RADIO_TYPE: radio, CONF_DEVICE: dev_config} + + return None diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index e74f5592908b34..b704f8e59cd0fd 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -146,11 +146,11 @@ class RadioType(enum.Enum): """Possible options for radio type.""" - deconz = "deconz" ezsp = "ezsp" + deconz = "deconz" ti_cc = "ti_cc" - xbee = "xbee" zigate = "zigate" + xbee = "xbee" @classmethod def list(cls): diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 6906b5b3e8cf88..b26cebbd40a61f 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -3,9 +3,21 @@ "step": { "user": { "title": "ZHA", + "data": { "path": "Serial Device Path" }, + "description": "Select serial port for Zigbee radio" + }, + "pick_radio": { + "data": { "radio_type": "Radio Type" }, + "title": "Radio Type", + "description": "Pick a type of your Zigbee radio" + }, + "port_config": { + "title": "Settings", + "description": "Enter port specific settings", "data": { - "radio_type": "Radio Type", - "usb_path": "[%key:common::config_flow::data::usb_path%]" + "path": "Serial device path", + "baudrate": "port speed", + "flow_control": "data flow control" } } }, From 43f6225420e9350e8e020bf9b138a632a09c6920 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 29 Apr 2020 22:40:31 -0400 Subject: [PATCH 04/23] Clean ZHA_GW_RADIO --- homeassistant/components/zha/core/const.py | 1 - homeassistant/components/zha/core/registries.py | 12 +----------- tests/components/zha/conftest.py | 1 - tests/components/zha/test_config_flow.py | 8 +++----- 4 files changed, 4 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index b704f8e59cd0fd..a53c3a979b9a68 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -259,7 +259,6 @@ def list(cls): ZHA_GW_MSG_LOG_ENTRY = "log_entry" ZHA_GW_MSG_LOG_OUTPUT = "log_output" ZHA_GW_MSG_RAW_INIT = "raw_device_initialized" -ZHA_GW_RADIO = "radio" ZHA_GW_RADIO_DESCRIPTION = "radio_description" EFFECT_BLINK = 0x00 diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 29b7134324566e..9ce3f9df30d878 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -3,18 +3,13 @@ from typing import Callable, Dict, List, Set, Tuple, Union import attr -import bellows.ezsp import bellows.zigbee.application import zigpy.profiles.zha import zigpy.profiles.zll import zigpy.zcl as zcl -import zigpy_cc.api import zigpy_cc.zigbee.application -import zigpy_deconz.api import zigpy_deconz.zigbee.application -import zigpy_xbee.api import zigpy_xbee.zigbee.application -import zigpy_zigate.api import zigpy_zigate.zigbee.application from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR @@ -28,7 +23,7 @@ # importing channels updates registries from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import -from .const import CONTROLLER, ZHA_GW_RADIO, ZHA_GW_RADIO_DESCRIPTION, RadioType +from .const import CONTROLLER, ZHA_GW_RADIO_DESCRIPTION, RadioType from .decorators import CALLABLE_T, DictRegistry, SetRegistry from .typing import ChannelType @@ -131,27 +126,22 @@ RADIO_TYPES = { RadioType.deconz.name: { - ZHA_GW_RADIO: zigpy_deconz.api.Deconz, CONTROLLER: zigpy_deconz.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "Deconz", }, RadioType.ezsp.name: { - ZHA_GW_RADIO: bellows.ezsp.EZSP, CONTROLLER: bellows.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "EZSP", }, RadioType.ti_cc.name: { - ZHA_GW_RADIO: zigpy_cc.api.API, CONTROLLER: zigpy_cc.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "TI CC", }, RadioType.xbee.name: { - ZHA_GW_RADIO: zigpy_xbee.api.XBee, CONTROLLER: zigpy_xbee.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "XBee", }, RadioType.zigate.name: { - ZHA_GW_RADIO: zigpy_zigate.api.ZiGate, CONTROLLER: zigpy_zigate.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "ZiGate", }, diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index e737f990163516..71020cd9db45e2 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -67,7 +67,6 @@ def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio): zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} radio_details = { - zha_const.ZHA_GW_RADIO: mock.MagicMock(return_value=zigpy_radio), zha_const.CONTROLLER: mock.MagicMock(return_value=zigpy_app_controller), zha_const.ZHA_GW_RADIO_DESCRIPTION: "mock radio", } diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 91d2ef75aa5349..32c875975414ef 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -2,7 +2,7 @@ from unittest import mock from homeassistant.components.zha import config_flow -from homeassistant.components.zha.core.const import CONTROLLER, DOMAIN, ZHA_GW_RADIO +from homeassistant.components.zha.core.const import CONTROLLER, DOMAIN import homeassistant.components.zha.core.registries import tests.async_mock @@ -80,19 +80,17 @@ async def test_check_zigpy_connection(): mock_radio = tests.async_mock.MagicMock() mock_radio.connect = tests.async_mock.AsyncMock() - radio_cls = tests.async_mock.MagicMock(return_value=mock_radio) bad_radio = tests.async_mock.MagicMock() bad_radio.connect = tests.async_mock.AsyncMock(side_effect=Exception) - bad_radio_cls = tests.async_mock.MagicMock(return_value=bad_radio) mock_ctrl = tests.async_mock.MagicMock() mock_ctrl.startup = tests.async_mock.AsyncMock() mock_ctrl.shutdown = tests.async_mock.AsyncMock() ctrl_cls = tests.async_mock.MagicMock(return_value=mock_ctrl) new_radios = { - mock.sentinel.radio: {ZHA_GW_RADIO: radio_cls, CONTROLLER: ctrl_cls}, - mock.sentinel.bad_radio: {ZHA_GW_RADIO: bad_radio_cls, CONTROLLER: ctrl_cls}, + mock.sentinel.radio: {CONTROLLER: ctrl_cls}, + mock.sentinel.bad_radio: {CONTROLLER: ctrl_cls}, } with mock.patch.dict( From 680429138371f60377a5aa70278ea766e065a267 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 30 Apr 2020 17:23:34 -0400 Subject: [PATCH 05/23] Don't import ZHA device settings. --- homeassistant/components/zha/__init__.py | 21 ++++----------------- homeassistant/components/zha/config_flow.py | 9 --------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index b2a77b18a72d85..eb5f6ef5985aa1 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -71,23 +71,10 @@ 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_DEVICE: conf[CONF_DEVICE], - CONF_RADIO_TYPE: conf[CONF_RADIO_TYPE].value, - }, - ) - ) + if DOMAIN in config: + conf = config[DOMAIN] + hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf + return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 67522665c29904..a79278ce2b76f0 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -54,15 +54,6 @@ async def async_step_user(self, user_input=None): schema = vol.Schema({vol.Required(CONF_DEVICE_PATH): vol.In(list_of_ports)}) return self.async_show_form(step_id="user", data_schema=schema) - 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_DEVICE][CONF_DEVICE_PATH], data=import_info - ) - async def async_step_pick_radio(self, user_input=None): """Select radio type.""" From e7b8cad459972f81c0363ce438d2ff9af309069c Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 30 Apr 2020 21:46:37 -0400 Subject: [PATCH 06/23] Update config flow tests. --- .coveragerc | 1 - tests/components/zha/conftest.py | 12 +- tests/components/zha/test_config_flow.py | 169 +++++++++++++---------- tests/components/zha/test_init.py | 69 +++++++++ 4 files changed, 172 insertions(+), 79 deletions(-) create mode 100644 tests/components/zha/test_init.py diff --git a/.coveragerc b/.coveragerc index 251fe05c01446b..8de6d1458a0393 100644 --- a/.coveragerc +++ b/.coveragerc @@ -860,7 +860,6 @@ omit = homeassistant/components/zengge/light.py homeassistant/components/zeroconf/* homeassistant/components/zestimate/sensor.py - homeassistant/components/zha/__init__.py homeassistant/components/zha/api.py homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/const.py diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 71020cd9db45e2..a1783173cf9e3b 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -4,6 +4,7 @@ import pytest import zigpy from zigpy.application import ControllerApplication +import zigpy.config import zigpy.group import zigpy.types @@ -49,12 +50,11 @@ def zigpy_radio(): async def config_entry_fixture(hass): """Fixture representing a config entry.""" entry = MockConfigEntry( - version=1, + version=2, domain=zha_const.DOMAIN, data={ - zha_const.CONF_BAUDRATE: zha_const.DEFAULT_BAUDRATE, + zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB0"}, zha_const.CONF_RADIO_TYPE: "MockRadio", - zha_const.CONF_USB_PATH: "/dev/ttyUSB0", }, ) entry.add_to_hass(hass) @@ -65,9 +65,13 @@ async def config_entry_fixture(hass): def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio): """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} + app_ctrl = mock.MagicMock() + app_ctrl.new = CoroutineMock(return_value=zigpy_app_controller) + app_ctrl.SCHEMA = zigpy.config.CONFIG_SCHEMA + app_ctrl.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE radio_details = { - zha_const.CONTROLLER: mock.MagicMock(return_value=zigpy_app_controller), + zha_const.CONTROLLER: app_ctrl, zha_const.ZHA_GW_RADIO_DESCRIPTION: "mock radio", } diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 32c875975414ef..3f98d8ace9bc8c 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,123 +1,144 @@ """Tests for ZHA config flow.""" + from unittest import mock +import serial.tools.list_ports +import zigpy.config + from homeassistant.components.zha import config_flow -from homeassistant.components.zha.core.const import CONTROLLER, DOMAIN -import homeassistant.components.zha.core.registries +from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN import tests.async_mock from tests.common import MockConfigEntry +def com_port(): + """Mock of a serial port.""" + port = serial.tools.list_ports_common.ListPortInfo() + port.serial_number = "1234" + port.manufacturer = "Virtual serial port" + port.device = "/dev/ttyUSB1" + port.description = "Some serial port" + + return port + + +@mock.patch( + "serial.tools.list_ports.comports", mock.MagicMock(return_value=[com_port()]) +) async def test_user_flow(hass): - """Test that config flow works.""" + """Test user flow.""" flow = config_flow.ZhaFlowHandler() flow.hass = hass - with tests.async_mock.patch( - "homeassistant.components.zha.config_flow.check_zigpy_connection", - return_value=False, + port = com_port() + port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}" + + with mock.patch.object( + flow, "detect_radios", return_value=mock.sentinel.data, ): result = await flow.async_step_user( - user_input={"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"} + user_input={zigpy.config.CONF_DEVICE_PATH: port_select} ) + assert result["type"] == "create_entry" + assert result["title"].startswith(port.device) + assert result["data"] is mock.sentinel.data - assert result["errors"] == {"base": "cannot_connect"} - - with tests.async_mock.patch( - "homeassistant.components.zha.config_flow.check_zigpy_connection", - return_value=True, + with mock.patch.object( + flow, "detect_radios", return_value=None, ): result = await flow.async_step_user( - user_input={"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"} + user_input={zigpy.config.CONF_DEVICE_PATH: port_select} ) - assert result["type"] == "create_entry" - assert result["title"] == "/dev/ttyUSB1" - assert result["data"] == {"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"} + assert result["type"] == "form" + assert result["step_id"] == "pick_radio" + await flow.async_step_user() -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) + +async def test_user_flow_manual(hass): + """Test user flow manual entry.""" flow = config_flow.ZhaFlowHandler() flow.hass = hass - result = await flow.async_step_user() - - assert result["type"] == "abort" + result = await flow.async_step_user( + user_input={zigpy.config.CONF_DEVICE_PATH: config_flow.CONF_MANUAL_PATH} + ) + assert result["type"] == "form" + assert result["step_id"] == "pick_radio" -async def test_import_flow(hass): - """Test import from configuration.yaml .""" +async def test_pick_radio_flow(hass): + """Test radio picker.""" flow = config_flow.ZhaFlowHandler() flow.hass = hass - result = await flow.async_step_import( - {"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"} - ) + result = await flow.async_step_pick_radio({CONF_RADIO_TYPE: "ezsp"}) + assert result["type"] == "form" + assert result["step_id"] == "port_config" - assert result["type"] == "create_entry" - assert result["title"] == "/dev/ttyUSB1" - assert result["data"] == {"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"} + await flow.async_step_pick_radio() -async def test_import_flow_existing_config_entry(hass): - """Test import from configuration.yaml .""" +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_import( - {"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"} - ) + result = await flow.async_step_user() assert result["type"] == "abort" -async def test_check_zigpy_connection(): - """Test config flow validator.""" +async def test_probe_radios(hass): + """Test detect radios.""" + app_ctrl_cls = mock.MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.probe = tests.async_mock.AsyncMock(side_effect=(True, False)) + + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + with mock.patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + res = await flow.detect_radios("/dev/null") + assert app_ctrl_cls.probe.await_count == 1 + assert res[CONF_RADIO_TYPE] == "ezsp" + assert zigpy.config.CONF_DEVICE in res + assert ( + res[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] == "/dev/null" + ) - mock_radio = tests.async_mock.MagicMock() - mock_radio.connect = tests.async_mock.AsyncMock() + res = await flow.detect_radios("/dev/null") + assert res is None - bad_radio = tests.async_mock.MagicMock() - bad_radio.connect = tests.async_mock.AsyncMock(side_effect=Exception) - mock_ctrl = tests.async_mock.MagicMock() - mock_ctrl.startup = tests.async_mock.AsyncMock() - mock_ctrl.shutdown = tests.async_mock.AsyncMock() - ctrl_cls = tests.async_mock.MagicMock(return_value=mock_ctrl) - new_radios = { - mock.sentinel.radio: {CONTROLLER: ctrl_cls}, - mock.sentinel.bad_radio: {CONTROLLER: ctrl_cls}, - } +async def test_user_port_config_fail(hass): + """Test port config flow.""" + app_ctrl_cls = mock.MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.probe = tests.async_mock.AsyncMock(side_effect=(False, True)) - with mock.patch.dict( - homeassistant.components.zha.core.registries.RADIO_TYPES, new_radios, clear=True - ): - assert not await config_flow.check_zigpy_connection( - mock.sentinel.usb_path, mock.sentinel.unk_radio, mock.sentinel.zigbee_db + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + await flow.async_step_pick_radio(user_input={CONF_RADIO_TYPE: "ezsp"}) + + with mock.patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + result = await flow.async_step_port_config( + {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"} ) - assert mock_radio.connect.call_count == 0 - assert bad_radio.connect.call_count == 0 - assert mock_ctrl.startup.call_count == 0 - assert mock_ctrl.shutdown.call_count == 0 - - # unsuccessful radio connect - assert not await config_flow.check_zigpy_connection( - mock.sentinel.usb_path, mock.sentinel.bad_radio, mock.sentinel.zigbee_db + assert result["type"] == "form" + assert result["step_id"] == "port_config" + assert result["errors"]["base"] == "cannot_connect" + + result = await flow.async_step_port_config( + {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"} ) - assert mock_radio.connect.call_count == 0 - assert bad_radio.connect.call_count == 1 - assert mock_ctrl.startup.call_count == 0 - assert mock_ctrl.shutdown.call_count == 0 - - # successful radio connect - assert await config_flow.check_zigpy_connection( - mock.sentinel.usb_path, mock.sentinel.radio, mock.sentinel.zigbee_db + assert result["type"] == "create_entry" + assert result["title"].startswith("/dev/ttyUSB33") + assert ( + result["data"][zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] + == "/dev/ttyUSB33" ) - assert mock_radio.connect.call_count == 1 - assert bad_radio.connect.call_count == 1 - assert mock_ctrl.startup.call_count == 1 - assert mock_ctrl.shutdown.call_count == 1 + assert result["data"][CONF_RADIO_TYPE] == "ezsp" diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py new file mode 100644 index 00000000000000..a722f070eef1fb --- /dev/null +++ b/tests/components/zha/test_init.py @@ -0,0 +1,69 @@ +"""Tests for ZHA integration init.""" + +import pytest +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH + +import homeassistant.components.zha +from homeassistant.components.zha.core.const import ( + CONF_BAUDRATE, + CONF_RADIO_TYPE, + CONF_USB_PATH, + DOMAIN, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +DATA_RADIO_TYPE = "deconz" +DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0" + + +@pytest.fixture +def config_entry_v1(hass): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_RADIO_TYPE: DATA_RADIO_TYPE, CONF_USB_PATH: DATA_PORT_PATH}, + version=1, + ) + + +@pytest.mark.parametrize("config", ({}, {DOMAIN: {}})) +async def test_migration_from_v1_no_baudrate(hass, config_entry_v1, config): + """Test migration of config entry from v1.""" + assert await async_setup_component(hass, DOMAIN, config) + await homeassistant.components.zha.async_migrate_entry(hass, config_entry_v1) + + assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert CONF_DEVICE in config_entry_v1.data + assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH + assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] + assert CONF_USB_PATH not in config_entry_v1.data + assert config_entry_v1.version == 2 + + +async def test_migration_from_v1_with_baudrate(hass, config_entry_v1): + """Test migration of config entry from v1 with baudrate in config.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_BAUDRATE: 115200}}) + await homeassistant.components.zha.async_migrate_entry(hass, config_entry_v1) + + assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert CONF_DEVICE in config_entry_v1.data + assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH + assert CONF_USB_PATH not in config_entry_v1.data + assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE] + assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200 + assert config_entry_v1.version == 2 + + +async def test_migration_from_v1_wrong_baudrate(hass, config_entry_v1): + """Test migration of config entry from v1 with wrong baudrate.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_BAUDRATE: 115222}}) + await homeassistant.components.zha.async_migrate_entry(hass, config_entry_v1) + + assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert CONF_DEVICE in config_entry_v1.data + assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH + assert CONF_USB_PATH not in config_entry_v1.data + assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] + assert config_entry_v1.version == 2 From e4a05525343dba91f919195f84c79086dc13a533 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 1 May 2020 14:26:15 -0400 Subject: [PATCH 07/23] Filter out empty manufacturer. --- homeassistant/components/zha/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index a79278ce2b76f0..1b38657f4407ce 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -29,9 +29,11 @@ async def async_step_user(self, user_input=None): if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - ports = serial.tools.list_ports.comports() + ports = serial.tools.list_ports.comports(include_links=False) list_of_ports = [ - f"{p}, s/n: {p.serial_number} - {p.manufacturer}" for p in ports + f"{p}, s/n: {p.serial_number or 'n/a'}" + + (f" - {p.manufacturer}" if p.manufacturer else "") + for p in ports ] list_of_ports.append(CONF_MANUAL_PATH) From 0c5ac74804809d55efc8ce28ff09ee93a5aba2ae Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 1 May 2020 16:27:33 -0400 Subject: [PATCH 08/23] Replace port path with an by-id device name. --- homeassistant/components/zha/config_flow.py | 24 +++++++-- .../components/zha/translations/en.json | 20 ++++++- tests/components/zha/test_config_flow.py | 52 ++++++++++++++++++- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1b38657f4407ce..124b062d4fa889 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,4 +1,5 @@ """Config flow for ZHA.""" +import os from typing import Any, Dict, Optional import serial.tools.list_ports @@ -42,12 +43,13 @@ async def async_step_user(self, user_input=None): if user_selection == CONF_MANUAL_PATH: return await self.async_step_pick_radio() - dev_path = ports[list_of_ports.index(user_selection)].device + port = ports[list_of_ports.index(user_selection)] + dev_path = get_serial_by_id(port.device) auto_detected_data = await self.detect_radios(dev_path) if auto_detected_data is not None: - return self.async_create_entry( - title=user_selection, data=auto_detected_data, - ) + title = f"{port.description}, s/n: {port.serial_number or 'n/a'}" + title += f" - {port.manufacturer}" if port.manufacturer else "" + return self.async_create_entry(title=title, data=auto_detected_data,) # did not detect anything self._device_path = dev_path @@ -76,6 +78,8 @@ async def async_step_port_config(self, user_input=None): if user_input is not None: self._device_path = user_input.get(CONF_DEVICE_PATH) if await app_cls.probe(user_input): + serial_by_id = get_serial_by_id(user_input[CONF_DEVICE_PATH]) + user_input[CONF_DEVICE_PATH] = serial_by_id return self.async_create_entry( title=user_input[CONF_DEVICE_PATH], data={CONF_DEVICE: user_input, CONF_RADIO_TYPE: self._radio_type}, @@ -103,3 +107,15 @@ async def detect_radios(dev_path: str) -> Optional[Dict[str, Any]]: return {CONF_RADIO_TYPE: radio, CONF_DEVICE: dev_config} return None + + +def get_serial_by_id(dev_path: str) -> str: + """Return a /dev/serial/by-id match for given device if available.""" + by_id = "/dev/serial/by-id" + if not os.path.isdir(by_id): + return dev_path + + for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + if os.path.realpath(path) == dev_path: + return path + return dev_path diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index d8db817507dd3d..6a1eb4bac8ec1e 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -7,11 +7,27 @@ "cannot_connect": "Unable to connect to ZHA device." }, "step": { + "pick_radio": { + "data": { + "radio_type": "Radio Type" + }, + "description": "Pick a type of your Zigbee radio", + "title": "Radio Type" + }, + "port_config": { + "data": { + "baudrate": "port speed", + "flow_control": "data flow control", + "path": "Serial device path" + }, + "description": "Enter port specific settings", + "title": "Settings" + }, "user": { "data": { - "radio_type": "Radio Type", - "usb_path": "USB Device Path" + "path": "Serial Device Path" }, + "description": "Select serial port for Zigbee radio", "title": "ZHA" } } diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 3f98d8ace9bc8c..5f1133368f87dd 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for ZHA config flow.""" +import os from unittest import mock import serial.tools.list_ports @@ -17,7 +18,7 @@ def com_port(): port = serial.tools.list_ports_common.ListPortInfo() port.serial_number = "1234" port.manufacturer = "Virtual serial port" - port.device = "/dev/ttyUSB1" + port.device = "/dev/ttyUSB1234" port.description = "Some serial port" return port @@ -41,7 +42,7 @@ async def test_user_flow(hass): user_input={zigpy.config.CONF_DEVICE_PATH: port_select} ) assert result["type"] == "create_entry" - assert result["title"].startswith(port.device) + assert result["title"].startswith(port.description) assert result["data"] is mock.sentinel.data with mock.patch.object( @@ -142,3 +143,50 @@ async def test_user_port_config_fail(hass): == "/dev/ttyUSB33" ) assert result["data"][CONF_RADIO_TYPE] == "ezsp" + + +def test_get_serial_by_id_no_dir(): + """Test serial by id conversion if there's no /dev/serial/by-id.""" + p1 = mock.patch("os.path.isdir", mock.MagicMock(return_value=False)) + p2 = mock.patch("os.scandir") + with p1 as is_dir_mock, p2 as scan_mock: + res = config_flow.get_serial_by_id(mock.sentinel.path) + assert res is mock.sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 0 + + +def test_get_serial_by_id(): + """Test serial by id conversion.""" + p1 = mock.patch("os.path.isdir", mock.MagicMock(return_value=True)) + p2 = mock.patch("os.scandir") + + def _realpath(path): + if path is mock.sentinel.matched_link: + return mock.sentinel.path + return mock.sentinel.serial_link_path + + p3 = mock.patch("os.path.realpath", side_effect=_realpath) + with p1 as is_dir_mock, p2 as scan_mock, p3: + res = config_flow.get_serial_by_id(mock.sentinel.path) + assert res is mock.sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 1 + + entry1 = mock.MagicMock(spec_set=os.DirEntry) + entry1.is_symlink.return_value = True + entry1.path = mock.sentinel.some_path + + entry2 = mock.MagicMock(spec_set=os.DirEntry) + entry2.is_symlink.return_value = False + entry2.path = mock.sentinel.other_path + + entry3 = mock.MagicMock(spec_set=os.DirEntry) + entry3.is_symlink.return_value = True + entry3.path = mock.sentinel.matched_link + + scan_mock.return_value = [entry1, entry2, entry3] + res = config_flow.get_serial_by_id(mock.sentinel.path) + assert res is mock.sentinel.matched_link + assert is_dir_mock.call_count == 2 + assert scan_mock.call_count == 2 From 0a8a0720d7234c33f805213833c42e8541df89f6 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Sun, 3 May 2020 18:38:54 -0400 Subject: [PATCH 09/23] Rebase cleanup --- tests/components/zha/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index a1783173cf9e3b..f11f5ac936220f 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -66,7 +66,7 @@ def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio): """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} app_ctrl = mock.MagicMock() - app_ctrl.new = CoroutineMock(return_value=zigpy_app_controller) + app_ctrl.new = tests.async_mock.AsyncMock(return_value=zigpy_app_controller) app_ctrl.SCHEMA = zigpy.config.CONFIG_SCHEMA app_ctrl.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE From 708cefb8e257ca8251643cab88f9ef9f724f1e9c Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Sun, 3 May 2020 19:24:56 -0400 Subject: [PATCH 10/23] Use correct mock. --- tests/components/zha/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 5f1133368f87dd..6c8bcca2f6c507 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,8 +1,8 @@ """Tests for ZHA config flow.""" import os -from unittest import mock +from asynctest import mock import serial.tools.list_ports import zigpy.config From a6c258fedc2494896ceab23aa1d45c31df2f86b2 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Sun, 3 May 2020 22:00:04 -0400 Subject: [PATCH 11/23] Make lint happy again --- homeassistant/components/zha/config_flow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 124b062d4fa889..7af97844535344 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -8,7 +8,12 @@ from homeassistant import config_entries -from .core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN, RadioType +from .core.const import ( # pylint:disable=unused-import + CONF_RADIO_TYPE, + CONTROLLER, + DOMAIN, + RadioType, +) from .core.registries import RADIO_TYPES CONF_MANUAL_PATH = "Enter Manually" From 662c07437d7b3bfb524a5c9966f82fedc4195b00 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 4 May 2020 22:27:09 -0400 Subject: [PATCH 12/23] Use executor pool for IO --- homeassistant/components/zha/config_flow.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 7af97844535344..89612116c6446a 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -35,7 +35,7 @@ async def async_step_user(self, user_input=None): if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - ports = serial.tools.list_ports.comports(include_links=False) + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) list_of_ports = [ f"{p}, s/n: {p.serial_number or 'n/a'}" + (f" - {p.manufacturer}" if p.manufacturer else "") @@ -49,7 +49,9 @@ async def async_step_user(self, user_input=None): return await self.async_step_pick_radio() port = ports[list_of_ports.index(user_selection)] - dev_path = get_serial_by_id(port.device) + dev_path = await self.hass.async_add_executor_job( + get_serial_by_id, port.device + ) auto_detected_data = await self.detect_radios(dev_path) if auto_detected_data is not None: title = f"{port.description}, s/n: {port.serial_number or 'n/a'}" @@ -83,7 +85,9 @@ async def async_step_port_config(self, user_input=None): if user_input is not None: self._device_path = user_input.get(CONF_DEVICE_PATH) if await app_cls.probe(user_input): - serial_by_id = get_serial_by_id(user_input[CONF_DEVICE_PATH]) + serial_by_id = await self.hass.async_add_executor_job( + get_serial_by_id, user_input[CONF_DEVICE_PATH] + ) user_input[CONF_DEVICE_PATH] = serial_by_id return self.async_create_entry( title=user_input[CONF_DEVICE_PATH], From 546f4110dc6b555a3ddad503e484e1171f0861f5 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 4 May 2020 22:16:26 -0400 Subject: [PATCH 13/23] Update tests/components/zha/test_config_flow.py Co-authored-by: Martin Hjelmare --- tests/components/zha/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 6c8bcca2f6c507..d7100d2f4f15cc 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.zha import config_flow from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN -import tests.async_mock +from tests.async_mock import AsyncMock, MagicMock, patch, sentinel from tests.common import MockConfigEntry From ccecfa3645f33eb27951c78b336b4333c5b8dca7 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 4 May 2020 22:16:37 -0400 Subject: [PATCH 14/23] Update tests/components/zha/test_config_flow.py Co-authored-by: Martin Hjelmare --- tests/components/zha/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index d7100d2f4f15cc..08eda4476a7cb5 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -24,8 +24,8 @@ def com_port(): return port -@mock.patch( - "serial.tools.list_ports.comports", mock.MagicMock(return_value=[com_port()]) +@patch( + "serial.tools.list_ports.comports", return_value=[com_port()] ) async def test_user_flow(hass): """Test user flow.""" From fe377f036b77a63ca2dc25fb6076a3e0b823d518 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 4 May 2020 22:17:15 -0400 Subject: [PATCH 15/23] Update tests/components/zha/test_config_flow.py Co-authored-by: Martin Hjelmare --- tests/components/zha/test_config_flow.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 08eda4476a7cb5..7059d437cca2f1 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -29,9 +29,6 @@ def com_port(): ) async def test_user_flow(hass): """Test user flow.""" - flow = config_flow.ZhaFlowHandler() - flow.hass = hass - port = com_port() port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}" From 0592f83c0e1d2b6b520947b3ac2dc74e6dd6b156 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 4 May 2020 22:48:33 -0400 Subject: [PATCH 16/23] Address comments. Use AsyncMock from tests. --- tests/components/zha/test_config_flow.py | 69 ++++++++++++------------ 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 7059d437cca2f1..d68b802fb28d2c 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -2,7 +2,6 @@ import os -from asynctest import mock import serial.tools.list_ports import zigpy.config @@ -24,25 +23,27 @@ def com_port(): return port -@patch( - "serial.tools.list_ports.comports", return_value=[com_port()] -) +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) async def test_user_flow(hass): """Test user flow.""" + + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + port = com_port() port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}" - with mock.patch.object( - flow, "detect_radios", return_value=mock.sentinel.data, + with patch.object( + flow, "detect_radios", return_value=sentinel.data, ): result = await flow.async_step_user( user_input={zigpy.config.CONF_DEVICE_PATH: port_select} ) assert result["type"] == "create_entry" assert result["title"].startswith(port.description) - assert result["data"] is mock.sentinel.data + assert result["data"] is sentinel.data - with mock.patch.object( + with patch.object( flow, "detect_radios", return_value=None, ): result = await flow.async_step_user( @@ -92,14 +93,14 @@ async def test_user_flow_existing_config_entry(hass): async def test_probe_radios(hass): """Test detect radios.""" - app_ctrl_cls = mock.MagicMock() + app_ctrl_cls = MagicMock() app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE - app_ctrl_cls.probe = tests.async_mock.AsyncMock(side_effect=(True, False)) + app_ctrl_cls.probe = AsyncMock(side_effect=(True, False)) flow = config_flow.ZhaFlowHandler() flow.hass = hass - with mock.patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): res = await flow.detect_radios("/dev/null") assert app_ctrl_cls.probe.await_count == 1 assert res[CONF_RADIO_TYPE] == "ezsp" @@ -114,15 +115,15 @@ async def test_probe_radios(hass): async def test_user_port_config_fail(hass): """Test port config flow.""" - app_ctrl_cls = mock.MagicMock() + app_ctrl_cls = MagicMock() app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE - app_ctrl_cls.probe = tests.async_mock.AsyncMock(side_effect=(False, True)) + app_ctrl_cls.probe = AsyncMock(side_effect=(False, True)) flow = config_flow.ZhaFlowHandler() flow.hass = hass await flow.async_step_pick_radio(user_input={CONF_RADIO_TYPE: "ezsp"}) - with mock.patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): result = await flow.async_step_port_config( {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"} ) @@ -144,46 +145,46 @@ async def test_user_port_config_fail(hass): def test_get_serial_by_id_no_dir(): """Test serial by id conversion if there's no /dev/serial/by-id.""" - p1 = mock.patch("os.path.isdir", mock.MagicMock(return_value=False)) - p2 = mock.patch("os.scandir") + p1 = patch("os.path.isdir", MagicMock(return_value=False)) + p2 = patch("os.scandir") with p1 as is_dir_mock, p2 as scan_mock: - res = config_flow.get_serial_by_id(mock.sentinel.path) - assert res is mock.sentinel.path + res = config_flow.get_serial_by_id(sentinel.path) + assert res is sentinel.path assert is_dir_mock.call_count == 1 assert scan_mock.call_count == 0 def test_get_serial_by_id(): """Test serial by id conversion.""" - p1 = mock.patch("os.path.isdir", mock.MagicMock(return_value=True)) - p2 = mock.patch("os.scandir") + p1 = patch("os.path.isdir", MagicMock(return_value=True)) + p2 = patch("os.scandir") def _realpath(path): - if path is mock.sentinel.matched_link: - return mock.sentinel.path - return mock.sentinel.serial_link_path + if path is sentinel.matched_link: + return sentinel.path + return sentinel.serial_link_path - p3 = mock.patch("os.path.realpath", side_effect=_realpath) + p3 = patch("os.path.realpath", side_effect=_realpath) with p1 as is_dir_mock, p2 as scan_mock, p3: - res = config_flow.get_serial_by_id(mock.sentinel.path) - assert res is mock.sentinel.path + res = config_flow.get_serial_by_id(sentinel.path) + assert res is sentinel.path assert is_dir_mock.call_count == 1 assert scan_mock.call_count == 1 - entry1 = mock.MagicMock(spec_set=os.DirEntry) + entry1 = MagicMock(spec_set=os.DirEntry) entry1.is_symlink.return_value = True - entry1.path = mock.sentinel.some_path + entry1.path = sentinel.some_path - entry2 = mock.MagicMock(spec_set=os.DirEntry) + entry2 = MagicMock(spec_set=os.DirEntry) entry2.is_symlink.return_value = False - entry2.path = mock.sentinel.other_path + entry2.path = sentinel.other_path - entry3 = mock.MagicMock(spec_set=os.DirEntry) + entry3 = MagicMock(spec_set=os.DirEntry) entry3.is_symlink.return_value = True - entry3.path = mock.sentinel.matched_link + entry3.path = sentinel.matched_link scan_mock.return_value = [entry1, entry2, entry3] - res = config_flow.get_serial_by_id(mock.sentinel.path) - assert res is mock.sentinel.matched_link + res = config_flow.get_serial_by_id(sentinel.path) + assert res is sentinel.matched_link assert is_dir_mock.call_count == 2 assert scan_mock.call_count == 2 From 69fbadead75a041564df37fedd72d08620e55f51 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 5 May 2020 00:24:36 -0400 Subject: [PATCH 17/23] Use core interface to test config flow. --- homeassistant/components/zha/config_flow.py | 20 ++--- tests/components/zha/test_config_flow.py | 83 +++++++++++++-------- 2 files changed, 61 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 89612116c6446a..53c5358e763490 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -52,7 +52,7 @@ async def async_step_user(self, user_input=None): dev_path = await self.hass.async_add_executor_job( get_serial_by_id, port.device ) - auto_detected_data = await self.detect_radios(dev_path) + auto_detected_data = await detect_radios(dev_path) if auto_detected_data is not None: title = f"{port.description}, s/n: {port.serial_number or 'n/a'}" title += f" - {port.manufacturer}" if port.manufacturer else "" @@ -106,16 +106,16 @@ async def async_step_port_config(self, user_input=None): errors=errors, ) - @staticmethod - async def detect_radios(dev_path: str) -> Optional[Dict[str, Any]]: - """Probe all radio types on the device port.""" - for radio in RadioType.list(): - app_cls = RADIO_TYPES[radio][CONTROLLER] - dev_config = app_cls.SCHEMA_DEVICE({CONF_DEVICE_PATH: dev_path}) - if await app_cls.probe(dev_config): - return {CONF_RADIO_TYPE: radio, CONF_DEVICE: dev_config} - return None +async def detect_radios(dev_path: str) -> Optional[Dict[str, Any]]: + """Probe all radio types on the device port.""" + for radio in RadioType.list(): + app_cls = RADIO_TYPES[radio][CONTROLLER] + dev_config = app_cls.SCHEMA_DEVICE({CONF_DEVICE_PATH: dev_path}) + if await app_cls.probe(dev_config): + return {CONF_RADIO_TYPE: radio, CONF_DEVICE: dev_config} + + return None def get_serial_by_id(dev_path: str) -> str: diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index d68b802fb28d2c..4ae35f93c97b86 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -7,6 +7,9 @@ from homeassistant.components.zha import config_flow from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_SOURCE +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM from tests.async_mock import AsyncMock, MagicMock, patch, sentinel from tests.common import MockConfigEntry @@ -24,47 +27,66 @@ def com_port(): @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch.object( + config_flow, + "detect_radios", + AsyncMock(return_value={CONF_RADIO_TYPE: "test_radio"}), +) async def test_user_flow(hass): - """Test user flow.""" - - flow = config_flow.ZhaFlowHandler() - flow.hass = hass + """Test user flow -- radio detected.""" port = com_port() port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}" - with patch.object( - flow, "detect_radios", return_value=sentinel.data, - ): - result = await flow.async_step_user( - user_input={zigpy.config.CONF_DEVICE_PATH: port_select} - ) - assert result["type"] == "create_entry" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={zigpy.config.CONF_DEVICE_PATH: port_select}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"].startswith(port.description) - assert result["data"] is sentinel.data + assert result["data"] == {CONF_RADIO_TYPE: "test_radio"} - with patch.object( - flow, "detect_radios", return_value=None, - ): - result = await flow.async_step_user( - user_input={zigpy.config.CONF_DEVICE_PATH: port_select} - ) - assert result["type"] == "form" +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch.object( + config_flow, "detect_radios", AsyncMock(return_value=None), +) +async def test_user_flow_not_detected(hass): + """Test user flow, radio not detected.""" + + port = com_port() + port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={zigpy.config.CONF_DEVICE_PATH: port_select}, + ) + + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "pick_radio" - await flow.async_step_user() + +async def test_user_flow_show_form(hass): + """Test user step form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" async def test_user_flow_manual(hass): """Test user flow manual entry.""" - flow = config_flow.ZhaFlowHandler() - flow.hass = hass - result = await flow.async_step_user( - user_input={zigpy.config.CONF_DEVICE_PATH: config_flow.CONF_MANUAL_PATH} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={zigpy.config.CONF_DEVICE_PATH: config_flow.CONF_MANUAL_PATH}, ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "pick_radio" @@ -74,7 +96,7 @@ async def test_pick_radio_flow(hass): flow.hass = hass result = await flow.async_step_pick_radio({CONF_RADIO_TYPE: "ezsp"}) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "port_config" await flow.async_step_pick_radio() @@ -97,11 +119,8 @@ async def test_probe_radios(hass): app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE app_ctrl_cls.probe = AsyncMock(side_effect=(True, False)) - flow = config_flow.ZhaFlowHandler() - flow.hass = hass - with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): - res = await flow.detect_radios("/dev/null") + res = await config_flow.detect_radios("/dev/null") assert app_ctrl_cls.probe.await_count == 1 assert res[CONF_RADIO_TYPE] == "ezsp" assert zigpy.config.CONF_DEVICE in res @@ -109,7 +128,7 @@ async def test_probe_radios(hass): res[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] == "/dev/null" ) - res = await flow.detect_radios("/dev/null") + res = await config_flow.detect_radios("/dev/null") assert res is None @@ -127,7 +146,7 @@ async def test_user_port_config_fail(hass): result = await flow.async_step_port_config( {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "port_config" assert result["errors"]["base"] == "cannot_connect" From a0a733a9552778de3856e0b48e35bc33238fa658 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 5 May 2020 00:47:30 -0400 Subject: [PATCH 18/23] Use core interface to test config_flow. --- tests/components/zha/test_config_flow.py | 39 ++++++++++++++++-------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 4ae35f93c97b86..b90f2cd6a01948 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -92,15 +92,13 @@ async def test_user_flow_manual(hass): async def test_pick_radio_flow(hass): """Test radio picker.""" - flow = config_flow.ZhaFlowHandler() - flow.hass = hass - result = await flow.async_step_pick_radio({CONF_RADIO_TYPE: "ezsp"}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: "pick_radio"}, data={CONF_RADIO_TYPE: "ezsp"} + ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "port_config" - await flow.async_step_pick_radio() - async def test_user_flow_existing_config_entry(hass): """Test if config entry already exists.""" @@ -136,23 +134,38 @@ async def test_user_port_config_fail(hass): """Test port config flow.""" app_ctrl_cls = MagicMock() app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE - app_ctrl_cls.probe = AsyncMock(side_effect=(False, True)) + app_ctrl_cls.probe = AsyncMock(return_value=False) - flow = config_flow.ZhaFlowHandler() - flow.hass = hass - await flow.async_step_pick_radio(user_input={CONF_RADIO_TYPE: "ezsp"}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: "pick_radio"}, data={CONF_RADIO_TYPE: "ezsp"} + ) with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): - result = await flow.async_step_port_config( - {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "port_config" assert result["errors"]["base"] == "cannot_connect" - result = await flow.async_step_port_config( - {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"} + +async def test_user_port_config(hass): + """Test port config.""" + app_ctrl_cls = MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.probe = AsyncMock(return_value=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: "pick_radio"}, data={CONF_RADIO_TYPE: "ezsp"} + ) + + with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, ) + assert result["type"] == "create_entry" assert result["title"].startswith("/dev/ttyUSB33") assert ( From ac0c03f2d1d2786866182011eeffc846a3cafb31 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 5 May 2020 12:40:14 -0400 Subject: [PATCH 19/23] Address comments. Use core interface. --- tests/components/zha/test_config_flow.py | 28 ++++++++++++++---------- tests/components/zha/test_init.py | 11 ++++++---- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index b90f2cd6a01948..eb0823202c9fbc 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -5,6 +5,7 @@ import serial.tools.list_ports import zigpy.config +from homeassistant import setup from homeassistant.components.zha import config_flow from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -27,12 +28,11 @@ def com_port(): @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) -@patch.object( - config_flow, - "detect_radios", - AsyncMock(return_value={CONF_RADIO_TYPE: "test_radio"}), +@patch( + "homeassistant.components.zha.config_flow.detect_radios", + return_value={CONF_RADIO_TYPE: "test_radio"}, ) -async def test_user_flow(hass): +async def test_user_flow(detect_mock, hass): """Test user flow -- radio detected.""" port = com_port() @@ -46,13 +46,15 @@ async def test_user_flow(hass): assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"].startswith(port.description) assert result["data"] == {CONF_RADIO_TYPE: "test_radio"} + assert detect_mock.await_count == 1 + assert detect_mock.await_args[0][0] == port.device @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) -@patch.object( - config_flow, "detect_radios", AsyncMock(return_value=None), +@patch( + "homeassistant.components.zha.config_flow.detect_radios", return_value=None, ) -async def test_user_flow_not_detected(hass): +async def test_user_flow_not_detected(detect_mock, hass): """Test user flow, radio not detected.""" port = com_port() @@ -66,6 +68,8 @@ async def test_user_flow_not_detected(hass): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "pick_radio" + assert detect_mock.await_count == 1 + assert detect_mock.await_args[0][0] == port.device async def test_user_flow_show_form(hass): @@ -103,10 +107,10 @@ async def test_pick_radio_flow(hass): 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() + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) assert result["type"] == "abort" diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index a722f070eef1fb..963cea33bdd899 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -3,7 +3,6 @@ import pytest from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -import homeassistant.components.zha from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, CONF_RADIO_TYPE, @@ -12,6 +11,7 @@ ) from homeassistant.setup import async_setup_component +from tests.async_mock import AsyncMock, patch from tests.common import MockConfigEntry DATA_RADIO_TYPE = "deconz" @@ -29,10 +29,11 @@ def config_entry_v1(hass): @pytest.mark.parametrize("config", ({}, {DOMAIN: {}})) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migration_from_v1_no_baudrate(hass, config_entry_v1, config): """Test migration of config entry from v1.""" + config_entry_v1.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, config) - await homeassistant.components.zha.async_migrate_entry(hass, config_entry_v1) assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert CONF_DEVICE in config_entry_v1.data @@ -42,10 +43,11 @@ async def test_migration_from_v1_no_baudrate(hass, config_entry_v1, config): assert config_entry_v1.version == 2 +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migration_from_v1_with_baudrate(hass, config_entry_v1): """Test migration of config entry from v1 with baudrate in config.""" + config_entry_v1.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_BAUDRATE: 115200}}) - await homeassistant.components.zha.async_migrate_entry(hass, config_entry_v1) assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert CONF_DEVICE in config_entry_v1.data @@ -56,10 +58,11 @@ async def test_migration_from_v1_with_baudrate(hass, config_entry_v1): assert config_entry_v1.version == 2 +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migration_from_v1_wrong_baudrate(hass, config_entry_v1): """Test migration of config entry from v1 with wrong baudrate.""" + config_entry_v1.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_BAUDRATE: 115222}}) - await homeassistant.components.zha.async_migrate_entry(hass, config_entry_v1) assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert CONF_DEVICE in config_entry_v1.data From 1b425b5e38d94d62d83ef98ce29e3c1fe64ad9e8 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 15 Apr 2020 21:17:26 -0400 Subject: [PATCH 20/23] Update ZHA dependencies. --- homeassistant/components/zha/manifest.json | 13 +++++++------ requirements_all.txt | 13 +++++++------ requirements_test_all.txt | 16 ++++++++++------ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e49f4f1407aeee..51db75600bdc9c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,13 +4,14 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows-homeassistant==0.15.2", + "bellows==0.16.1", + "pyserial==3.4", "zha-quirks==0.0.38", - "zigpy-cc==0.3.1", - "zigpy-deconz==0.8.1", - "zigpy-homeassistant==0.19.0", - "zigpy-xbee-homeassistant==0.11.0", - "zigpy-zigate==0.5.1" + "zigpy-cc==0.4.2", + "zigpy-deconz==0.9.1", + "zigpy==0.20.1", + "zigpy-xbee==0.12.1", + "zigpy-zigate==0.6.1" ], "codeowners": ["@dmulcahey", "@adminiuga"] } diff --git a/requirements_all.txt b/requirements_all.txt index 16d83966382847..9d33097a6fb0ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -327,7 +327,7 @@ beautifulsoup4==4.9.0 beewi_smartclim==0.0.7 # homeassistant.components.zha -bellows-homeassistant==0.15.2 +bellows==0.16.1 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.5 @@ -1557,6 +1557,7 @@ pysensibo==1.0.3 pyserial-asyncio==0.4 # homeassistant.components.acer_projector +# homeassistant.components.zha pyserial==3.4 # homeassistant.components.sesame @@ -2217,19 +2218,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-cc==0.3.1 +zigpy-cc==0.4.2 # homeassistant.components.zha -zigpy-deconz==0.8.1 +zigpy-deconz==0.9.1 # homeassistant.components.zha -zigpy-homeassistant==0.19.0 +zigpy-xbee==0.12.1 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.11.0 +zigpy-zigate==0.6.1 # homeassistant.components.zha -zigpy-zigate==0.5.1 +zigpy==0.20.1 # homeassistant.components.zoneminder zm-py==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 437ab3cb329b3f..eccc5cd7aab23b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -141,7 +141,7 @@ axis==25 base36==0.1.1 # homeassistant.components.zha -bellows-homeassistant==0.15.2 +bellows==0.16.1 # homeassistant.components.blebox blebox_uniapi==1.3.2 @@ -634,6 +634,10 @@ pyps4-2ndscreen==1.0.7 # homeassistant.components.qwikswitch pyqwikswitch==0.93 +# homeassistant.components.acer_projector +# homeassistant.components.zha +pyserial==3.4 + # homeassistant.components.signal_messenger pysignalclirestapi==0.3.4 @@ -857,16 +861,16 @@ zeroconf==0.26.0 zha-quirks==0.0.38 # homeassistant.components.zha -zigpy-cc==0.3.1 +zigpy-cc==0.4.2 # homeassistant.components.zha -zigpy-deconz==0.8.1 +zigpy-deconz==0.9.1 # homeassistant.components.zha -zigpy-homeassistant==0.19.0 +zigpy-xbee==0.12.1 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.11.0 +zigpy-zigate==0.6.1 # homeassistant.components.zha -zigpy-zigate==0.5.1 +zigpy==0.20.1 From 310104600646faf472859f02103ecd804402f069 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 5 May 2020 20:51:08 -0400 Subject: [PATCH 21/23] Schema guard --- homeassistant/components/zha/config_flow.py | 18 +++++++++++++++--- homeassistant/components/zha/core/const.py | 3 +++ tests/components/zha/test_config_flow.py | 21 ++++++++++++++++----- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 53c5358e763490..7f0ba0451848e9 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -9,6 +9,8 @@ from homeassistant import config_entries from .core.const import ( # pylint:disable=unused-import + CONF_BAUDRATE, + CONF_FLOWCONTROL, CONF_RADIO_TYPE, CONTROLLER, DOMAIN, @@ -17,6 +19,10 @@ from .core.registries import RADIO_TYPES CONF_MANUAL_PATH = "Enter Manually" +SUPPORTED_PORT_SETTINGS = ( + CONF_BAUDRATE, + CONF_FLOWCONTROL, +) class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -100,10 +106,16 @@ async def async_step_port_config(self, user_input=None): CONF_DEVICE_PATH, default=self._device_path or vol.UNDEFINED ): str } + radio_schema = app_cls.SCHEMA_DEVICE.schema + if isinstance(radio_schema, vol.Schema): + radio_schema = radio_schema.schema + + for param, value in radio_schema.items(): + if param in SUPPORTED_PORT_SETTINGS: + schema[param] = value + return self.async_show_form( - step_id="port_config", - data_schema=app_cls.SCHEMA_DEVICE.extend(schema), - errors=errors, + step_id="port_config", data_schema=vol.Schema(schema), errors=errors, ) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index a53c3a979b9a68..0d8d95a1969868 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -2,6 +2,8 @@ import enum import logging +from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import + from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.cover import DOMAIN as COVER from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER @@ -92,6 +94,7 @@ CONF_DATABASE = "database_path" CONF_DEVICE_CONFIG = "device_config" CONF_ENABLE_QUIRKS = "enable_quirks" +CONF_FLOWCONTROL = "flow_control" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_ZIGPY = "zigpy_config" diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index eb0823202c9fbc..7ba566e33f5572 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -2,12 +2,14 @@ import os +import pytest import serial.tools.list_ports import zigpy.config from homeassistant import setup from homeassistant.components.zha import config_flow from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN +from homeassistant.components.zha.core.registries import RADIO_TYPES from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_SOURCE from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM @@ -154,17 +156,26 @@ async def test_user_port_config_fail(hass): assert result["errors"]["base"] == "cannot_connect" -async def test_user_port_config(hass): +@pytest.mark.parametrize( + "radio_type, orig_ctrl_cls", + ((name, r[CONTROLLER]) for name, r in RADIO_TYPES.items()), +) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_user_port_config(hass, radio_type, orig_ctrl_cls): """Test port config.""" app_ctrl_cls = MagicMock() - app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.SCHEMA_DEVICE = orig_ctrl_cls.SCHEMA_DEVICE app_ctrl_cls.probe = AsyncMock(return_value=True) + await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: "pick_radio"}, data={CONF_RADIO_TYPE: "ezsp"} + DOMAIN, context={CONF_SOURCE: "pick_radio"}, data={CONF_RADIO_TYPE: radio_type} ) - with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + with patch.dict( + config_flow.RADIO_TYPES, + {radio_type: {CONTROLLER: app_ctrl_cls, "radio_description": "radio"}}, + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, @@ -176,7 +187,7 @@ async def test_user_port_config(hass): result["data"][zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] == "/dev/ttyUSB33" ) - assert result["data"][CONF_RADIO_TYPE] == "ezsp" + assert result["data"][CONF_RADIO_TYPE] == radio_type def test_get_serial_by_id_no_dir(): From 10d8aae8e2fbcb3b89d16c37cec08b0fc56a7470 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 5 May 2020 22:34:15 -0400 Subject: [PATCH 22/23] Use async_update_entry for migration. --- homeassistant/components/zha/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index eb5f6ef5985aa1..308fc0c5f54413 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -161,19 +161,17 @@ async def async_migrate_entry( _LOGGER.debug("Migrating from version %s", config_entry.version) if config_entry.version == 1: - config_entry.data = { + data = { CONF_RADIO_TYPE: config_entry.data[CONF_RADIO_TYPE], CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]}, } baudrate = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}).get(CONF_BAUDRATE) - if ( - config_entry.data[CONF_RADIO_TYPE] != RadioType.deconz - and baudrate in BAUD_RATES - ): - config_entry.data[CONF_DEVICE][CONF_BAUDRATE] = baudrate + if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: + data[CONF_DEVICE][CONF_BAUDRATE] = baudrate config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, data=data) _LOGGER.info("Migration to version %s successful", config_entry.version) return True From c5a37578e6ada22c03250c06698271e3a6dd0891 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 5 May 2020 22:43:23 -0400 Subject: [PATCH 23/23] Don't allow schema extra keys. --- homeassistant/components/zha/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 308fc0c5f54413..4f844613336ca2 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -54,7 +54,6 @@ cv.deprecated(CONF_RADIO_TYPE, invalidation_version="0.112"), ZHA_CONFIG_SCHEMA, ), - extra=vol.ALLOW_EXTRA, ), }, extra=vol.ALLOW_EXTRA,