Add config flow, use async loading, and restore brightness option to ISY994 (#35413)

* ISY994 Add Config and Options Flow


Add tests for config flow


Fix test


Update Tests

* Fix merge errors

* Update homeassistant/components/isy994/strings.json

Co-authored-by: J. Nick Koston <nick@koston.org>

* Apply suggestions from code review

Co-authored-by: J. Nick Koston <nick@koston.org>

* Fix patching in tests to not actually start Home Assistant

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
shbatm 2020-05-09 14:49:00 -05:00 committed by GitHub
parent d61bde6ae6
commit d7f736ed6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 847 additions and 149 deletions

View file

@ -1,31 +1,34 @@
"""Support the ISY-994 controllers."""
import asyncio
from functools import partial
from typing import Optional
from urllib.parse import urlparse
from pyisy import ISY
import voluptuous as vol
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
_LOGGER,
CONF_IGNORE_STRING,
CONF_RESTORE_LIGHT_STATE,
CONF_SENSOR_STRING,
CONF_TLS_VER,
DEFAULT_IGNORE_STRING,
DEFAULT_RESTORE_LIGHT_STATE,
DEFAULT_SENSOR_STRING,
DOMAIN,
ISY994_ISY,
ISY994_NODES,
ISY994_PROGRAMS,
SUPPORTED_PLATFORMS,
SUPPORTED_PROGRAM_PLATFORMS,
UNDO_UPDATE_LISTENER,
)
from .helpers import _categorize_nodes, _categorize_programs
@ -43,6 +46,9 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(
CONF_SENSOR_STRING, default=DEFAULT_SENSOR_STRING
): cv.string,
vol.Required(
CONF_RESTORE_LIGHT_STATE, default=DEFAULT_RESTORE_LIGHT_STATE
): bool,
}
)
},
@ -50,24 +56,71 @@ CONFIG_SCHEMA = vol.Schema(
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the ISY 994 platform."""
hass.data[ISY994_NODES] = {}
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the isy994 integration from YAML."""
isy_config: Optional[ConfigType] = config.get(DOMAIN)
hass.data.setdefault(DOMAIN, {})
if not isy_config:
return True
# Only import if we haven't before.
config_entry = _async_find_matching_config_entry(hass)
if not config_entry:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=dict(isy_config),
)
)
return True
# Update the entry based on the YAML configuration, in case it changed.
hass.config_entries.async_update_entry(config_entry, data=dict(isy_config))
return True
@callback
def _async_find_matching_config_entry(hass):
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.source == config_entries.SOURCE_IMPORT:
return entry
async def async_setup_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
"""Set up the ISY 994 integration."""
# As there currently is no way to import options from yaml
# when setting up a config entry, we fallback to adding
# the options to the config entry and pull them out here if
# they are missing from the options
_async_import_options_from_data_if_missing(hass, entry)
hass.data[DOMAIN][entry.entry_id] = {}
hass_isy_data = hass.data[DOMAIN][entry.entry_id]
hass_isy_data[ISY994_NODES] = {}
for platform in SUPPORTED_PLATFORMS:
hass.data[ISY994_NODES][platform] = []
hass_isy_data[ISY994_NODES][platform] = []
hass.data[ISY994_PROGRAMS] = {}
hass_isy_data[ISY994_PROGRAMS] = {}
for platform in SUPPORTED_PROGRAM_PLATFORMS:
hass.data[ISY994_PROGRAMS][platform] = []
hass_isy_data[ISY994_PROGRAMS][platform] = []
isy_config = config.get(DOMAIN)
isy_config = entry.data
isy_options = entry.options
user = isy_config.get(CONF_USERNAME)
password = isy_config.get(CONF_PASSWORD)
# Required
user = isy_config[CONF_USERNAME]
password = isy_config[CONF_PASSWORD]
host = urlparse(isy_config[CONF_HOST])
# Optional
tls_version = isy_config.get(CONF_TLS_VER)
host = urlparse(isy_config.get(CONF_HOST))
ignore_identifier = isy_config.get(CONF_IGNORE_STRING)
sensor_identifier = isy_config.get(CONF_SENSOR_STRING)
ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING)
sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING)
if host.scheme == "http":
https = False
@ -80,7 +133,9 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return False
# Connect to ISY controller.
isy = ISY(
isy = await hass.async_add_executor_job(
partial(
ISY,
host.hostname,
port,
username=user,
@ -88,23 +143,93 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
use_https=https,
tls_ver=tls_version,
log=_LOGGER,
webroot=host.path,
)
)
if not isy.connected:
return False
_categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier)
_categorize_programs(hass, isy.programs)
_categorize_nodes(hass_isy_data, isy.nodes, ignore_identifier, sensor_identifier)
_categorize_programs(hass_isy_data, isy.programs)
def stop(event: object) -> None:
"""Stop ISY auto updates."""
isy.auto_update = False
# Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs
_LOGGER.info(repr(isy.clock))
# Listen for HA stop to disconnect.
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
hass_isy_data[ISY994_ISY] = isy
# Load platforms for the devices in the ISY controller that we support.
for platform in SUPPORTED_PLATFORMS:
discovery.load_platform(hass, platform, DOMAIN, {}, config)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
def _start_auto_update() -> None:
"""Start isy auto update."""
_LOGGER.debug("ISY Starting Event Stream and automatic updates.")
isy.auto_update = True
await hass.async_add_executor_job(_start_auto_update)
undo_listener = entry.add_update_listener(_async_update_listener)
hass_isy_data[UNDO_UPDATE_LISTENER] = undo_listener
return True
async def _async_update_listener(
hass: HomeAssistant, entry: config_entries.ConfigEntry
):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
@callback
def _async_import_options_from_data_if_missing(
hass: HomeAssistant, entry: config_entries.ConfigEntry
):
options = dict(entry.options)
modified = False
for importable_option in [
CONF_IGNORE_STRING,
CONF_SENSOR_STRING,
CONF_RESTORE_LIGHT_STATE,
]:
if importable_option not in entry.options and importable_option in entry.data:
options[importable_option] = entry.data[importable_option]
modified = True
if modified:
hass.config_entries.async_update_entry(entry, options=options)
async def async_unload_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, platform)
for platform in SUPPORTED_PLATFORMS
]
)
)
hass_isy_data = hass.data[DOMAIN][entry.entry_id]
isy = hass_isy_data[ISY994_ISY]
def _stop_auto_update() -> None:
"""Start isy auto update."""
_LOGGER.debug("ISY Stopping Event Stream and automatic updates.")
isy.auto_update = False
await hass.async_add_executor_job(_stop_auto_update)
hass_isy_data[UNDO_UPDATE_LISTENER]()
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -23,20 +23,24 @@ from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import dt as dt_util
from . import ISY994_NODES, ISY994_PROGRAMS
from .const import (
_LOGGER,
BINARY_SENSOR_DEVICE_TYPES_ISY,
BINARY_SENSOR_DEVICE_TYPES_ZWAVE,
DOMAIN as ISY994_DOMAIN,
ISY994_NODES,
ISY994_PROGRAMS,
TYPE_CATEGORY_CLIMATE,
)
from .entity import ISYNodeEntity, ISYProgramEntity
from .helpers import migrate_old_unique_ids
DEVICE_PARENT_REQUIRED = [
DEVICE_CLASS_OPENING,
@ -56,15 +60,18 @@ SUBNODE_MOTION_DISABLED = (13, 19) # Int->13 or Hex->0xD depending on firmware
TYPE_INSTEON_MOTION = ("16.1.", "16.22.")
def setup_platform(
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
):
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[list], None],
) -> bool:
"""Set up the ISY994 binary sensor platform."""
devices = []
devices_by_address = {}
child_nodes = []
for node in hass.data[ISY994_NODES][BINARY_SENSOR]:
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
for node in hass_isy_data[ISY994_NODES][BINARY_SENSOR]:
device_class, device_type = _detect_device_type_and_class(node)
if node.protocol == PROTO_INSTEON:
if node.parent_node is not None:
@ -162,10 +169,11 @@ def setup_platform(
device = ISYBinarySensorEntity(node, device_class)
devices.append(device)
for name, status, _ in hass.data[ISY994_PROGRAMS][BINARY_SENSOR]:
for name, status, _ in hass_isy_data[ISY994_PROGRAMS][BINARY_SENSOR]:
devices.append(ISYBinarySensorProgramEntity(name, status))
add_entities(devices)
await migrate_old_unique_ids(hass, BINARY_SENSOR, devices)
async_add_entities(devices)
def _detect_device_type_and_class(node: Union[Group, Node]) -> (str, str):

View file

@ -0,0 +1,180 @@
"""Config flow for Universal Devices ISY994 integration."""
import logging
from urllib.parse import urlparse
from pyisy.configuration import Configuration
from pyisy.connection import Connection
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from .const import (
CONF_IGNORE_STRING,
CONF_RESTORE_LIGHT_STATE,
CONF_SENSOR_STRING,
CONF_TLS_VER,
DEFAULT_IGNORE_STRING,
DEFAULT_RESTORE_LIGHT_STATE,
DEFAULT_SENSOR_STRING,
DEFAULT_TLS_VERSION,
)
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_TLS_VER, default=DEFAULT_TLS_VERSION): vol.In([1.1, 1.2]),
},
extra=vol.ALLOW_EXTRA,
)
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
user = data[CONF_USERNAME]
password = data[CONF_PASSWORD]
host = urlparse(data[CONF_HOST])
tls_version = data.get(CONF_TLS_VER)
if host.scheme == "http":
https = False
port = host.port or 80
elif host.scheme == "https":
https = True
port = host.port or 443
else:
_LOGGER.error("isy994 host value in configuration is invalid")
raise InvalidHost
# Connect to ISY controller.
isy_conf = await hass.async_add_executor_job(
_fetch_isy_configuration,
host.hostname,
port,
user,
password,
https,
tls_version,
host.path,
)
# Return info that you want to store in the config entry.
return {"title": f"{isy_conf['name']} ({host.hostname})", "uuid": isy_conf["uuid"]}
def _fetch_isy_configuration(
address, port, username, password, use_https, tls_ver, webroot
):
"""Validate and fetch the configuration from the ISY."""
try:
isy_conn = Connection(
address,
port,
username,
password,
use_https,
tls_ver,
log=_LOGGER,
webroot=webroot,
)
except ValueError as err:
raise InvalidAuth(err.args[0])
return Configuration(log=_LOGGER, xml=isy_conn.get_config())
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Universal Devices ISY994."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
info = None
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidHost:
errors["base"] = "invalid_host"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if "base" not in errors:
await self.async_set_unique_id(info["uuid"])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_import(self, user_input):
"""Handle import."""
return await self.async_step_user(user_input)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow for isy994."""
def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options = self.config_entry.options
restore_light_state = options.get(
CONF_RESTORE_LIGHT_STATE, DEFAULT_RESTORE_LIGHT_STATE
)
ignore_string = options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING)
sensor_string = options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING)
options_schema = vol.Schema(
{
vol.Optional(CONF_IGNORE_STRING, default=ignore_string): str,
vol.Optional(CONF_SENSOR_STRING, default=sensor_string): str,
vol.Required(
CONF_RESTORE_LIGHT_STATE, default=restore_light_state
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=options_schema)
class InvalidHost(exceptions.HomeAssistantError):
"""Error to indicate the host value is invalid."""
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View file

@ -98,9 +98,11 @@ MANUFACTURER = "Universal Devices, Inc"
CONF_IGNORE_STRING = "ignore_string"
CONF_SENSOR_STRING = "sensor_string"
CONF_TLS_VER = "tls"
CONF_RESTORE_LIGHT_STATE = "restore_light_state"
DEFAULT_IGNORE_STRING = "{IGNORE ME}"
DEFAULT_SENSOR_STRING = "sensor"
DEFAULT_RESTORE_LIGHT_STATE = False
DEFAULT_TLS_VERSION = 1.1
DEFAULT_PROGRAM_STRING = "HA."
@ -136,12 +138,16 @@ TYPE_CATEGORY_POOL_CTL = "6."
TYPE_CATEGORY_SENSOR_ACTUATORS = "7."
TYPE_CATEGORY_ENERGY_MGMT = "9."
TYPE_CATEGORY_COVER = "14."
TYPE_CATEOGRY_LOCK = "15."
TYPE_CATEGORY_LOCK = "15."
TYPE_CATEGORY_SAFETY = "16."
TYPE_CATEGORY_X10 = "113."
UNDO_UPDATE_LISTENER = "undo_update_listener"
# Do not use the Home Assistant consts for the states here - we're matching exact API
# responses, not using them for Home Assistant states
# Insteon Types: https://www.universal-devices.com/developers/wsdk/5.0.4/1_fam.xml
# Z-Wave Categories: https://www.universal-devices.com/developers/wsdk/5.0.4/4_fam.xml
NODE_FILTERS = {
BINARY_SENSOR: {
FILTER_UOM: [],
@ -191,7 +197,7 @@ NODE_FILTERS = {
FILTER_UOM: ["11"],
FILTER_STATES: ["locked", "unlocked"],
FILTER_NODE_DEF_ID: ["DoorLock"],
FILTER_INSTEON_TYPE: [TYPE_CATEOGRY_LOCK, "4.64."],
FILTER_INSTEON_TYPE: [TYPE_CATEGORY_LOCK, "4.64."],
FILTER_ZWAVE_CAT: ["111"],
},
FAN: {

View file

@ -4,26 +4,37 @@ from typing import Callable
from pyisy.constants import ISY_VALUE_UNKNOWN
from homeassistant.components.cover import DOMAIN as COVER, CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_CLOSED, STATE_OPEN, STATE_UNKNOWN
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import HomeAssistantType
from . import ISY994_NODES, ISY994_PROGRAMS
from .const import _LOGGER, UOM_TO_STATES
from .const import (
_LOGGER,
DOMAIN as ISY994_DOMAIN,
ISY994_NODES,
ISY994_PROGRAMS,
UOM_TO_STATES,
)
from .entity import ISYNodeEntity, ISYProgramEntity
from .helpers import migrate_old_unique_ids
def setup_platform(
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
):
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[list], None],
) -> bool:
"""Set up the ISY994 cover platform."""
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
devices = []
for node in hass.data[ISY994_NODES][COVER]:
for node in hass_isy_data[ISY994_NODES][COVER]:
devices.append(ISYCoverEntity(node))
for name, status, actions in hass.data[ISY994_PROGRAMS][COVER]:
for name, status, actions in hass_isy_data[ISY994_PROGRAMS][COVER]:
devices.append(ISYCoverProgramEntity(name, status, actions))
add_entities(devices)
await migrate_old_unique_ids(hass, COVER, devices)
async_add_entities(devices)
class ISYCoverEntity(ISYNodeEntity, CoverEntity):

View file

@ -56,6 +56,13 @@ class ISYEntity(Entity):
@property
def unique_id(self) -> str:
"""Get the unique identifier of the device."""
if hasattr(self._node, "address"):
return f"{self._node.isy.configuration['uuid']}_{self._node.address}"
return None
@property
def old_unique_id(self) -> str:
"""Get the old unique identifier of the device."""
if hasattr(self._node, "address"):
return self._node.address
return None

View file

@ -10,11 +10,12 @@ from homeassistant.components.fan import (
SUPPORT_SET_SPEED,
FanEntity,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
from . import ISY994_NODES, ISY994_PROGRAMS
from .const import _LOGGER
from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS
from .entity import ISYNodeEntity, ISYProgramEntity
from .helpers import migrate_old_unique_ids
VALUE_TO_STATE = {
0: SPEED_OFF,
@ -30,19 +31,23 @@ for key in VALUE_TO_STATE:
STATE_TO_VALUE[VALUE_TO_STATE[key]] = key
def setup_platform(
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
):
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[list], None],
) -> bool:
"""Set up the ISY994 fan platform."""
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
devices = []
for node in hass.data[ISY994_NODES][FAN]:
for node in hass_isy_data[ISY994_NODES][FAN]:
devices.append(ISYFanEntity(node))
for name, status, actions in hass.data[ISY994_PROGRAMS][FAN]:
for name, status, actions in hass_isy_data[ISY994_PROGRAMS][FAN]:
devices.append(ISYFanProgramEntity(name, status, actions))
add_entities(devices)
await migrate_old_unique_ids(hass, FAN, devices)
async_add_entities(devices)
class ISYFanEntity(ISYNodeEntity, FanEntity):

View file

@ -1,5 +1,5 @@
"""Sorting helpers for ISY994 device classifications."""
from typing import Union
from typing import Any, List, Optional, Union
from pyisy.constants import (
PROTO_GROUP,
@ -16,11 +16,13 @@ from homeassistant.components.fan import DOMAIN as FAN
from homeassistant.components.light import DOMAIN as LIGHT
from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.components.switch import DOMAIN as SWITCH
from homeassistant.helpers.entity_registry import async_get_registry
from homeassistant.helpers.typing import HomeAssistantType
from .const import (
_LOGGER,
DEFAULT_PROGRAM_STRING,
DOMAIN,
FILTER_INSTEON_TYPE,
FILTER_NODE_DEF_ID,
FILTER_STATES,
@ -48,7 +50,7 @@ SUBNODE_IOLINC_RELAY = 2
def _check_for_node_def(
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
hass_isy_data: dict, node: Union[Group, Node], single_platform: str = None
) -> bool:
"""Check if the node matches the node_def_id for any platforms.
@ -64,14 +66,14 @@ def _check_for_node_def(
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
for platform in platforms:
if node_def_id in NODE_FILTERS[platform][FILTER_NODE_DEF_ID]:
hass.data[ISY994_NODES][platform].append(node)
hass_isy_data[ISY994_NODES][platform].append(node)
return True
return False
def _check_for_insteon_type(
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
hass_isy_data: dict, node: Union[Group, Node], single_platform: str = None
) -> bool:
"""Check if the node matches the Insteon type for any platforms.
@ -102,7 +104,7 @@ def _check_for_insteon_type(
# FanLinc, which has a light module as one of its nodes.
if platform == FAN and subnode_id == SUBNODE_FANLINC_LIGHT:
hass.data[ISY994_NODES][LIGHT].append(node)
hass_isy_data[ISY994_NODES][LIGHT].append(node)
return True
# IOLincs which have a sensor and relay on 2 different nodes
@ -111,7 +113,7 @@ def _check_for_insteon_type(
and device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS)
and subnode_id == SUBNODE_IOLINC_RELAY
):
hass.data[ISY994_NODES][SWITCH].append(node)
hass_isy_data[ISY994_NODES][SWITCH].append(node)
return True
# Smartenit EZIO2X4
@ -120,17 +122,17 @@ def _check_for_insteon_type(
and device_type.startswith(TYPE_EZIO2X4)
and subnode_id in SUBNODE_EZIO2X4_SENSORS
):
hass.data[ISY994_NODES][BINARY_SENSOR].append(node)
hass_isy_data[ISY994_NODES][BINARY_SENSOR].append(node)
return True
hass.data[ISY994_NODES][platform].append(node)
hass_isy_data[ISY994_NODES][platform].append(node)
return True
return False
def _check_for_zwave_cat(
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
hass_isy_data: dict, node: Union[Group, Node], single_platform: str = None
) -> bool:
"""Check if the node matches the ISY Z-Wave Category for any platforms.
@ -154,14 +156,14 @@ def _check_for_zwave_cat(
]
):
hass.data[ISY994_NODES][platform].append(node)
hass_isy_data[ISY994_NODES][platform].append(node)
return True
return False
def _check_for_uom_id(
hass: HomeAssistantType,
hass_isy_data: dict,
node: Union[Group, Node],
single_platform: str = None,
uom_list: list = None,
@ -182,21 +184,21 @@ def _check_for_uom_id(
if uom_list:
if node_uom in uom_list:
hass.data[ISY994_NODES][single_platform].append(node)
hass_isy_data[ISY994_NODES][single_platform].append(node)
return True
return False
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
for platform in platforms:
if node_uom in NODE_FILTERS[platform][FILTER_UOM]:
hass.data[ISY994_NODES][platform].append(node)
hass_isy_data[ISY994_NODES][platform].append(node)
return True
return False
def _check_for_states_in_uom(
hass: HomeAssistantType,
hass_isy_data: dict,
node: Union[Group, Node],
single_platform: str = None,
states_list: list = None,
@ -219,26 +221,24 @@ def _check_for_states_in_uom(
if states_list:
if node_uom == set(states_list):
hass.data[ISY994_NODES][single_platform].append(node)
hass_isy_data[ISY994_NODES][single_platform].append(node)
return True
return False
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
for platform in platforms:
if node_uom == set(NODE_FILTERS[platform][FILTER_STATES]):
hass.data[ISY994_NODES][platform].append(node)
hass_isy_data[ISY994_NODES][platform].append(node)
return True
return False
def _is_sensor_a_binary_sensor(
hass: HomeAssistantType, node: Union[Group, Node]
) -> bool:
def _is_sensor_a_binary_sensor(hass_isy_data: dict, node: Union[Group, Node]) -> bool:
"""Determine if the given sensor node should be a binary_sensor."""
if _check_for_node_def(hass, node, single_platform=BINARY_SENSOR):
if _check_for_node_def(hass_isy_data, node, single_platform=BINARY_SENSOR):
return True
if _check_for_insteon_type(hass, node, single_platform=BINARY_SENSOR):
if _check_for_insteon_type(hass_isy_data, node, single_platform=BINARY_SENSOR):
return True
# For the next two checks, we're providing our own set of uoms that
@ -246,11 +246,14 @@ def _is_sensor_a_binary_sensor(
# checks in the context of already knowing that this is definitely a
# sensor device.
if _check_for_uom_id(
hass, node, single_platform=BINARY_SENSOR, uom_list=BINARY_SENSOR_UOMS
hass_isy_data, node, single_platform=BINARY_SENSOR, uom_list=BINARY_SENSOR_UOMS
):
return True
if _check_for_states_in_uom(
hass, node, single_platform=BINARY_SENSOR, states_list=BINARY_SENSOR_ISY_STATES
hass_isy_data,
node,
single_platform=BINARY_SENSOR,
states_list=BINARY_SENSOR_ISY_STATES,
):
return True
@ -258,10 +261,7 @@ def _is_sensor_a_binary_sensor(
def _categorize_nodes(
hass: HomeAssistantType,
nodes: Nodes,
ignore_identifier: str,
sensor_identifier: str,
hass_isy_data: dict, nodes: Nodes, ignore_identifier: str, sensor_identifier: str
) -> None:
"""Sort the nodes to their proper platforms."""
for (path, node) in nodes:
@ -271,37 +271,36 @@ def _categorize_nodes(
continue
if hasattr(node, "protocol") and node.protocol == PROTO_GROUP:
hass.data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node)
hass_isy_data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node)
continue
if sensor_identifier in path or sensor_identifier in node.name:
# User has specified to treat this as a sensor. First we need to
# determine if it should be a binary_sensor.
if _is_sensor_a_binary_sensor(hass, node):
if _is_sensor_a_binary_sensor(hass_isy_data, node):
continue
hass.data[ISY994_NODES][SENSOR].append(node)
hass_isy_data[ISY994_NODES][SENSOR].append(node)
continue
# We have a bunch of different methods for determining the device type,
# each of which works with different ISY firmware versions or device
# family. The order here is important, from most reliable to least.
if _check_for_node_def(hass, node):
if _check_for_node_def(hass_isy_data, node):
continue
if _check_for_insteon_type(hass, node):
if _check_for_insteon_type(hass_isy_data, node):
continue
if _check_for_zwave_cat(hass, node):
if _check_for_zwave_cat(hass_isy_data, node):
continue
if _check_for_uom_id(hass, node):
if _check_for_uom_id(hass_isy_data, node):
continue
if _check_for_states_in_uom(hass, node):
if _check_for_states_in_uom(hass_isy_data, node):
continue
# Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes.
hass.data[ISY994_NODES][SENSOR].append(node)
hass_isy_data[ISY994_NODES][SENSOR].append(node)
def _categorize_programs(hass: HomeAssistantType, programs: Programs) -> None:
def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None:
"""Categorize the ISY994 programs."""
for platform in SUPPORTED_PROGRAM_PLATFORMS:
folder = programs.get_by_name(f"{DEFAULT_PROGRAM_STRING}{platform}")
@ -334,4 +333,36 @@ def _categorize_programs(hass: HomeAssistantType, programs: Programs) -> None:
continue
entity = (entity_folder.name, status, actions)
hass.data[ISY994_PROGRAMS][platform].append(entity)
hass_isy_data[ISY994_PROGRAMS][platform].append(entity)
async def migrate_old_unique_ids(
hass: HomeAssistantType, platform: str, devices: Optional[List[Any]]
) -> None:
"""Migrate to new controller-specific unique ids."""
registry = await async_get_registry(hass)
for device in devices:
old_entity_id = registry.async_get_entity_id(
platform, DOMAIN, device.old_unique_id
)
if old_entity_id is not None:
_LOGGER.debug(
"Migrating unique_id from [%s] to [%s]",
device.old_unique_id,
device.unique_id,
)
registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id)
old_entity_id_2 = registry.async_get_entity_id(
platform, DOMAIN, device.unique_id.replace(":", "")
)
if old_entity_id_2 is not None:
_LOGGER.debug(
"Migrating unique_id from [%s] to [%s]",
device.unique_id.replace(":", ""),
device.unique_id,
)
registry.async_update_entity(
old_entity_id_2, new_unique_id=device.unique_id
)

View file

@ -8,35 +8,49 @@ from homeassistant.components.light import (
SUPPORT_BRIGHTNESS,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNKNOWN
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import HomeAssistantType
from . import ISY994_NODES
from .const import _LOGGER
from .const import (
_LOGGER,
CONF_RESTORE_LIGHT_STATE,
DOMAIN as ISY994_DOMAIN,
ISY994_NODES,
)
from .entity import ISYNodeEntity
from .helpers import migrate_old_unique_ids
ATTR_LAST_BRIGHTNESS = "last_brightness"
def setup_platform(
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
):
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[list], None],
) -> bool:
"""Set up the ISY994 light platform."""
devices = []
for node in hass.data[ISY994_NODES][LIGHT]:
devices.append(ISYLightEntity(node))
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
isy_options = entry.options
restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False)
add_entities(devices)
devices = []
for node in hass_isy_data[ISY994_NODES][LIGHT]:
devices.append(ISYLightEntity(node, restore_light_state))
await migrate_old_unique_ids(hass, LIGHT, devices)
async_add_entities(devices)
class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
"""Representation of an ISY994 light device."""
def __init__(self, node) -> None:
def __init__(self, node, restore_light_state) -> None:
"""Initialize the ISY994 light device."""
super().__init__(node)
self._last_brightness = None
self._restore_light_state = restore_light_state
@property
def is_on(self) -> bool:
@ -65,16 +79,11 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
# pylint: disable=arguments-differ
def turn_on(self, brightness=None, **kwargs) -> None:
"""Send the turn on command to the ISY994 light device."""
if brightness is None and self._last_brightness:
if self._restore_light_state and brightness is None and self._last_brightness:
brightness = self._last_brightness
if not self._node.turn_on(val=brightness):
_LOGGER.debug("Unable to turn on light")
@property
def supported_features(self):
"""Flag supported features."""
return SUPPORT_BRIGHTNESS
@property
def device_state_attributes(self) -> Dict:
"""Return the light attributes."""
@ -82,6 +91,11 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
attribs[ATTR_LAST_BRIGHTNESS] = self._last_brightness
return attribs
@property
def supported_features(self):
"""Flag supported features."""
return SUPPORT_BRIGHTNESS
async def async_added_to_hass(self) -> None:
"""Restore last_brightness on restart."""
await super().async_added_to_hass()

View file

@ -4,28 +4,33 @@ from typing import Callable
from pyisy.constants import ISY_VALUE_UNKNOWN
from homeassistant.components.lock import DOMAIN as LOCK, LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import HomeAssistantType
from . import ISY994_NODES, ISY994_PROGRAMS
from .const import _LOGGER
from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS
from .entity import ISYNodeEntity, ISYProgramEntity
from .helpers import migrate_old_unique_ids
VALUE_TO_STATE = {0: STATE_UNLOCKED, 100: STATE_LOCKED}
def setup_platform(
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
):
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[list], None],
) -> bool:
"""Set up the ISY994 lock platform."""
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
devices = []
for node in hass.data[ISY994_NODES][LOCK]:
for node in hass_isy_data[ISY994_NODES][LOCK]:
devices.append(ISYLockEntity(node))
for name, status, actions in hass.data[ISY994_PROGRAMS][LOCK]:
for name, status, actions in hass_isy_data[ISY994_PROGRAMS][LOCK]:
devices.append(ISYLockProgramEntity(name, status, actions))
add_entities(devices)
await migrate_old_unique_ids(hass, LOCK, devices)
async_add_entities(devices)
class ISYLockEntity(ISYNodeEntity, LockEntity):

