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
Changes from 20 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
@@ -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
82 changes: 49 additions & 33 deletions homeassistant/components/zha/__init__.py
Original file line number Diff line number Diff line change
@@ -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
@@ -14,44 +15,47 @@
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,
Adminiuga marked this conversation as resolved.
Show resolved Hide resolved
),
},
extra=vol.ALLOW_EXTRA,
)
@@ -67,23 +71,10 @@ async def async_setup(hass, config):
"""Set up ZHA from config."""
hass.data[DATA_ZHA] = {}

if DOMAIN not in config:
return True

conf = config[DOMAIN]
hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf

if not hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
CONF_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


@@ -161,3 +152,28 @@ async def async_load_entities(hass: HomeAssistantType) -> None:
if isinstance(res, Exception):
_LOGGER.warning("Couldn't setup zha platform: %s", res)
async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES)


async def async_migrate_entry(
hass: HomeAssistantType, config_entry: config_entries.ConfigEntry
):
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)

if config_entry.version == 1:
config_entry.data = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data attribute should be considered read only. We set it to a mapping proxy when the config entry is defined. We should use hass.config_entries.async_update_entry to update the data.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, i'll update it.
BTW this was copied that from axis integration, so that may need update too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, it's using async_update_entry() now. For config entry migration, i still have to set the config_entry.version attribute to the new version, right?

CONF_RADIO_TYPE: config_entry.data[CONF_RADIO_TYPE],
CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]},
}

baudrate = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}).get(CONF_BAUDRATE)
if (
config_entry.data[CONF_RADIO_TYPE] != RadioType.deconz
and baudrate in BAUD_RATES
):
config_entry.data[CONF_DEVICE][CONF_BAUDRATE] = baudrate

config_entry.version = 2

_LOGGER.info("Migration to version %s successful", config_entry.version)
return True
136 changes: 93 additions & 43 deletions homeassistant/components/zha/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,80 +1,130 @@
"""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_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"

@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
}
return self.async_show_form(
step_id="user", data_schema=vol.Schema(fields), errors=errors
step_id="port_config",
data_schema=app_cls.SCHEMA_DEVICE.extend(schema),
Adminiuga marked this conversation as resolved.
Show resolved Hide resolved
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
6 changes: 3 additions & 3 deletions homeassistant/components/zha/core/const.py
Original file line number Diff line number Diff line change
@@ -94,6 +94,7 @@
CONF_ENABLE_QUIRKS = "enable_quirks"
CONF_RADIO_TYPE = "radio_type"
CONF_USB_PATH = "usb_path"
CONF_ZIGPY = "zigpy_config"
CONTROLLER = "controller"

DATA_DEVICE_CONFIG = "zha_device_config"
@@ -145,11 +146,11 @@
class RadioType(enum.Enum):
"""Possible options for radio type."""

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

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

EFFECT_BLINK = 0x00
Loading