diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index c541c733e08..869504d22a2 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -4,29 +4,31 @@ from __future__ import annotations from typing import Any from aiohttp import ClientConnectionError -from intellifire4py import IntellifireAsync +from intellifire4py import AsyncUDPFireplaceFinder, IntellifireAsync import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant 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}) +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. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ api = IntellifireAsync(host) 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 api.data.serial + return serial class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -34,30 +36,94 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - errors = {} + """Start the user flow.""" - try: - serial = await validate_input(self.hass, user_input[CONF_HOST]) - 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]} - ) + # Launch fireplaces discovery + await self._find_fireplaces() - return self.async_create_entry( - title="Fireplace", - data={CONF_HOST: user_input[CONF_HOST]}, - ) - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) + if self._not_configured_hosts: + LOGGER.debug("Running Step: pick_device") + return await self.async_step_pick_device() + LOGGER.debug("Running Step: manual_device_entry") + return await self.async_step_manual_device_entry() diff --git a/homeassistant/components/intellifire/strings.json b/homeassistant/components/intellifire/strings.json index 52d57eda809..d5d3f344c8e 100644 --- a/homeassistant/components/intellifire/strings.json +++ b/homeassistant/components/intellifire/strings.json @@ -1,7 +1,13 @@ { "config": { "step": { - "user": { + "manual_device_entry": { + "description": "Local Configuration", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "pick_device": { "data": { "host": "[%key:common::config_flow::data::host%]" } @@ -15,3 +21,4 @@ } } } + diff --git a/homeassistant/components/intellifire/translations/en.json b/homeassistant/components/intellifire/translations/en.json index 0a4ba36e285..0f7538e7413 100644 --- a/homeassistant/components/intellifire/translations/en.json +++ b/homeassistant/components/intellifire/translations/en.json @@ -1,18 +1,27 @@ { "config": { - "abort": { - "already_configured": "Device is already configured" + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Could not connect to a fireplace endpoint at url: http://{host}/poll\nVerify IP address and try again" + }, + "step": { + "manual_device_entry": { + "title": "IntelliFire - Local Config", + "description": "Enter the IP address of the IntelliFire unit on your local network.", + "data": { + "host": "Host (IP Address)" + } }, - "error": { - "cannot_connect": "Failed to connect", - "unknown": "Unexpected error" + "user": { + "description": "Username and password are the same information used in your IntelliFire Android/iOS application.", + "title": "IntelliFire Config" }, - "step": { - "user": { - "data": { - "host": "Host" - } - } + "pick_device": { + "title": "Device Selection", + "description": "The following IntelliFire devices were discovered. Please select which you wish to configure." } + } } -} \ No newline at end of file + } \ No newline at end of file diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py index 2bbb3318090..a9de5a260bc 100644 --- a/tests/components/intellifire/conftest.py +++ b/tests/components/intellifire/conftest.py @@ -14,6 +14,28 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: 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 def mock_intellifire_config_flow() -> Generator[None, MagicMock, None]: """Return a mocked IntelliFire client.""" diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index c9d08318d3c..2e130bfa14e 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -1,41 +1,152 @@ """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.components.intellifire.config_flow import MANUAL_ENTRY_STRING from homeassistant.components.intellifire.const import DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant 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, mock_setup_entry: AsyncMock, mock_intellifire_config_flow: MagicMock, ) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + """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( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) 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( result["flow_id"], { - "host": "1.1.1.1", + CONF_HOST: "1.1.1.1", }, ) await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "Fireplace" - assert result2["data"] == {"host": "1.1.1.1"} - + assert result2["title"] == "Fireplace 12345" + assert result2["data"] == {CONF_HOST: "1.1.1.1"} assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect( - hass: HomeAssistant, mock_intellifire_config_flow: MagicMock +async def test_single_discovery( + 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: """Test we handle cannot connect error.""" 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( 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( result["flow_id"], { - "host": "1.1.1.1", + CONF_HOST: "1.1.1.1", }, ) assert result2["type"] == RESULT_TYPE_FORM 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