View file

@ -3,5 +3,6 @@
"name": "Universal Devices ISY994",
"documentation": "https://www.home-assistant.io/integrations/isy994",
"requirements": ["pyisy==2.0.2"],
"codeowners": ["@bdraco", "@shbatm"]
"codeowners": ["@bdraco", "@shbatm"],
"config_flow": true
}

View file

@ -4,25 +4,36 @@ from typing import Callable
from pyisy.constants import ISY_VALUE_UNKNOWN
from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import HomeAssistantType
from . import ISY994_NODES
from .const import _LOGGER, UOM_FRIENDLY_NAME, UOM_TO_STATES
from .const import (
_LOGGER,
DOMAIN as ISY994_DOMAIN,
ISY994_NODES,
UOM_FRIENDLY_NAME,
UOM_TO_STATES,
)
from .entity import ISYNodeEntity
from .helpers import migrate_old_unique_ids
def setup_platform(
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
):
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[list], None],
) -> bool:
"""Set up the ISY994 sensor platform."""
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
devices = []
for node in hass.data[ISY994_NODES][SENSOR]:
for node in hass_isy_data[ISY994_NODES][SENSOR]:
_LOGGER.debug("Loading %s", node.name)
devices.append(ISYSensorEntity(node))
add_entities(devices)
await migrate_old_unique_ids(hass, SENSOR, devices)
async_add_entities(devices)
class ISYSensorEntity(ISYNodeEntity):

