From c71a7b901fea74cd2ce3b2a5305877f0cca2ae45 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 6 May 2020 06:23:53 -0400 Subject: [PATCH] New configuration flow for ZHA integration (#35161) * 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. * Migrate config entry. * 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. * Clean ZHA_GW_RADIO * Don't import ZHA device settings. * Update config flow tests. * Filter out empty manufacturer. * Replace port path with an by-id device name. * Rebase cleanup * Use correct mock. * Make lint happy again * Update tests/components/zha/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/zha/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/zha/test_config_flow.py Co-authored-by: Martin Hjelmare * Use executor pool for IO * Address comments. Use AsyncMock from tests. * Use core interface to test config flow. * Use core interface to test config_flow. * Address comments. Use core interface. * Update ZHA dependencies. * Schema guard * Use async_update_entry for migration. * Don't allow schema extra keys. Co-authored-by: Martin Hjelmare --- .coveragerc | 1 - homeassistant/components/zha/__init__.py | 77 +++-- homeassistant/components/zha/config_flow.py | 152 ++++++--- homeassistant/components/zha/core/const.py | 9 +- homeassistant/components/zha/core/gateway.py | 60 ++-- .../components/zha/core/registries.py | 12 +- homeassistant/components/zha/manifest.json | 13 +- homeassistant/components/zha/strings.json | 16 +- .../components/zha/translations/en.json | 20 +- requirements_all.txt | 13 +- requirements_test_all.txt | 16 +- tests/components/zha/conftest.py | 13 +- tests/components/zha/test_config_flow.py | 302 ++++++++++++------ tests/components/zha/test_init.py | 72 +++++ 14 files changed, 527 insertions(+), 249 deletions(-) create mode 100644 tests/components/zha/test_init.py diff --git a/.coveragerc b/.coveragerc index 251fe05c014..8de6d1458a0 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/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 9e59b63adb4..4f844613336 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -4,6 +4,7 @@ import asyncio 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 @@ -14,6 +15,7 @@ from homeassistant.helpers.typing import HomeAssistantType from . import api from .core import ZHAGateway from .core.const import ( + BAUD_RATES, COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, @@ -21,13 +23,12 @@ from .core.const import ( 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, @@ -35,23 +36,25 @@ from .core.const import ( 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, ) @@ -67,23 +70,10 @@ async def async_setup(hass, config): """Set up ZHA from config.""" hass.data[DATA_ZHA] = {} - if DOMAIN not in config: - return True + if DOMAIN in config: + conf = config[DOMAIN] + hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf - 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 @@ -161,3 +151,26 @@ 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: + 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 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 diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5ee0d0ee9bb..7f0ba045184 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,80 +1,142 @@ """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_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries -from .core.const import ( +from .core.const import ( # pylint:disable=unused-import + CONF_BAUDRATE, + CONF_FLOWCONTROL, CONF_RADIO_TYPE, - CONF_USB_PATH, CONTROLLER, - DEFAULT_BAUDRATE, - DEFAULT_DATABASE_NAME, DOMAIN, - ZHA_GW_RADIO, RadioType, ) from .core.registries import RADIO_TYPES +CONF_MANUAL_PATH = "Enter Manually" +SUPPORTED_PORT_SETTINGS = ( + CONF_BAUDRATE, + CONF_FLOWCONTROL, +) -@config_entries.HANDLERS.register(DOMAIN) -class ZhaFlowHandler(config_entries.ConfigFlow): + +class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + 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_USB_PATH)] = str - fields[vol.Optional(CONF_RADIO_TYPE, default="ezsp")] = vol.In(RadioType.list()) + 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 "") + 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_USB_PATH], user_input[CONF_RADIO_TYPE], database + user_selection = user_input[CONF_DEVICE_PATH] + if user_selection == CONF_MANUAL_PATH: + return await self.async_step_pick_radio() + + port = ports[list_of_ports.index(user_selection)] + dev_path = await self.hass.async_add_executor_job( + get_serial_by_id, port.device ) - if test: + 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 "" + return self.async_create_entry(title=title, data=auto_detected_data,) + + # 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_pick_radio(self, user_input=None): + """Select radio type.""" + + 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), + ) + + 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): + 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_USB_PATH], data=user_input + 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 + } + 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="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 + step_id="port_config", data_schema=vol.Schema(schema), errors=errors, ) -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) - await controller.shutdown() - except Exception: # pylint: disable=broad-except - return False - return True +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: + """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/core/const.py b/homeassistant/components/zha/core/const.py index b181b848f04..0d8d95a1969 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,8 +94,10 @@ CONF_BAUDRATE = "baudrate" 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" CONTROLLER = "controller" DATA_DEVICE_CONFIG = "zha_device_config" @@ -145,11 +149,11 @@ POWER_BATTERY_OR_UNKNOWN = "Battery or Unknown" 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): @@ -258,7 +262,6 @@ ZHA_GW_MSG_GROUP_REMOVED = "group_removed" 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/gateway.py b/homeassistant/components/zha/core/gateway.py index b8efdf873b1..c340ab99473 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -10,6 +10,7 @@ import traceback 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 @@ from .const import ( 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 @@ from .const import ( DEBUG_LEVEL_ORIGINAL, DEBUG_LEVELS, DEBUG_RELAY_LOGGERS, - DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DOMAIN, SIGNAL_ADD_ENTITIES, @@ -74,7 +73,6 @@ from .const import ( 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 @@ class ZHAGateway: 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] + app_controller_cls = RADIO_TYPES[radio_type][CONTROLLER] + self.radio_description = RADIO_TYPES[radio_type][ZHA_GW_RADIO_DESCRIPTION] + + 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: - 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 + 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", + self.radio_description, + exc_info=exception, + ) + raise ConfigEntryNotReady from exception - if CONF_DATABASE in self._config: - database = self._config[CONF_DATABASE] - else: - database = os.path.join(self._hass.config.config_dir, DEFAULT_DATABASE_NAME) - - 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) - - 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: - _LOGGER.error( - "Couldn't start %s coordinator", - radio_details[ZHA_GW_RADIO_DESCRIPTION], - exc_info=exception, - ) - radio.close() - raise ConfigEntryNotReady from exception - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str( self.application_controller.ieee diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 29b71343245..9ce3f9df30d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -3,18 +3,13 @@ import collections 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 @@ from homeassistant.components.switch import DOMAIN as SWITCH # 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 @@ CLIENT_CHANNELS_REGISTRY = DictRegistry() 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/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e49f4f1407a..51db75600bd 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/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 6906b5b3e8c..b26cebbd40a 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" } } }, diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index d8db817507d..6a1eb4bac8e 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/requirements_all.txt b/requirements_all.txt index c3b5be78d8e..69d5475afcc 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 a7f3c6075da..0a26be5d527 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 diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index e737f990163..f11f5ac9362 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -4,6 +4,7 @@ from unittest import mock 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,10 +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 = tests.async_mock.AsyncMock(return_value=zigpy_app_controller) + app_ctrl.SCHEMA = zigpy.config.CONFIG_SCHEMA + app_ctrl.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE 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.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 91d2ef75aa5..7ba566e33f5 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,125 +1,237 @@ """Tests for ZHA config flow.""" -from unittest import mock +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 CONTROLLER, DOMAIN, ZHA_GW_RADIO -import homeassistant.components.zha.core.registries +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 -import tests.async_mock +from tests.async_mock import AsyncMock, MagicMock, patch, sentinel from tests.common import MockConfigEntry -async def test_user_flow(hass): - """Test that config flow works.""" - flow = config_flow.ZhaFlowHandler() - flow.hass = hass +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/ttyUSB1234" + port.description = "Some serial port" - with tests.async_mock.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"} - ) + return port - assert result["errors"] == {"base": "cannot_connect"} - with tests.async_mock.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"} - ) +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.zha.config_flow.detect_radios", + return_value={CONF_RADIO_TYPE: "test_radio"}, +) +async def test_user_flow(detect_mock, hass): + """Test user flow -- radio detected.""" - assert result["type"] == "create_entry" - assert result["title"] == "/dev/ttyUSB1" - assert result["data"] == {"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"} + 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_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( + "homeassistant.components.zha.config_flow.detect_radios", return_value=None, +) +async def test_user_flow_not_detected(detect_mock, 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" + assert detect_mock.await_count == 1 + assert detect_mock.await_args[0][0] == port.device + + +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.""" + + 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"] == RESULT_TYPE_FORM + assert result["step_id"] == "pick_radio" + + +async def test_pick_radio_flow(hass): + """Test radio picker.""" + + 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" 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"} + 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" -async def test_check_zigpy_connection(): - """Test config flow validator.""" +async def test_probe_radios(hass): + """Test detect radios.""" + app_ctrl_cls = MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.probe = AsyncMock(side_effect=(True, False)) - mock_radio = tests.async_mock.MagicMock() - mock_radio.connect = tests.async_mock.AsyncMock() - radio_cls = tests.async_mock.MagicMock(return_value=mock_radio) + with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + 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 + assert ( + res[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] == "/dev/null" + ) - 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) + res = await config_flow.detect_radios("/dev/null") + assert res is None - 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}, - } - with mock.patch.dict( - homeassistant.components.zha.core.registries.RADIO_TYPES, new_radios, clear=True +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(return_value=False) + + 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"] == RESULT_TYPE_FORM + assert result["step_id"] == "port_config" + assert result["errors"]["base"] == "cannot_connect" + + +@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 = 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: radio_type} + ) + + with patch.dict( + config_flow.RADIO_TYPES, + {radio_type: {CONTROLLER: app_ctrl_cls, "radio_description": "radio"}}, ): - assert not await config_flow.check_zigpy_connection( - mock.sentinel.usb_path, mock.sentinel.unk_radio, mock.sentinel.zigbee_db + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={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"] == "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 == 0 - assert bad_radio.connect.call_count == 1 - assert mock_ctrl.startup.call_count == 0 - assert mock_ctrl.shutdown.call_count == 0 + assert result["data"][CONF_RADIO_TYPE] == radio_type - # successful radio connect - assert await config_flow.check_zigpy_connection( - mock.sentinel.usb_path, mock.sentinel.radio, mock.sentinel.zigbee_db - ) - 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 + +def test_get_serial_by_id_no_dir(): + """Test serial by id conversion if there's no /dev/serial/by-id.""" + 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(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 = patch("os.path.isdir", MagicMock(return_value=True)) + p2 = patch("os.scandir") + + def _realpath(path): + if path is sentinel.matched_link: + return sentinel.path + return sentinel.serial_link_path + + 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(sentinel.path) + assert res is sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 1 + + entry1 = MagicMock(spec_set=os.DirEntry) + entry1.is_symlink.return_value = True + entry1.path = sentinel.some_path + + entry2 = MagicMock(spec_set=os.DirEntry) + entry2.is_symlink.return_value = False + entry2.path = sentinel.other_path + + entry3 = MagicMock(spec_set=os.DirEntry) + entry3.is_symlink.return_value = True + entry3.path = sentinel.matched_link + + scan_mock.return_value = [entry1, entry2, entry3] + 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 diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py new file mode 100644 index 00000000000..963cea33bdd --- /dev/null +++ b/tests/components/zha/test_init.py @@ -0,0 +1,72 @@ +"""Tests for ZHA integration init.""" + +import pytest +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH + +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.async_mock import AsyncMock, patch +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: {}})) +@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) + + 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 + + +@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}}) + + 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 + + +@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}}) + + 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