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