Add support for updating the ISY ip address from discovery (#53290)

Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
J. Nick Koston 2021-07-21 20:38:55 -10:00 committed by GitHub
parent ce382a39d0
commit 4df928c188
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 292 additions and 10 deletions

View File

@ -1,6 +1,6 @@
"""Config flow for Universal Devices ISY994 integration.""" """Config flow for Universal Devices ISY994 integration."""
import logging import logging
from urllib.parse import urlparse from urllib.parse import urlparse, urlunparse
from aiohttp import CookieJar from aiohttp import CookieJar
import async_timeout import async_timeout
@ -9,7 +9,7 @@ from pyisy.configuration import Configuration
from pyisy.connection import Connection from pyisy.connection import Connection
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries, core, exceptions from homeassistant import config_entries, core, data_entry_flow, exceptions
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
@ -28,7 +28,11 @@ from .const import (
DEFAULT_TLS_VERSION, DEFAULT_TLS_VERSION,
DEFAULT_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING,
DOMAIN, DOMAIN,
HTTP_PORT,
HTTPS_PORT,
ISY_URL_POSTFIX, ISY_URL_POSTFIX,
SCHEME_HTTP,
SCHEME_HTTPS,
UDN_UUID_PREFIX, UDN_UUID_PREFIX,
) )
@ -58,15 +62,15 @@ async def validate_input(hass: core.HomeAssistant, data):
host = urlparse(data[CONF_HOST]) host = urlparse(data[CONF_HOST])
tls_version = data.get(CONF_TLS_VER) tls_version = data.get(CONF_TLS_VER)
if host.scheme == "http": if host.scheme == SCHEME_HTTP:
https = False https = False
port = host.port or 80 port = host.port or HTTP_PORT
session = aiohttp_client.async_create_clientsession( session = aiohttp_client.async_create_clientsession(
hass, verify_ssl=None, cookie_jar=CookieJar(unsafe=True) hass, verify_ssl=None, cookie_jar=CookieJar(unsafe=True)
) )
elif host.scheme == "https": elif host.scheme == SCHEME_HTTPS:
https = True https = True
port = host.port or 443 port = host.port or HTTPS_PORT
session = aiohttp_client.async_get_clientsession(hass) session = aiohttp_client.async_get_clientsession(hass)
else: else:
_LOGGER.error("The isy994 host value in configuration is invalid") _LOGGER.error("The isy994 host value in configuration is invalid")
@ -150,6 +154,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle import.""" """Handle import."""
return await self.async_step_user(user_input) return await self.async_step_user(user_input)
async def _async_set_unique_id_or_update(self, isy_mac, ip_address, port) -> None:
"""Abort and update the ip address on change."""
existing_entry = await self.async_set_unique_id(isy_mac)
if not existing_entry:
return
parsed_url = urlparse(existing_entry.data[CONF_HOST])
if parsed_url.hostname != ip_address:
new_netloc = ip_address
if port:
new_netloc = f"{ip_address}:{port}"
elif parsed_url.port:
new_netloc = f"{ip_address}:{parsed_url.port}"
self.hass.config_entries.async_update_entry(
existing_entry,
data={
**existing_entry.data,
CONF_HOST: urlunparse(
(
parsed_url.scheme,
new_netloc,
parsed_url.path,
parsed_url.query,
parsed_url.fragment,
None,
)
),
},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(existing_entry.entry_id)
)
raise data_entry_flow.AbortFlow("already_configured")
async def async_step_dhcp(self, discovery_info): async def async_step_dhcp(self, discovery_info):
"""Handle a discovered isy994 via dhcp.""" """Handle a discovered isy994 via dhcp."""
friendly_name = discovery_info[HOSTNAME] friendly_name = discovery_info[HOSTNAME]
@ -158,8 +195,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
isy_mac = ( isy_mac = (
f"{mac[0:2]}:{mac[2:4]}:{mac[4:6]}:{mac[6:8]}:{mac[8:10]}:{mac[10:12]}" f"{mac[0:2]}:{mac[2:4]}:{mac[4:6]}:{mac[6:8]}:{mac[8:10]}:{mac[10:12]}"
) )
await self.async_set_unique_id(isy_mac) await self._async_set_unique_id_or_update(
self._abort_if_unique_id_configured() isy_mac, discovery_info[IP_ADDRESS], None
)
self.discovered_conf = { self.discovered_conf = {
CONF_NAME: friendly_name, CONF_NAME: friendly_name,
@ -173,14 +211,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a discovered isy994.""" """Handle a discovered isy994."""
friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME]
url = discovery_info[ssdp.ATTR_SSDP_LOCATION] url = discovery_info[ssdp.ATTR_SSDP_LOCATION]
parsed_url = urlparse(url)
mac = discovery_info[ssdp.ATTR_UPNP_UDN] mac = discovery_info[ssdp.ATTR_UPNP_UDN]
if mac.startswith(UDN_UUID_PREFIX): if mac.startswith(UDN_UUID_PREFIX):
mac = mac[len(UDN_UUID_PREFIX) :] mac = mac[len(UDN_UUID_PREFIX) :]
if url.endswith(ISY_URL_POSTFIX): if url.endswith(ISY_URL_POSTFIX):
url = url[: -len(ISY_URL_POSTFIX)] url = url[: -len(ISY_URL_POSTFIX)]
await self.async_set_unique_id(mac) port = HTTP_PORT
self._abort_if_unique_id_configured() if parsed_url.port:
port = parsed_url.port
elif parsed_url.scheme == SCHEME_HTTPS:
port = HTTPS_PORT
await self._async_set_unique_id_or_update(mac, parsed_url.hostname, port)
self.discovered_conf = { self.discovered_conf = {
CONF_NAME: friendly_name, CONF_NAME: friendly_name,

View File

@ -672,3 +672,9 @@ BINARY_SENSOR_DEVICE_TYPES_ZWAVE = {
DEVICE_CLASS_MOTION: ["155"], DEVICE_CLASS_MOTION: ["155"],
DEVICE_CLASS_VIBRATION: ["173"], DEVICE_CLASS_VIBRATION: ["173"],
} }
SCHEME_HTTP = "http"
HTTP_PORT = 80
SCHEME_HTTPS = "https"
HTTPS_PORT = 443

View File

@ -156,6 +156,24 @@ async def test_form_invalid_auth(hass: HomeAssistant):
assert result2["errors"] == {"base": "invalid_auth"} assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_unknown_exeption(hass: HomeAssistant):
"""Test we handle generic exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
PATCH_CONNECTION,
side_effect=Exception,
):
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": "unknown"}
async def test_form_isy_connection_error(hass: HomeAssistant): async def test_form_isy_connection_error(hass: HomeAssistant):
"""Test we handle invalid auth.""" """Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -355,6 +373,146 @@ async def test_form_ssdp(hass: HomeAssistant):
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_form_ssdp_existing_entry(hass: HomeAssistant):
"""Test we update the ip of an existing entry from ssdp."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}"},
unique_id=MOCK_UUID,
)
entry.add_to_hass(hass)
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_SSDP},
data={
ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3{ISY_URL_POSTFIX}",
ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy",
ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}",
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == f"http://3.3.3.3:80{ISY_URL_POSTFIX}"
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant):
"""Test we update the ip of an existing entry from ssdp with no port."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: f"http://{MOCK_HOSTNAME}:1443/{ISY_URL_POSTFIX}"},
unique_id=MOCK_UUID,
)
entry.add_to_hass(hass)
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_SSDP},
data={
ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3/{ISY_URL_POSTFIX}",
ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy",
ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}",
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == f"http://3.3.3.3:80/{ISY_URL_POSTFIX}"
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant):
"""Test we update the ip of an existing entry from ssdp with an alternate port."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: f"http://{MOCK_HOSTNAME}:1443/{ISY_URL_POSTFIX}"},
unique_id=MOCK_UUID,
)
entry.add_to_hass(hass)
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_SSDP},
data={
ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}",
ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy",
ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}",
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}"
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant):
"""Test we update the ip of an existing entry from ssdp with no port and https."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: f"https://{MOCK_HOSTNAME}/{ISY_URL_POSTFIX}"},
unique_id=MOCK_UUID,
)
entry.add_to_hass(hass)
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_SSDP},
data={
ssdp.ATTR_SSDP_LOCATION: f"https://3.3.3.3/{ISY_URL_POSTFIX}",
ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy",
ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}",
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == f"https://3.3.3.3:443/{ISY_URL_POSTFIX}"
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_dhcp(hass: HomeAssistant): async def test_form_dhcp(hass: HomeAssistant):
"""Test we can setup from dhcp.""" """Test we can setup from dhcp."""
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
@ -390,3 +548,77 @@ async def test_form_dhcp(hass: HomeAssistant):
assert result2["data"] == MOCK_USER_INPUT assert result2["data"] == MOCK_USER_INPUT
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_form_dhcp_existing_entry(hass: HomeAssistant):
"""Test we update the ip of an existing entry from dhcp."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}"},
unique_id=MOCK_UUID,
)
entry.add_to_hass(hass)
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data={
dhcp.IP_ADDRESS: "1.2.3.4",
dhcp.HOSTNAME: "isy994-ems",
dhcp.MAC_ADDRESS: MOCK_MAC,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == f"http://1.2.3.4{ISY_URL_POSTFIX}"
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant):
"""Test we update the ip of an existing entry from dhcp preserves port."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_USERNAME: "bob",
CONF_HOST: f"http://{MOCK_HOSTNAME}:1443{ISY_URL_POSTFIX}",
},
unique_id=MOCK_UUID,
)
entry.add_to_hass(hass)
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data={
dhcp.IP_ADDRESS: "1.2.3.4",
dhcp.HOSTNAME: "isy994-ems",
dhcp.MAC_ADDRESS: MOCK_MAC,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == f"http://1.2.3.4:1443{ISY_URL_POSTFIX}"
assert entry.data[CONF_USERNAME] == "bob"
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1