Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New configuration flow for ZHA integration #35161

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 46 additions & 33 deletions homeassistant/components/zha/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging

import voluptuous as vol
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH

from homeassistant import config_entries, const as ha_const
import homeassistant.helpers.config_validation as cv
Expand All @@ -14,44 +15,46 @@
from . import api
from .core import ZHAGateway
from .core.const import (
BAUD_RATES,
COMPONENTS,
CONF_BAUDRATE,
CONF_DATABASE,
CONF_DEVICE_CONFIG,
CONF_ENABLE_QUIRKS,
CONF_RADIO_TYPE,
CONF_USB_PATH,
CONF_ZIGPY,
DATA_ZHA,
DATA_ZHA_CONFIG,
DATA_ZHA_DISPATCHERS,
DATA_ZHA_GATEWAY,
DATA_ZHA_PLATFORM_LOADED,
DEFAULT_BAUDRATE,
DEFAULT_RADIO_TYPE,
DOMAIN,
SIGNAL_ADD_ENTITIES,
RadioType,
)
from .core.discovery import GROUP_PROBE

DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(ha_const.CONF_TYPE): cv.string})

ZHA_CONFIG_SCHEMA = {
vol.Optional(CONF_BAUDRATE): cv.positive_int,
vol.Optional(CONF_DATABASE): cv.string,
vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema(
{cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}
),
vol.Optional(CONF_ENABLE_QUIRKS, default=True): cv.boolean,
vol.Optional(CONF_ZIGPY): dict,
}
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(CONF_RADIO_TYPE, default=DEFAULT_RADIO_TYPE): cv.enum(
RadioType
),
CONF_USB_PATH: cv.string,
vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int,
vol.Optional(CONF_DATABASE): cv.string,
vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema(
{cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}
),
vol.Optional(CONF_ENABLE_QUIRKS, default=True): cv.boolean,
}
)
vol.All(
cv.deprecated(CONF_USB_PATH, invalidation_version="0.112"),
cv.deprecated(CONF_BAUDRATE, invalidation_version="0.112"),
cv.deprecated(CONF_RADIO_TYPE, invalidation_version="0.112"),
ZHA_CONFIG_SCHEMA,
),
),
},
extra=vol.ALLOW_EXTRA,
)
Expand All @@ -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

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,
},
)
)
if DOMAIN in config:
conf = config[DOMAIN]
hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf

return True


Expand Down Expand Up @@ -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
148 changes: 105 additions & 43 deletions homeassistant/components/zha/config_flow.py
Original file line number Diff line number Diff line change
@@ -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
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 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

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
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
9 changes: 6 additions & 3 deletions homeassistant/components/zha/core/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,8 +94,10 @@
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"
Expand Down Expand Up @@ -145,11 +149,11 @@
class RadioType(enum.Enum):
"""Possible options for radio type."""

deconz = "deconz"
ezsp = "ezsp"
deconz = "deconz"
ti_cc = "ti_cc"
xbee = "xbee"
zigate = "zigate"
xbee = "xbee"

@classmethod
def list(cls):
Expand Down Expand Up @@ -258,7 +262,6 @@ def list(cls):
ZHA_GW_MSG_LOG_ENTRY = "log_entry"
ZHA_GW_MSG_LOG_OUTPUT = "log_output"
ZHA_GW_MSG_RAW_INIT = "raw_device_initialized"
ZHA_GW_RADIO = "radio"
ZHA_GW_RADIO_DESCRIPTION = "radio_description"

EFFECT_BLINK = 0x00
Expand Down
Loading