diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py
index e0fcab2fe2051..05e44ea4fdd50 100644
--- a/homeassistant/components/isy994/__init__.py
+++ b/homeassistant/components/isy994/__init__.py
@@ -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 @@
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 @@
)
-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
diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py
index 1ec54aa7b452c..62fae6cca3709 100644
--- a/homeassistant/components/isy994/binary_sensor.py
+++ b/homeassistant/components/isy994/binary_sensor.py
@@ -23,20 +23,24 @@
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 @@
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.
"""
diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py
new file mode 100644
index 0000000000000..cf78c6ddc4782
--- /dev/null
+++ b/homeassistant/components/isy994/config_flow.py
@@ -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."""
diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py
index 3cc01d55b46e9..5474f76d41366 100644
--- a/homeassistant/components/isy994/const.py
+++ b/homeassistant/components/isy994/const.py
@@ -98,9 +98,11 @@
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_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 @@
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: {
diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py
index 567838c570f62..1d99bb1171f75 100644
--- a/homeassistant/components/isy994/cover.py
+++ b/homeassistant/components/isy994/cover.py
@@ -4,26 +4,37 @@
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 . import ISY994_NODES, ISY994_PROGRAMS
-from .const import _LOGGER, UOM_TO_STATES
+from homeassistant.helpers.typing import HomeAssistantType
+
+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):
diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py
index c9dc3bbb56df6..9a1444e61d31b 100644
--- a/homeassistant/components/isy994/entity.py
+++ b/homeassistant/components/isy994/entity.py
@@ -56,6 +56,13 @@ def on_control(self, event: NodeProperty) -> None:
@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
diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py
index 013191093e4f6..a472cba3f33d6 100644
--- a/homeassistant/components/isy994/fan.py
+++ b/homeassistant/components/isy994/fan.py
@@ -10,11 +10,12 @@
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 @@
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):
diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py
index 4201a038fbccc..2fde917485f42 100644
--- a/homeassistant/components/isy994/helpers.py
+++ b/homeassistant/components/isy994/helpers.py
@@ -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.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 @@
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
+ )
diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py
index 257ecd853f866..1721a2a27f216 100644
--- a/homeassistant/components/isy994/light.py
+++ b/homeassistant/components/isy994/light.py
@@ -8,35 +8,49 @@
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."""
+ 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)
+
devices = []
- for node in hass.data[ISY994_NODES][LIGHT]:
- devices.append(ISYLightEntity(node))
+ for node in hass_isy_data[ISY994_NODES][LIGHT]:
+ devices.append(ISYLightEntity(node, restore_light_state))
- add_entities(devices)
+ 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 @@ def on_update(self, event: object) -> None:
# 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 @@ def device_state_attributes(self) -> Dict:
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()
diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py
index 22cd5e08e4415..29cb3705de959 100644
--- a/homeassistant/components/isy994/lock.py
+++ b/homeassistant/components/isy994/lock.py
@@ -4,28 +4,33 @@
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):
diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json
index e132b7043e661..b05aae8e1c65c 100644
--- a/homeassistant/components/isy994/manifest.json
+++ b/homeassistant/components/isy994/manifest.json
@@ -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
}
diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py
index b9144c8bcd1fd..838020026dbd1 100644
--- a/homeassistant/components/isy994/sensor.py
+++ b/homeassistant/components/isy994/sensor.py
@@ -4,25 +4,36 @@
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):
diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json
new file mode 100644
index 0000000000000..11f1dcfb2b0db
--- /dev/null
+++ b/homeassistant/components/isy994/strings.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py
index 64f7c840054e5..fadc493505fd1 100644
--- a/homeassistant/components/isy994/switch.py
+++ b/homeassistant/components/isy994/switch.py
@@ -4,26 +4,31 @@
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):
diff --git a/homeassistant/components/isy994/translations/en.json b/homeassistant/components/isy994/translations/en.json
new file mode 100644
index 0000000000000..4ad14d99c6319
--- /dev/null
+++ b/homeassistant/components/isy994/translations/en.json
@@ -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"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index bfc5d8baef4af..528092a6c604e 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -68,6 +68,7 @@
"ipp",
"iqvia",
"islamic_prayer_times",
+ "isy994",
"izone",
"juicenet",
"konnected",
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 75441d77e1733..1cc2af2cb1b27 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -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
diff --git a/tests/components/isy994/__init__.py b/tests/components/isy994/__init__.py
new file mode 100644
index 0000000000000..9aee1e159051f
--- /dev/null
+++ b/tests/components/isy994/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Universal Devices ISY994 integration."""
diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py
new file mode 100644
index 0000000000000..c9c914b5a7992
--- /dev/null
+++ b/tests/components/isy994/test_config_flow.py
@@ -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 = ""
+ 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 = ""
+ 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 = ""
+ 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 = ""
+ 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