View file

@ -0,0 +1,39 @@
{
"title": "Universal Devices ISY994",
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"host": "URL",
"password": "[%key:common::config_flow::data::password%]",
"tls": "The TLS version of the ISY controller."
},
"description": "The host entry must be in full URL format, e.g., http://192.168.10.100:80",
"title": "Connect to your ISY994"
}
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_host": "The host entry was not in full URL format, e.g., http://192.168.10.100:80"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%"
}
},
"options": {
"step": {
"init": {
"title": "ISY994 Options",
"description": "Set the options for the ISY Integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.",
"data": {
"sensor_string": "Node Sensor String",
"ignore_string": "Ignore String",
"restore_light_state": "Restore Light Brightness"
}
}
}
}
}

View file

@ -4,26 +4,31 @@ from typing import Callable
from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_GROUP
from homeassistant.components.switch import DOMAIN as SWITCH, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNKNOWN
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import HomeAssistantType
from . import ISY994_NODES, ISY994_PROGRAMS
from .const import _LOGGER
from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS
from .entity import ISYNodeEntity, ISYProgramEntity
from .helpers import migrate_old_unique_ids
def setup_platform(
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
):
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[list], None],
) -> bool:
"""Set up the ISY994 switch platform."""
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
devices = []
for node in hass.data[ISY994_NODES][SWITCH]:
for node in hass_isy_data[ISY994_NODES][SWITCH]:
devices.append(ISYSwitchEntity(node))
for name, status, actions in hass.data[ISY994_PROGRAMS][SWITCH]:
for name, status, actions in hass_isy_data[ISY994_PROGRAMS][SWITCH]:
devices.append(ISYSwitchProgramEntity(name, status, actions))
add_entities(devices)
await migrate_old_unique_ids(hass, SWITCH, devices)
async_add_entities(devices)
class ISYSwitchEntity(ISYNodeEntity, SwitchEntity):

