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 <marhje52@gmail.com> * Update tests/components/zha/test_config_flow.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update tests/components/zha/test_config_flow.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * 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 <marhje52@gmail.com>
This commit is contained in:
parent
2581b031d9
commit
c71a7b901f
14 changed files with 527 additions and 249 deletions
|
@ -860,7 +860,6 @@ omit =
|
||||||
homeassistant/components/zengge/light.py
|
homeassistant/components/zengge/light.py
|
||||||
homeassistant/components/zeroconf/*
|
homeassistant/components/zeroconf/*
|
||||||
homeassistant/components/zestimate/sensor.py
|
homeassistant/components/zestimate/sensor.py
|
||||||
homeassistant/components/zha/__init__.py
|
|
||||||
homeassistant/components/zha/api.py
|
homeassistant/components/zha/api.py
|
||||||
homeassistant/components/zha/core/channels/*
|
homeassistant/components/zha/core/channels/*
|
||||||
homeassistant/components/zha/core/const.py
|
homeassistant/components/zha/core/const.py
|
||||||
|
|
|
@ -4,6 +4,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
|
||||||
|
|
||||||
from homeassistant import config_entries, const as ha_const
|
from homeassistant import config_entries, const as ha_const
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
@ -14,6 +15,7 @@ from homeassistant.helpers.typing import HomeAssistantType
|
||||||
from . import api
|
from . import api
|
||||||
from .core import ZHAGateway
|
from .core import ZHAGateway
|
||||||
from .core.const import (
|
from .core.const import (
|
||||||
|
BAUD_RATES,
|
||||||
COMPONENTS,
|
COMPONENTS,
|
||||||
CONF_BAUDRATE,
|
CONF_BAUDRATE,
|
||||||
CONF_DATABASE,
|
CONF_DATABASE,
|
||||||
|
@ -21,13 +23,12 @@ from .core.const import (
|
||||||
CONF_ENABLE_QUIRKS,
|
CONF_ENABLE_QUIRKS,
|
||||||
CONF_RADIO_TYPE,
|
CONF_RADIO_TYPE,
|
||||||
CONF_USB_PATH,
|
CONF_USB_PATH,
|
||||||
|
CONF_ZIGPY,
|
||||||
DATA_ZHA,
|
DATA_ZHA,
|
||||||
DATA_ZHA_CONFIG,
|
DATA_ZHA_CONFIG,
|
||||||
DATA_ZHA_DISPATCHERS,
|
DATA_ZHA_DISPATCHERS,
|
||||||
DATA_ZHA_GATEWAY,
|
DATA_ZHA_GATEWAY,
|
||||||
DATA_ZHA_PLATFORM_LOADED,
|
DATA_ZHA_PLATFORM_LOADED,
|
||||||
DEFAULT_BAUDRATE,
|
|
||||||
DEFAULT_RADIO_TYPE,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SIGNAL_ADD_ENTITIES,
|
SIGNAL_ADD_ENTITIES,
|
||||||
RadioType,
|
RadioType,
|
||||||
|
@ -35,23 +36,25 @@ from .core.const import (
|
||||||
from .core.discovery import GROUP_PROBE
|
from .core.discovery import GROUP_PROBE
|
||||||
|
|
||||||
DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(ha_const.CONF_TYPE): cv.string})
|
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(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
DOMAIN: vol.Schema(
|
DOMAIN: vol.Schema(
|
||||||
{
|
vol.All(
|
||||||
vol.Optional(CONF_RADIO_TYPE, default=DEFAULT_RADIO_TYPE): cv.enum(
|
cv.deprecated(CONF_USB_PATH, invalidation_version="0.112"),
|
||||||
RadioType
|
cv.deprecated(CONF_BAUDRATE, invalidation_version="0.112"),
|
||||||
),
|
cv.deprecated(CONF_RADIO_TYPE, invalidation_version="0.112"),
|
||||||
CONF_USB_PATH: cv.string,
|
ZHA_CONFIG_SCHEMA,
|
||||||
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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
@ -67,23 +70,10 @@ async def async_setup(hass, config):
|
||||||
"""Set up ZHA from config."""
|
"""Set up ZHA from config."""
|
||||||
hass.data[DATA_ZHA] = {}
|
hass.data[DATA_ZHA] = {}
|
||||||
|
|
||||||
if DOMAIN not in config:
|
if DOMAIN in config:
|
||||||
return True
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -161,3 +151,26 @@ async def async_load_entities(hass: HomeAssistantType) -> None:
|
||||||
if isinstance(res, Exception):
|
if isinstance(res, Exception):
|
||||||
_LOGGER.warning("Couldn't setup zha platform: %s", res)
|
_LOGGER.warning("Couldn't setup zha platform: %s", res)
|
||||||
async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES)
|
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
|
||||||
|
|
|
@ -1,80 +1,142 @@
|
||||||
"""Config flow for ZHA."""
|
"""Config flow for ZHA."""
|
||||||
import asyncio
|
|
||||||
from collections import OrderedDict
|
|
||||||
import os
|
import os
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import serial.tools.list_ports
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
|
||||||
|
|
||||||
from homeassistant import config_entries
|
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_RADIO_TYPE,
|
||||||
CONF_USB_PATH,
|
|
||||||
CONTROLLER,
|
CONTROLLER,
|
||||||
DEFAULT_BAUDRATE,
|
|
||||||
DEFAULT_DATABASE_NAME,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ZHA_GW_RADIO,
|
|
||||||
RadioType,
|
RadioType,
|
||||||
)
|
)
|
||||||
from .core.registries import RADIO_TYPES
|
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."""
|
"""Handle a config flow."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 2
|
||||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
|
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):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Handle a zha config flow start."""
|
"""Handle a zha config flow start."""
|
||||||
if self._async_current_entries():
|
if self._async_current_entries():
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
errors = {}
|
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
|
||||||
|
list_of_ports = [
|
||||||
fields = OrderedDict()
|
f"{p}, s/n: {p.serial_number or 'n/a'}"
|
||||||
fields[vol.Required(CONF_USB_PATH)] = str
|
+ (f" - {p.manufacturer}" if p.manufacturer else "")
|
||||||
fields[vol.Optional(CONF_RADIO_TYPE, default="ezsp")] = vol.In(RadioType.list())
|
for p in ports
|
||||||
|
]
|
||||||
|
list_of_ports.append(CONF_MANUAL_PATH)
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
database = os.path.join(self.hass.config.config_dir, DEFAULT_DATABASE_NAME)
|
user_selection = user_input[CONF_DEVICE_PATH]
|
||||||
test = await check_zigpy_connection(
|
if user_selection == CONF_MANUAL_PATH:
|
||||||
user_input[CONF_USB_PATH], user_input[CONF_RADIO_TYPE], database
|
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(
|
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"
|
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(
|
return self.async_show_form(
|
||||||
step_id="user", data_schema=vol.Schema(fields), errors=errors
|
step_id="port_config", data_schema=vol.Schema(schema), 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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_zigpy_connection(usb_path, radio_type, database_path):
|
async def detect_radios(dev_path: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Test zigpy radio connection."""
|
"""Probe all radio types on the device port."""
|
||||||
try:
|
for radio in RadioType.list():
|
||||||
radio = RADIO_TYPES[radio_type][ZHA_GW_RADIO]()
|
app_cls = RADIO_TYPES[radio][CONTROLLER]
|
||||||
controller_application = RADIO_TYPES[radio_type][CONTROLLER]
|
dev_config = app_cls.SCHEMA_DEVICE({CONF_DEVICE_PATH: dev_path})
|
||||||
except KeyError:
|
if await app_cls.probe(dev_config):
|
||||||
return False
|
return {CONF_RADIO_TYPE: radio, CONF_DEVICE: dev_config}
|
||||||
try:
|
|
||||||
await radio.connect(usb_path, DEFAULT_BAUDRATE)
|
return None
|
||||||
controller = controller_application(radio, database_path)
|
|
||||||
await asyncio.wait_for(controller.startup(auto_form=True), timeout=30)
|
|
||||||
await controller.shutdown()
|
def get_serial_by_id(dev_path: str) -> str:
|
||||||
except Exception: # pylint: disable=broad-except
|
"""Return a /dev/serial/by-id match for given device if available."""
|
||||||
return False
|
by_id = "/dev/serial/by-id"
|
||||||
return True
|
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
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
import enum
|
import enum
|
||||||
import logging
|
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.binary_sensor import DOMAIN as BINARY_SENSOR
|
||||||
from homeassistant.components.cover import DOMAIN as COVER
|
from homeassistant.components.cover import DOMAIN as COVER
|
||||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
|
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
|
||||||
|
@ -92,8 +94,10 @@ CONF_BAUDRATE = "baudrate"
|
||||||
CONF_DATABASE = "database_path"
|
CONF_DATABASE = "database_path"
|
||||||
CONF_DEVICE_CONFIG = "device_config"
|
CONF_DEVICE_CONFIG = "device_config"
|
||||||
CONF_ENABLE_QUIRKS = "enable_quirks"
|
CONF_ENABLE_QUIRKS = "enable_quirks"
|
||||||
|
CONF_FLOWCONTROL = "flow_control"
|
||||||
CONF_RADIO_TYPE = "radio_type"
|
CONF_RADIO_TYPE = "radio_type"
|
||||||
CONF_USB_PATH = "usb_path"
|
CONF_USB_PATH = "usb_path"
|
||||||
|
CONF_ZIGPY = "zigpy_config"
|
||||||
CONTROLLER = "controller"
|
CONTROLLER = "controller"
|
||||||
|
|
||||||
DATA_DEVICE_CONFIG = "zha_device_config"
|
DATA_DEVICE_CONFIG = "zha_device_config"
|
||||||
|
@ -145,11 +149,11 @@ POWER_BATTERY_OR_UNKNOWN = "Battery or Unknown"
|
||||||
class RadioType(enum.Enum):
|
class RadioType(enum.Enum):
|
||||||
"""Possible options for radio type."""
|
"""Possible options for radio type."""
|
||||||
|
|
||||||
deconz = "deconz"
|
|
||||||
ezsp = "ezsp"
|
ezsp = "ezsp"
|
||||||
|
deconz = "deconz"
|
||||||
ti_cc = "ti_cc"
|
ti_cc = "ti_cc"
|
||||||
xbee = "xbee"
|
|
||||||
zigate = "zigate"
|
zigate = "zigate"
|
||||||
|
xbee = "xbee"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list(cls):
|
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_ENTRY = "log_entry"
|
||||||
ZHA_GW_MSG_LOG_OUTPUT = "log_output"
|
ZHA_GW_MSG_LOG_OUTPUT = "log_output"
|
||||||
ZHA_GW_MSG_RAW_INIT = "raw_device_initialized"
|
ZHA_GW_MSG_RAW_INIT = "raw_device_initialized"
|
||||||
ZHA_GW_RADIO = "radio"
|
|
||||||
ZHA_GW_RADIO_DESCRIPTION = "radio_description"
|
ZHA_GW_RADIO_DESCRIPTION = "radio_description"
|
||||||
|
|
||||||
EFFECT_BLINK = 0x00
|
EFFECT_BLINK = 0x00
|
||||||
|
|
|
@ -10,6 +10,7 @@ import traceback
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from serial import SerialException
|
from serial import SerialException
|
||||||
|
from zigpy.config import CONF_DEVICE
|
||||||
import zigpy.device as zigpy_dev
|
import zigpy.device as zigpy_dev
|
||||||
|
|
||||||
from homeassistant.components.system_log import LogEntry, _figure_out_source
|
from homeassistant.components.system_log import LogEntry, _figure_out_source
|
||||||
|
@ -33,10 +34,9 @@ from .const import (
|
||||||
ATTR_NWK,
|
ATTR_NWK,
|
||||||
ATTR_SIGNATURE,
|
ATTR_SIGNATURE,
|
||||||
ATTR_TYPE,
|
ATTR_TYPE,
|
||||||
CONF_BAUDRATE,
|
|
||||||
CONF_DATABASE,
|
CONF_DATABASE,
|
||||||
CONF_RADIO_TYPE,
|
CONF_RADIO_TYPE,
|
||||||
CONF_USB_PATH,
|
CONF_ZIGPY,
|
||||||
CONTROLLER,
|
CONTROLLER,
|
||||||
DATA_ZHA,
|
DATA_ZHA,
|
||||||
DATA_ZHA_BRIDGE_ID,
|
DATA_ZHA_BRIDGE_ID,
|
||||||
|
@ -52,7 +52,6 @@ from .const import (
|
||||||
DEBUG_LEVEL_ORIGINAL,
|
DEBUG_LEVEL_ORIGINAL,
|
||||||
DEBUG_LEVELS,
|
DEBUG_LEVELS,
|
||||||
DEBUG_RELAY_LOGGERS,
|
DEBUG_RELAY_LOGGERS,
|
||||||
DEFAULT_BAUDRATE,
|
|
||||||
DEFAULT_DATABASE_NAME,
|
DEFAULT_DATABASE_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SIGNAL_ADD_ENTITIES,
|
SIGNAL_ADD_ENTITIES,
|
||||||
|
@ -74,7 +73,6 @@ from .const import (
|
||||||
ZHA_GW_MSG_LOG_ENTRY,
|
ZHA_GW_MSG_LOG_ENTRY,
|
||||||
ZHA_GW_MSG_LOG_OUTPUT,
|
ZHA_GW_MSG_LOG_OUTPUT,
|
||||||
ZHA_GW_MSG_RAW_INIT,
|
ZHA_GW_MSG_RAW_INIT,
|
||||||
ZHA_GW_RADIO,
|
|
||||||
ZHA_GW_RADIO_DESCRIPTION,
|
ZHA_GW_RADIO_DESCRIPTION,
|
||||||
)
|
)
|
||||||
from .device import DeviceStatus, ZHADevice
|
from .device import DeviceStatus, ZHADevice
|
||||||
|
@ -125,43 +123,35 @@ class ZHAGateway:
|
||||||
self.ha_device_registry = await get_dev_reg(self._hass)
|
self.ha_device_registry = await get_dev_reg(self._hass)
|
||||||
self.ha_entity_registry = await get_ent_reg(self._hass)
|
self.ha_entity_registry = await get_ent_reg(self._hass)
|
||||||
|
|
||||||
usb_path = self._config_entry.data.get(CONF_USB_PATH)
|
radio_type = self._config_entry.data[CONF_RADIO_TYPE]
|
||||||
baudrate = self._config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE)
|
|
||||||
radio_type = self._config_entry.data.get(CONF_RADIO_TYPE)
|
|
||||||
|
|
||||||
radio_details = RADIO_TYPES[radio_type]
|
app_controller_cls = RADIO_TYPES[radio_type][CONTROLLER]
|
||||||
radio = radio_details[ZHA_GW_RADIO]()
|
self.radio_description = RADIO_TYPES[radio_type][ZHA_GW_RADIO_DESCRIPTION]
|
||||||
self.radio_description = radio_details[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:
|
try:
|
||||||
await radio.connect(usb_path, baudrate)
|
self.application_controller = await app_controller_cls.new(
|
||||||
except (SerialException, OSError) as exception:
|
app_config, auto_form=True, start_radio=True
|
||||||
_LOGGER.error("Couldn't open serial port for ZHA: %s", str(exception))
|
)
|
||||||
raise ConfigEntryNotReady
|
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)
|
apply_application_controller_patch(self)
|
||||||
self.application_controller.add_listener(self)
|
self.application_controller.add_listener(self)
|
||||||
self.application_controller.groups.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_GATEWAY] = self
|
||||||
self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(
|
self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(
|
||||||
self.application_controller.ieee
|
self.application_controller.ieee
|
||||||
|
|
|
@ -3,18 +3,13 @@ import collections
|
||||||
from typing import Callable, Dict, List, Set, Tuple, Union
|
from typing import Callable, Dict, List, Set, Tuple, Union
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
import bellows.ezsp
|
|
||||||
import bellows.zigbee.application
|
import bellows.zigbee.application
|
||||||
import zigpy.profiles.zha
|
import zigpy.profiles.zha
|
||||||
import zigpy.profiles.zll
|
import zigpy.profiles.zll
|
||||||
import zigpy.zcl as zcl
|
import zigpy.zcl as zcl
|
||||||
import zigpy_cc.api
|
|
||||||
import zigpy_cc.zigbee.application
|
import zigpy_cc.zigbee.application
|
||||||
import zigpy_deconz.api
|
|
||||||
import zigpy_deconz.zigbee.application
|
import zigpy_deconz.zigbee.application
|
||||||
import zigpy_xbee.api
|
|
||||||
import zigpy_xbee.zigbee.application
|
import zigpy_xbee.zigbee.application
|
||||||
import zigpy_zigate.api
|
|
||||||
import zigpy_zigate.zigbee.application
|
import zigpy_zigate.zigbee.application
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
|
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
|
# importing channels updates registries
|
||||||
from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import
|
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 .decorators import CALLABLE_T, DictRegistry, SetRegistry
|
||||||
from .typing import ChannelType
|
from .typing import ChannelType
|
||||||
|
|
||||||
|
@ -131,27 +126,22 @@ CLIENT_CHANNELS_REGISTRY = DictRegistry()
|
||||||
|
|
||||||
RADIO_TYPES = {
|
RADIO_TYPES = {
|
||||||
RadioType.deconz.name: {
|
RadioType.deconz.name: {
|
||||||
ZHA_GW_RADIO: zigpy_deconz.api.Deconz,
|
|
||||||
CONTROLLER: zigpy_deconz.zigbee.application.ControllerApplication,
|
CONTROLLER: zigpy_deconz.zigbee.application.ControllerApplication,
|
||||||
ZHA_GW_RADIO_DESCRIPTION: "Deconz",
|
ZHA_GW_RADIO_DESCRIPTION: "Deconz",
|
||||||
},
|
},
|
||||||
RadioType.ezsp.name: {
|
RadioType.ezsp.name: {
|
||||||
ZHA_GW_RADIO: bellows.ezsp.EZSP,
|
|
||||||
CONTROLLER: bellows.zigbee.application.ControllerApplication,
|
CONTROLLER: bellows.zigbee.application.ControllerApplication,
|
||||||
ZHA_GW_RADIO_DESCRIPTION: "EZSP",
|
ZHA_GW_RADIO_DESCRIPTION: "EZSP",
|
||||||
},
|
},
|
||||||
RadioType.ti_cc.name: {
|
RadioType.ti_cc.name: {
|
||||||
ZHA_GW_RADIO: zigpy_cc.api.API,
|
|
||||||
CONTROLLER: zigpy_cc.zigbee.application.ControllerApplication,
|
CONTROLLER: zigpy_cc.zigbee.application.ControllerApplication,
|
||||||
ZHA_GW_RADIO_DESCRIPTION: "TI CC",
|
ZHA_GW_RADIO_DESCRIPTION: "TI CC",
|
||||||
},
|
},
|
||||||
RadioType.xbee.name: {
|
RadioType.xbee.name: {
|
||||||
ZHA_GW_RADIO: zigpy_xbee.api.XBee,
|
|
||||||
CONTROLLER: zigpy_xbee.zigbee.application.ControllerApplication,
|
CONTROLLER: zigpy_xbee.zigbee.application.ControllerApplication,
|
||||||
ZHA_GW_RADIO_DESCRIPTION: "XBee",
|
ZHA_GW_RADIO_DESCRIPTION: "XBee",
|
||||||
},
|
},
|
||||||
RadioType.zigate.name: {
|
RadioType.zigate.name: {
|
||||||
ZHA_GW_RADIO: zigpy_zigate.api.ZiGate,
|
|
||||||
CONTROLLER: zigpy_zigate.zigbee.application.ControllerApplication,
|
CONTROLLER: zigpy_zigate.zigbee.application.ControllerApplication,
|
||||||
ZHA_GW_RADIO_DESCRIPTION: "ZiGate",
|
ZHA_GW_RADIO_DESCRIPTION: "ZiGate",
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,13 +4,14 @@
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/zha",
|
"documentation": "https://www.home-assistant.io/integrations/zha",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"bellows-homeassistant==0.15.2",
|
"bellows==0.16.1",
|
||||||
|
"pyserial==3.4",
|
||||||
"zha-quirks==0.0.38",
|
"zha-quirks==0.0.38",
|
||||||
"zigpy-cc==0.3.1",
|
"zigpy-cc==0.4.2",
|
||||||
"zigpy-deconz==0.8.1",
|
"zigpy-deconz==0.9.1",
|
||||||
"zigpy-homeassistant==0.19.0",
|
"zigpy==0.20.1",
|
||||||
"zigpy-xbee-homeassistant==0.11.0",
|
"zigpy-xbee==0.12.1",
|
||||||
"zigpy-zigate==0.5.1"
|
"zigpy-zigate==0.6.1"
|
||||||
],
|
],
|
||||||
"codeowners": ["@dmulcahey", "@adminiuga"]
|
"codeowners": ["@dmulcahey", "@adminiuga"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,21 @@
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "ZHA",
|
"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": {
|
"data": {
|
||||||
"radio_type": "Radio Type",
|
"path": "Serial device path",
|
||||||
"usb_path": "[%key:common::config_flow::data::usb_path%]"
|
"baudrate": "port speed",
|
||||||
|
"flow_control": "data flow control"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,11 +7,27 @@
|
||||||
"cannot_connect": "Unable to connect to ZHA device."
|
"cannot_connect": "Unable to connect to ZHA device."
|
||||||
},
|
},
|
||||||
"step": {
|
"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": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"radio_type": "Radio Type",
|
"path": "Serial Device Path"
|
||||||
"usb_path": "USB Device Path"
|
|
||||||
},
|
},
|
||||||
|
"description": "Select serial port for Zigbee radio",
|
||||||
"title": "ZHA"
|
"title": "ZHA"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -327,7 +327,7 @@ beautifulsoup4==4.9.0
|
||||||
beewi_smartclim==0.0.7
|
beewi_smartclim==0.0.7
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
bellows-homeassistant==0.15.2
|
bellows==0.16.1
|
||||||
|
|
||||||
# homeassistant.components.bmw_connected_drive
|
# homeassistant.components.bmw_connected_drive
|
||||||
bimmer_connected==0.7.5
|
bimmer_connected==0.7.5
|
||||||
|
@ -1557,6 +1557,7 @@ pysensibo==1.0.3
|
||||||
pyserial-asyncio==0.4
|
pyserial-asyncio==0.4
|
||||||
|
|
||||||
# homeassistant.components.acer_projector
|
# homeassistant.components.acer_projector
|
||||||
|
# homeassistant.components.zha
|
||||||
pyserial==3.4
|
pyserial==3.4
|
||||||
|
|
||||||
# homeassistant.components.sesame
|
# homeassistant.components.sesame
|
||||||
|
@ -2217,19 +2218,19 @@ zhong_hong_hvac==1.0.9
|
||||||
ziggo-mediabox-xl==1.1.0
|
ziggo-mediabox-xl==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-cc==0.3.1
|
zigpy-cc==0.4.2
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-deconz==0.8.1
|
zigpy-deconz==0.9.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-homeassistant==0.19.0
|
zigpy-xbee==0.12.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-xbee-homeassistant==0.11.0
|
zigpy-zigate==0.6.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-zigate==0.5.1
|
zigpy==0.20.1
|
||||||
|
|
||||||
# homeassistant.components.zoneminder
|
# homeassistant.components.zoneminder
|
||||||
zm-py==0.4.0
|
zm-py==0.4.0
|
||||||
|
|
|
@ -141,7 +141,7 @@ axis==25
|
||||||
base36==0.1.1
|
base36==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
bellows-homeassistant==0.15.2
|
bellows==0.16.1
|
||||||
|
|
||||||
# homeassistant.components.blebox
|
# homeassistant.components.blebox
|
||||||
blebox_uniapi==1.3.2
|
blebox_uniapi==1.3.2
|
||||||
|
@ -634,6 +634,10 @@ pyps4-2ndscreen==1.0.7
|
||||||
# homeassistant.components.qwikswitch
|
# homeassistant.components.qwikswitch
|
||||||
pyqwikswitch==0.93
|
pyqwikswitch==0.93
|
||||||
|
|
||||||
|
# homeassistant.components.acer_projector
|
||||||
|
# homeassistant.components.zha
|
||||||
|
pyserial==3.4
|
||||||
|
|
||||||
# homeassistant.components.signal_messenger
|
# homeassistant.components.signal_messenger
|
||||||
pysignalclirestapi==0.3.4
|
pysignalclirestapi==0.3.4
|
||||||
|
|
||||||
|
@ -857,16 +861,16 @@ zeroconf==0.26.0
|
||||||
zha-quirks==0.0.38
|
zha-quirks==0.0.38
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-cc==0.3.1
|
zigpy-cc==0.4.2
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-deconz==0.8.1
|
zigpy-deconz==0.9.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-homeassistant==0.19.0
|
zigpy-xbee==0.12.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-xbee-homeassistant==0.11.0
|
zigpy-zigate==0.6.1
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zigpy-zigate==0.5.1
|
zigpy==0.20.1
|
||||||
|
|
|
@ -4,6 +4,7 @@ from unittest import mock
|
||||||
import pytest
|
import pytest
|
||||||
import zigpy
|
import zigpy
|
||||||
from zigpy.application import ControllerApplication
|
from zigpy.application import ControllerApplication
|
||||||
|
import zigpy.config
|
||||||
import zigpy.group
|
import zigpy.group
|
||||||
import zigpy.types
|
import zigpy.types
|
||||||
|
|
||||||
|
@ -49,12 +50,11 @@ def zigpy_radio():
|
||||||
async def config_entry_fixture(hass):
|
async def config_entry_fixture(hass):
|
||||||
"""Fixture representing a config entry."""
|
"""Fixture representing a config entry."""
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
version=1,
|
version=2,
|
||||||
domain=zha_const.DOMAIN,
|
domain=zha_const.DOMAIN,
|
||||||
data={
|
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_RADIO_TYPE: "MockRadio",
|
||||||
zha_const.CONF_USB_PATH: "/dev/ttyUSB0",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass)
|
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):
|
def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio):
|
||||||
"""Set up ZHA component."""
|
"""Set up ZHA component."""
|
||||||
zha_config = {zha_const.CONF_ENABLE_QUIRKS: False}
|
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 = {
|
radio_details = {
|
||||||
zha_const.ZHA_GW_RADIO: mock.MagicMock(return_value=zigpy_radio),
|
zha_const.CONTROLLER: app_ctrl,
|
||||||
zha_const.CONTROLLER: mock.MagicMock(return_value=zigpy_app_controller),
|
|
||||||
zha_const.ZHA_GW_RADIO_DESCRIPTION: "mock radio",
|
zha_const.ZHA_GW_RADIO_DESCRIPTION: "mock radio",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,125 +1,237 @@
|
||||||
"""Tests for ZHA config flow."""
|
"""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 import config_flow
|
||||||
from homeassistant.components.zha.core.const import CONTROLLER, DOMAIN, ZHA_GW_RADIO
|
from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN
|
||||||
import homeassistant.components.zha.core.registries
|
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
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
async def test_user_flow(hass):
|
def com_port():
|
||||||
"""Test that config flow works."""
|
"""Mock of a serial port."""
|
||||||
flow = config_flow.ZhaFlowHandler()
|
port = serial.tools.list_ports_common.ListPortInfo()
|
||||||
flow.hass = hass
|
port.serial_number = "1234"
|
||||||
|
port.manufacturer = "Virtual serial port"
|
||||||
|
port.device = "/dev/ttyUSB1234"
|
||||||
|
port.description = "Some serial port"
|
||||||
|
|
||||||
with tests.async_mock.patch(
|
return port
|
||||||
"homeassistant.components.zha.config_flow.check_zigpy_connection",
|
|
||||||
return_value=False,
|
|
||||||
):
|
|
||||||
result = await flow.async_step_user(
|
|
||||||
user_input={"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["errors"] == {"base": "cannot_connect"}
|
|
||||||
|
|
||||||
with tests.async_mock.patch(
|
@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()]))
|
||||||
"homeassistant.components.zha.config_flow.check_zigpy_connection",
|
@patch(
|
||||||
return_value=True,
|
"homeassistant.components.zha.config_flow.detect_radios",
|
||||||
):
|
return_value={CONF_RADIO_TYPE: "test_radio"},
|
||||||
result = await flow.async_step_user(
|
)
|
||||||
user_input={"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"}
|
async def test_user_flow(detect_mock, hass):
|
||||||
)
|
"""Test user flow -- radio detected."""
|
||||||
|
|
||||||
assert result["type"] == "create_entry"
|
port = com_port()
|
||||||
assert result["title"] == "/dev/ttyUSB1"
|
port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}"
|
||||||
assert result["data"] == {"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"}
|
|
||||||
|
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):
|
async def test_user_flow_existing_config_entry(hass):
|
||||||
"""Test if config entry already exists."""
|
"""Test if config entry already exists."""
|
||||||
MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass)
|
MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass)
|
||||||
flow = config_flow.ZhaFlowHandler()
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
flow.hass = hass
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||||
result = await flow.async_step_user()
|
|
||||||
|
|
||||||
assert result["type"] == "abort"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_import_flow(hass):
|
|
||||||
"""Test import from configuration.yaml ."""
|
|
||||||
flow = config_flow.ZhaFlowHandler()
|
|
||||||
flow.hass = hass
|
|
||||||
|
|
||||||
result = await flow.async_step_import(
|
|
||||||
{"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["type"] == "create_entry"
|
|
||||||
assert result["title"] == "/dev/ttyUSB1"
|
|
||||||
assert result["data"] == {"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_import_flow_existing_config_entry(hass):
|
|
||||||
"""Test import from configuration.yaml ."""
|
|
||||||
MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass)
|
|
||||||
flow = config_flow.ZhaFlowHandler()
|
|
||||||
flow.hass = hass
|
|
||||||
|
|
||||||
result = await flow.async_step_import(
|
|
||||||
{"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == "abort"
|
assert result["type"] == "abort"
|
||||||
|
|
||||||
|
|
||||||
async def test_check_zigpy_connection():
|
async def test_probe_radios(hass):
|
||||||
"""Test config flow validator."""
|
"""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()
|
with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}):
|
||||||
mock_radio.connect = tests.async_mock.AsyncMock()
|
res = await config_flow.detect_radios("/dev/null")
|
||||||
radio_cls = tests.async_mock.MagicMock(return_value=mock_radio)
|
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()
|
res = await config_flow.detect_radios("/dev/null")
|
||||||
bad_radio.connect = tests.async_mock.AsyncMock(side_effect=Exception)
|
assert res is None
|
||||||
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},
|
|
||||||
}
|
|
||||||
|
|
||||||
with mock.patch.dict(
|
async def test_user_port_config_fail(hass):
|
||||||
homeassistant.components.zha.core.registries.RADIO_TYPES, new_radios, clear=True
|
"""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(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
mock.sentinel.usb_path, mock.sentinel.unk_radio, mock.sentinel.zigbee_db
|
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 result["type"] == "create_entry"
|
||||||
assert not await config_flow.check_zigpy_connection(
|
assert result["title"].startswith("/dev/ttyUSB33")
|
||||||
mock.sentinel.usb_path, mock.sentinel.bad_radio, mock.sentinel.zigbee_db
|
assert (
|
||||||
|
result["data"][zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH]
|
||||||
|
== "/dev/ttyUSB33"
|
||||||
)
|
)
|
||||||
assert mock_radio.connect.call_count == 0
|
assert result["data"][CONF_RADIO_TYPE] == radio_type
|
||||||
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(
|
def test_get_serial_by_id_no_dir():
|
||||||
mock.sentinel.usb_path, mock.sentinel.radio, mock.sentinel.zigbee_db
|
"""Test serial by id conversion if there's no /dev/serial/by-id."""
|
||||||
)
|
p1 = patch("os.path.isdir", MagicMock(return_value=False))
|
||||||
assert mock_radio.connect.call_count == 1
|
p2 = patch("os.scandir")
|
||||||
assert bad_radio.connect.call_count == 1
|
with p1 as is_dir_mock, p2 as scan_mock:
|
||||||
assert mock_ctrl.startup.call_count == 1
|
res = config_flow.get_serial_by_id(sentinel.path)
|
||||||
assert mock_ctrl.shutdown.call_count == 1
|
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
|
||||||
|
|
72
tests/components/zha/test_init.py
Normal file
72
tests/components/zha/test_init.py
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue