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,31 +133,103 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return False
# Connect to ISY controller.
isy = ISY(
host.hostname,
port,
username=user,
password=password,
use_https=https,
tls_ver=tls_version,
log=_LOGGER,
isy = await hass.async_add_executor_job(
partial(
ISY,
host.hostname,
port,
username=user,
password=password,
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
isy.auto_update = True
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):
@ -235,7 +243,7 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
Often times, a single device is represented by multiple nodes in the ISY,
allowing for different nuances in how those devices report their on and
off events. This class turns those multiple nodes in to a single Home
off events. This class turns those multiple nodes into a single Home
Assistant entity and handles both ways that ISY binary sensors can work.
"""

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