Add intellifire UDP discovery at configuration start (#67002)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Jeef 2022-03-14 00:58:55 -06:00 committed by GitHub
parent 8e76948297
commit cea21a00b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 310 additions and 56 deletions

View File

@ -4,29 +4,31 @@ from __future__ import annotations
from typing import Any from typing import Any
from aiohttp import ClientConnectionError from aiohttp import ClientConnectionError
from intellifire4py import IntellifireAsync from intellifire4py import AsyncUDPFireplaceFinder, IntellifireAsync
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN from .const import DOMAIN, LOGGER
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
MANUAL_ENTRY_STRING = "IP Address" # Simplified so it does not have to be translated
async def validate_input(hass: HomeAssistant, host: str) -> str:
async def validate_host_input(host: str) -> str:
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
""" """
api = IntellifireAsync(host) api = IntellifireAsync(host)
await api.poll() await api.poll()
serial = api.data.serial
LOGGER.debug("Found a fireplace: %s", serial)
# Return the serial number which will be used to calculate a unique ID for the device/sensors # Return the serial number which will be used to calculate a unique ID for the device/sensors
return api.data.serial return serial
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@ -34,30 +36,94 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
def __init__(self):
"""Initialize the Config Flow Handler."""
self._config_context = {}
self._not_configured_hosts: list[str] = []
async def _find_fireplaces(self):
"""Perform UDP discovery."""
fireplace_finder = AsyncUDPFireplaceFinder()
discovered_hosts = await fireplace_finder.search_fireplace(timeout=1)
configured_hosts = {
entry.data[CONF_HOST]
for entry in self._async_current_entries(include_ignore=False)
if CONF_HOST in entry.data # CONF_HOST will be missing for ignored entries
}
self._not_configured_hosts = [
ip for ip in discovered_hosts if ip not in configured_hosts
]
LOGGER.debug("Discovered Hosts: %s", discovered_hosts)
LOGGER.debug("Configured Hosts: %s", configured_hosts)
LOGGER.debug("Not Configured Hosts: %s", self._not_configured_hosts)
async def _async_validate_and_create_entry(self, host: str) -> FlowResult:
"""Validate and create the entry."""
self._async_abort_entries_match({CONF_HOST: host})
serial = await validate_host_input(host)
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
return self.async_create_entry(
title=f"Fireplace {serial}",
data={CONF_HOST: host},
)
async def async_step_manual_device_entry(self, user_input=None):
"""Handle manual input of local IP configuration."""
errors = {}
host = user_input.get(CONF_HOST) if user_input else None
if user_input is not None:
try:
return await self._async_validate_and_create_entry(host)
except (ConnectionError, ClientConnectionError):
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="manual_device_entry",
errors=errors,
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
)
async def async_step_pick_device(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Pick which device to configure."""
errors = {}
if user_input is not None:
if user_input[CONF_HOST] == MANUAL_ENTRY_STRING:
return await self.async_step_manual_device_entry()
try:
return await self._async_validate_and_create_entry(
user_input[CONF_HOST]
)
except (ConnectionError, ClientConnectionError):
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="pick_device",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): vol.In(
self._not_configured_hosts + [MANUAL_ENTRY_STRING]
)
}
),
)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle the initial step.""" """Start the user flow."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
errors = {}
try: # Launch fireplaces discovery
serial = await validate_input(self.hass, user_input[CONF_HOST]) await self._find_fireplaces()
except (ConnectionError, ClientConnectionError):
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
return self.async_create_entry( if self._not_configured_hosts:
title="Fireplace", LOGGER.debug("Running Step: pick_device")
data={CONF_HOST: user_input[CONF_HOST]}, return await self.async_step_pick_device()
) LOGGER.debug("Running Step: manual_device_entry")
return self.async_show_form( return await self.async_step_manual_device_entry()
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@ -1,7 +1,13 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "manual_device_entry": {
"description": "Local Configuration",
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"pick_device": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
} }
@ -15,3 +21,4 @@
} }
} }
} }

View File

@ -4,14 +4,23 @@
"already_configured": "Device is already configured" "already_configured": "Device is already configured"
}, },
"error": { "error": {
"cannot_connect": "Failed to connect", "cannot_connect": "Could not connect to a fireplace endpoint at url: http://{host}/poll\nVerify IP address and try again"
"unknown": "Unexpected error"
}, },
"step": { "step": {
"user": { "manual_device_entry": {
"title": "IntelliFire - Local Config",
"description": "Enter the IP address of the IntelliFire unit on your local network.",
"data": { "data": {
"host": "Host" "host": "Host (IP Address)"
} }
},
"user": {
"description": "Username and password are the same information used in your IntelliFire Android/iOS application.",
"title": "IntelliFire Config"
},
"pick_device": {
"title": "Device Selection",
"description": "The following IntelliFire devices were discovered. Please select which you wish to configure."
} }
} }
} }

View File

@ -14,6 +14,28 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]:
yield mock_setup yield mock_setup
@pytest.fixture()
def mock_fireplace_finder_none() -> Generator[None, MagicMock, None]:
"""Mock fireplace finder."""
mock_found_fireplaces = Mock()
mock_found_fireplaces.ips = []
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace"
):
yield mock_found_fireplaces
@pytest.fixture()
def mock_fireplace_finder_single() -> Generator[None, MagicMock, None]:
"""Mock fireplace finder."""
mock_found_fireplaces = Mock()
mock_found_fireplaces.ips = ["192.168.1.69"]
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace"
):
yield mock_found_fireplaces
@pytest.fixture @pytest.fixture
def mock_intellifire_config_flow() -> Generator[None, MagicMock, None]: def mock_intellifire_config_flow() -> Generator[None, MagicMock, None]:
"""Return a mocked IntelliFire client.""" """Return a mocked IntelliFire client."""

View File

@ -1,41 +1,152 @@
"""Test the IntelliFire config flow.""" """Test the IntelliFire config flow."""
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.intellifire.config_flow import MANUAL_ENTRY_STRING
from homeassistant.components.intellifire.const import DOMAIN from homeassistant.components.intellifire.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
from tests.common import MockConfigEntry
async def test_form(
async def test_no_discovery(
hass: HomeAssistant, hass: HomeAssistant,
mock_setup_entry: AsyncMock, mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock, mock_intellifire_config_flow: MagicMock,
) -> None: ) -> None:
"""Test we get the form.""" """Test we should get the manual discovery form - because no discovered fireplaces."""
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace",
return_value=[],
):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] == RESULT_TYPE_FORM assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None assert result["errors"] == {}
assert result["step_id"] == "manual_device_entry"
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"host": "1.1.1.1", CONF_HOST: "1.1.1.1",
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "Fireplace" assert result2["title"] == "Fireplace 12345"
assert result2["data"] == {"host": "1.1.1.1"} assert result2["data"] == {CONF_HOST: "1.1.1.1"}
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect( async def test_single_discovery(
hass: HomeAssistant, mock_intellifire_config_flow: MagicMock hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock,
) -> None:
"""Test single fireplace UDP discovery."""
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace",
return_value=["192.168.1.69"],
):
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"], {CONF_HOST: "192.168.1.69"}
)
await hass.async_block_till_done()
print("Result:", result)
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "Fireplace 12345"
assert result2["data"] == {CONF_HOST: "192.168.1.69"}
assert len(mock_setup_entry.mock_calls) == 1
async def test_manual_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock,
) -> None:
"""Test for multiple firepalce discovery - involing a pick_device step."""
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace",
return_value=["192.168.1.69", "192.168.1.33", "192.168.169"],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["step_id"] == "pick_device"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: MANUAL_ENTRY_STRING}
)
await hass.async_block_till_done()
assert result2["step_id"] == "manual_device_entry"
async def test_multi_discovery(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock,
) -> None:
"""Test for multiple fireplace discovery - involving a pick_device step."""
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace",
return_value=["192.168.1.69", "192.168.1.33", "192.168.169"],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["step_id"] == "pick_device"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "192.168.1.33"}
)
await hass.async_block_till_done()
assert result["step_id"] == "pick_device"
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
async def test_multi_discovery_cannot_connect(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock,
) -> None:
"""Test for multiple fireplace discovery - involving a pick_device step."""
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace",
return_value=["192.168.1.69", "192.168.1.33", "192.168.169"],
):
mock_intellifire_config_flow.poll.side_effect = ConnectionError
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "pick_device"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "192.168.1.33"}
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_cannot_connect_manual_entry(
hass: HomeAssistant,
mock_intellifire_config_flow: MagicMock,
mock_fireplace_finder_single: AsyncMock,
) -> None: ) -> None:
"""Test we handle cannot connect error.""" """Test we handle cannot connect error."""
mock_intellifire_config_flow.poll.side_effect = ConnectionError mock_intellifire_config_flow.poll.side_effect = ConnectionError
@ -43,13 +154,52 @@ async def test_form_cannot_connect(
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "manual_device_entry"
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"host": "1.1.1.1", CONF_HOST: "1.1.1.1",
}, },
) )
assert result2["type"] == RESULT_TYPE_FORM assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"} assert result2["errors"] == {"base": "cannot_connect"}
async def test_picker_already_discovered(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock,
) -> None:
"""Test single fireplace UDP discovery."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "192.168.1.3",
},
title="Fireplace",
unique_id=44444,
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace",
return_value=["192.168.1.3"],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.async_block_till_done()
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "192.168.1.4",
},
)
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "Fireplace 12345"
assert result2["data"] == {CONF_HOST: "192.168.1.4"}
assert len(mock_setup_entry.mock_calls) == 2