View file

@ -0,0 +1,39 @@
{
"title": "Universal Devices ISY994",
"config": {
"step": {
"user": {
"data": {
"username": "Username",
"host": "URL",
"password": "Password",
"tls": "The TLS version of the ISY controller."
},
"description": "The host entry must be in full URL format, e.g., http://192.168.10.100:80",
"title": "Connect to your ISY994"
}
},
"error": {
"unknown": "Unexpected error",
"cannot_connect": "Failed to connect, please try again",
"invalid_auth": "Invalid authentication",
"invalid_host": "The host entry was not in full URL format, e.g., http://192.168.10.100:80"
},
"abort": {
"already_configured": "Device is already configured"
}
},
"options": {
"step": {
"init": {
"title": "ISY994 Options",
"description": "Set the options for the ISY Integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.",
"data": {
"sensor_string": "Node Sensor String",
"ignore_string": "Ignore String",
"restore_light_state": "Restore Light Brightness"
}
}
}
}
}

View file

@ -68,6 +68,7 @@ FLOWS = [
"ipp",
"iqvia",
"islamic_prayer_times",
"isy994",
"izone",
"juicenet",
"konnected",

View file

@ -572,6 +572,9 @@ pyipp==0.10.1
# homeassistant.components.iqvia
pyiqvia==0.2.1
# homeassistant.components.isy994
pyisy==2.0.2
# homeassistant.components.kira
pykira==0.1.1

View file

@ -0,0 +1 @@
"""Tests for the Universal Devices ISY994 integration."""

View file

@ -0,0 +1,206 @@
"""Test the Universal Devices ISY994 config flow."""
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.isy994.config_flow import CannotConnect
from homeassistant.components.isy994.const import (
CONF_IGNORE_STRING,
CONF_RESTORE_LIGHT_STATE,
CONF_SENSOR_STRING,
CONF_TLS_VER,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.typing import HomeAssistantType
from tests.async_mock import patch
from tests.common import MockConfigEntry
MOCK_HOSTNAME = "1.1.1.1"
MOCK_USERNAME = "test-username"
MOCK_PASSWORD = "test-password"
# Don't use the integration defaults here to make sure they're being set correctly.
MOCK_TLS_VERSION = 1.2
MOCK_IGNORE_STRING = "{IGNOREME}"
MOCK_RESTORE_LIGHT_STATE = True
MOCK_SENSOR_STRING = "IMASENSOR"
MOCK_USER_INPUT = {
"host": f"http://{MOCK_HOSTNAME}",
"username": MOCK_USERNAME,
"password": MOCK_PASSWORD,
"tls": MOCK_TLS_VERSION,
}
MOCK_IMPORT_BASIC_CONFIG = {
CONF_HOST: f"http://{MOCK_HOSTNAME}",
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
}
MOCK_IMPORT_FULL_CONFIG = {
CONF_HOST: f"http://{MOCK_HOSTNAME}",
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
CONF_IGNORE_STRING: MOCK_IGNORE_STRING,
CONF_RESTORE_LIGHT_STATE: MOCK_RESTORE_LIGHT_STATE,
CONF_SENSOR_STRING: MOCK_SENSOR_STRING,
CONF_TLS_VER: MOCK_TLS_VERSION,
}
MOCK_DEVICE_NAME = "Name of the device"
MOCK_UUID = "CE:FB:72:31:B7:B9"
MOCK_VALIDATED_RESPONSE = {"name": MOCK_DEVICE_NAME, "uuid": MOCK_UUID}
PATCH_CONFIGURATION = "homeassistant.components.isy994.config_flow.Configuration"
PATCH_CONNECTION = "homeassistant.components.isy994.config_flow.Connection"
PATCH_ASYNC_SETUP = "homeassistant.components.isy994.async_setup"
PATCH_ASYNC_SETUP_ENTRY = "homeassistant.components.isy994.async_setup_entry"
async def test_form(hass: HomeAssistantType):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY, return_value=True,
) as mock_setup_entry:
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = "<xml></xml>"
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == f"{MOCK_DEVICE_NAME} ({MOCK_HOSTNAME})"
assert result2["result"].unique_id == MOCK_UUID
assert result2["data"] == MOCK_USER_INPUT
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_host(hass: HomeAssistantType):
"""Test we handle invalid host."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": MOCK_HOSTNAME, # Test with missing protocol (http://)
"username": MOCK_USERNAME,
"password": MOCK_PASSWORD,
"tls": MOCK_TLS_VERSION,
},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "invalid_host"}
async def test_form_invalid_auth(hass: HomeAssistantType):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(PATCH_CONFIGURATION), patch(
PATCH_CONNECTION, side_effect=ValueError("PyISY could not connect to the ISY."),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass: HomeAssistantType):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(PATCH_CONFIGURATION), patch(
PATCH_CONNECTION, side_effect=CannotConnect,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_existing_config_entry(hass: HomeAssistantType):
"""Test if config entry already exists."""
MockConfigEntry(domain=DOMAIN, unique_id=MOCK_UUID).add_to_hass(hass)
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class:
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = "<xml></xml>"
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
async def test_import_flow_some_fields(hass: HomeAssistantType) -> None:
"""Test import config flow with just the basic fields."""
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch(
PATCH_ASYNC_SETUP_ENTRY, return_value=True,
):
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = "<xml></xml>"
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_BASIC_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_HOST] == f"http://{MOCK_HOSTNAME}"
assert result["data"][CONF_USERNAME] == MOCK_USERNAME
assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD
async def test_import_flow_all_fields(hass: HomeAssistantType) -> None:
"""Test import config flow with all fields."""
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch(
PATCH_ASYNC_SETUP_ENTRY, return_value=True,
):
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = "<xml></xml>"
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_FULL_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_HOST] == f"http://{MOCK_HOSTNAME}"
assert result["data"][CONF_USERNAME] == MOCK_USERNAME
assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD
assert result["data"][CONF_IGNORE_STRING] == MOCK_IGNORE_STRING
assert result["data"][CONF_RESTORE_LIGHT_STATE] == MOCK_RESTORE_LIGHT_STATE
assert result["data"][CONF_SENSOR_STRING] == MOCK_SENSOR_STRING
assert result["data"][CONF_TLS_VER] == MOCK_TLS_VERSION