From 6d8b8ecfa90d4639965873522e37521acff2d63c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2020 11:15:17 -0500 Subject: [PATCH] Add ssdp discovery for isy994 (#35568) * Add ssdp discovery for isy994 * Increase test coverage for existing config flow * Update tests/components/isy994/test_config_flow.py Co-authored-by: shbatm * Update tests/components/isy994/test_config_flow.py Co-authored-by: shbatm * move constants * Update tests/components/isy994/test_config_flow.py Co-authored-by: shbatm * undo CONF_TLS_VER from homeassistant.const Co-authored-by: shbatm --- .coveragerc | 13 ++- .../components/isy994/config_flow.py | 62 ++++++++--- homeassistant/components/isy994/const.py | 4 + homeassistant/components/isy994/manifest.json | 8 +- homeassistant/components/isy994/strings.json | 1 + .../components/isy994/translations/en.json | 1 + homeassistant/generated/ssdp.py | 6 ++ tests/components/isy994/test_config_flow.py | 102 +++++++++++++++++- 8 files changed, 177 insertions(+), 20 deletions(-) diff --git a/.coveragerc b/.coveragerc index 1281ac1aca0..fb4e88903b4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -363,7 +363,18 @@ omit = homeassistant/components/iqvia/* homeassistant/components/irish_rail_transport/sensor.py homeassistant/components/iss/binary_sensor.py - homeassistant/components/isy994/* + homeassistant/components/isy994/__init__.py + homeassistant/components/isy994/binary_sensor.py + homeassistant/components/isy994/climate.py + homeassistant/components/isy994/cover.py + homeassistant/components/isy994/entity.py + homeassistant/components/isy994/fan.py + homeassistant/components/isy994/helpers.py + homeassistant/components/isy994/light.py + homeassistant/components/isy994/lock.py + homeassistant/components/isy994/sensor.py + homeassistant/components/isy994/services.py + homeassistant/components/isy994/switch.py homeassistant/components/itach/remote.py homeassistant/components/itunes/media_player.py homeassistant/components/joaoapps_join/* diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 3874cb58798..0ed1d7e6833 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -7,7 +7,8 @@ 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.components import ssdp +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from .const import ( @@ -21,21 +22,25 @@ from .const import ( DEFAULT_SENSOR_STRING, DEFAULT_TLS_VERSION, DEFAULT_VAR_SENSOR_STRING, + ISY_URL_POSTFIX, + UDN_UUID_PREFIX, ) 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, -) +def _data_schema(schema_input): + """Generate schema with defaults.""" + return vol.Schema( + { + vol.Required(CONF_HOST, default=schema_input.get(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): @@ -70,6 +75,9 @@ async def validate_input(hass: core.HomeAssistant, data): host.path, ) + if not isy_conf or "name" not in isy_conf or not isy_conf["name"]: + raise CannotConnect + # Return info that you want to store in the config entry. return {"title": f"{isy_conf['name']} ({host.hostname})", "uuid": isy_conf["uuid"]} @@ -101,6 +109,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + def __init__(self): + """Initialize the isy994 config flow.""" + self.discovered_conf = {} + @staticmethod @callback def async_get_options_flow(config_entry): @@ -124,19 +136,43 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - if "base" not in errors: - await self.async_set_unique_id(info["uuid"]) + if not errors: + await self.async_set_unique_id(info["uuid"], raise_on_progress=False) 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 + step_id="user", + data_schema=_data_schema(self.discovered_conf), + errors=errors, ) async def async_step_import(self, user_input): """Handle import.""" return await self.async_step_user(user_input) + async def async_step_ssdp(self, discovery_info): + """Handle a discovered isy994.""" + friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] + url = discovery_info[ssdp.ATTR_SSDP_LOCATION] + mac = discovery_info[ssdp.ATTR_UPNP_UDN] + if mac.startswith(UDN_UUID_PREFIX): + mac = mac[len(UDN_UUID_PREFIX) :] + if url.endswith(ISY_URL_POSTFIX): + url = url[: -len(ISY_URL_POSTFIX)] + + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + + self.discovered_conf = { + CONF_NAME: friendly_name, + CONF_HOST: url, + } + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = self.discovered_conf + return await self.async_step_user() + class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for isy994.""" diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index cc63f78738d..f7042a5860a 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -164,6 +164,10 @@ TYPE_INSTEON_MOTION = ("16.1.", "16.22.") UNDO_UPDATE_LISTENER = "undo_update_listener" +# Used for discovery +UDN_UUID_PREFIX = "uuid:" +ISY_URL_POSTFIX = "/desc" + # 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 diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index b05aae8e1c6..2effed0c06c 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -4,5 +4,11 @@ "documentation": "https://www.home-assistant.io/integrations/isy994", "requirements": ["pyisy==2.0.2"], "codeowners": ["@bdraco", "@shbatm"], - "config_flow": true + "config_flow": true, + "ssdp": [ + { + "manufacturer": "Universal Devices Inc.", + "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1" + } + ] } diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 516953a74b4..ce9818bc0c8 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -1,6 +1,7 @@ { "title": "Universal Devices ISY994", "config": { + "flow_title": "Universal Devices ISY994 {name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/isy994/translations/en.json b/homeassistant/components/isy994/translations/en.json index 0d65c07ebb4..58dfdfcf44b 100644 --- a/homeassistant/components/isy994/translations/en.json +++ b/homeassistant/components/isy994/translations/en.json @@ -1,6 +1,7 @@ { "title": "Universal Devices ISY994", "config": { + "flow_title": "Universal Devices ISY994 {name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 52134888c0c..490ffdffeb1 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -53,6 +53,12 @@ SSDP = { "modelName": "Philips hue bridge 2015" } ], + "isy994": [ + { + "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1", + "manufacturer": "Universal Devices Inc." + } + ], "konnected": [ { "manufacturer": "konnected.io" diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 0a6a5ca6d66..55311494d30 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Universal Devices ISY994 config flow.""" from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components import ssdp from homeassistant.components.isy994.config_flow import CannotConnect from homeassistant.components.isy994.const import ( CONF_IGNORE_STRING, @@ -9,8 +10,10 @@ from homeassistant.components.isy994.const import ( CONF_TLS_VER, CONF_VAR_SENSOR_STRING, DOMAIN, + ISY_URL_POSTFIX, + UDN_UUID_PREFIX, ) -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.typing import HomeAssistantType @@ -29,10 +32,16 @@ MOCK_SENSOR_STRING = "IMASENSOR" MOCK_VARIABLE_SENSOR_STRING = "HomeAssistant." MOCK_USER_INPUT = { - "host": f"http://{MOCK_HOSTNAME}", - "username": MOCK_USERNAME, - "password": MOCK_PASSWORD, - "tls": MOCK_TLS_VERSION, + CONF_HOST: f"http://{MOCK_HOSTNAME}", + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_TLS_VER: MOCK_TLS_VERSION, +} +MOCK_IMPORT_WITH_SSL = { + CONF_HOST: f"https://{MOCK_HOSTNAME}", + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_TLS_VER: MOCK_TLS_VERSION, } MOCK_IMPORT_BASIC_CONFIG = { CONF_HOST: f"http://{MOCK_HOSTNAME}", @@ -185,6 +194,27 @@ async def test_import_flow_some_fields(hass: HomeAssistantType) -> None: assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD +async def test_import_flow_with_https(hass: HomeAssistantType) -> None: + """Test import config with https.""" + + 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 = None + mock_config_class.return_value = MOCK_VALIDATED_RESPONSE + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_WITH_SSL, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_HOST] == f"https://{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( @@ -208,3 +238,65 @@ async def test_import_flow_all_fields(hass: HomeAssistantType) -> None: assert result["data"][CONF_SENSOR_STRING] == MOCK_SENSOR_STRING assert result["data"][CONF_VAR_SENSOR_STRING] == MOCK_VARIABLE_SENSOR_STRING assert result["data"][CONF_TLS_VER] == MOCK_TLS_VERSION + + +async def test_form_ssdp_already_configured(hass: HomeAssistantType) -> None: + """Test ssdp abort when the serial number is already configured.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_form_ssdp(hass: HomeAssistantType): + """Test we can setup from ssdp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + 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 = None + 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