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 <support@shbatm.com> * Update tests/components/isy994/test_config_flow.py Co-authored-by: shbatm <support@shbatm.com> * move constants * Update tests/components/isy994/test_config_flow.py Co-authored-by: shbatm <support@shbatm.com> * undo CONF_TLS_VER from homeassistant.const Co-authored-by: shbatm <support@shbatm.com>pull/35579/head
parent
85726b67b7
commit
6d8b8ecfa9
13
.coveragerc
13
.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/*
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"title": "Universal Devices ISY994",
|
||||
"config": {
|
||||
"flow_title": "Universal Devices ISY994 {name} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"title": "Universal Devices ISY994",
|
||||
"config": {
|
||||
"flow_title": "Universal Devices ISY994 {name} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue