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