diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index 5b4ae9ffc0dc5..a8d2821b97268 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -13,9 +13,15 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_REAUTH, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 from .const import DOMAIN @@ -33,6 +39,16 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_hostname: str | None = None + self._discovered_ip: str | None = None + + async def _async_set_single_instance_unique_id(self) -> None: + """Assign the unique ID used by this single-instance integration.""" + await self.async_set_unique_id(DOMAIN, raise_on_progress=False) + self._abort_if_unique_id_configured(error="single_instance_allowed") + async def _async_test_credentials( self, user_input: dict[str, Any] ) -> dict[str, str]: @@ -53,10 +69,24 @@ async def _async_test_credentials( return {} + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a DHCP discovery.""" + await self._async_set_single_instance_unique_id() + + self._discovered_hostname = discovery_info.hostname + self._discovered_ip = discovery_info.ip + + return await self.async_step_user() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow start.""" + if self.source not in (SOURCE_DHCP, SOURCE_REAUTH): + await self._async_set_single_instance_unique_id() + errors = {} if user_input is not None: @@ -66,9 +96,21 @@ async def async_step_user( title=user_input[CONF_USERNAME], data=user_input ) + discovery = "" + if ( + self.source == SOURCE_DHCP + and self._discovered_hostname is not None + and self._discovered_ip is not None + ): + discovery = ( + "A likely iAquaLink device was discovered on your network at " + f"{self._discovered_ip} ({self._discovered_hostname}). " + ) + return self.async_show_form( step_id="user", data_schema=CREDENTIALS_DATA_SCHEMA, + description_placeholders={"discovery": discovery}, errors=errors, ) diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index fea0531264ae9..8d1cc06303d2c 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -3,6 +3,7 @@ "name": "Jandy iAqualink", "codeowners": ["@flz"], "config_flow": true, + "dhcp": [{ "hostname": "iaqualink-*" }], "documentation": "https://www.home-assistant.io/integrations/iaqualink", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/iaqualink/strings.json b/homeassistant/components/iaqualink/strings.json index a8629e4d6b36e..724a2096a04e6 100644 --- a/homeassistant/components/iaqualink/strings.json +++ b/homeassistant/components/iaqualink/strings.json @@ -21,7 +21,7 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, - "description": "Please enter the username and password for your iAqualink account.", + "description": "{discovery}Please enter the username and password for your iAqualink account.", "title": "Connect to iAqualink" } } diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 97625caa89bed..070027f65a924 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -325,6 +325,10 @@ "hostname": "hunter*", "macaddress": "002674*", }, + { + "domain": "iaqualink", + "hostname": "iaqualink-*", + }, { "domain": "incomfort", "hostname": "rfgateway", diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py index eca09f88171e5..97a3bff8bbe04 100644 --- a/tests/components/iaqualink/test_config_flow.py +++ b/tests/components/iaqualink/test_config_flow.py @@ -8,13 +8,20 @@ ) from homeassistant.components.iaqualink import DOMAIN, config_flow -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry +DHCP_DISCOVERY = DhcpServiceInfo( + ip="192.168.1.23", + hostname="iAquaLink-123456", + macaddress="001122334455", +) + async def test_already_configured( hass: HomeAssistant, @@ -45,12 +52,32 @@ async def test_without_config(hass: HomeAssistant) -> None: assert result["errors"] == {} +async def test_dhcp_discovery_starts_user_flow(hass: HomeAssistant) -> None: + """Test DHCP discovery starts the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + assert result["description_placeholders"] == { + "discovery": ( + "A likely iAquaLink device was discovered on your network at " + "192.168.1.23 (iAquaLink-123456). " + ) + } + + async def test_with_invalid_credentials( hass: HomeAssistant, config_data: dict[str, str] ) -> None: """Test config flow with invalid username and/or password.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass + flow.context = {} with patch( "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", @@ -69,6 +96,7 @@ async def test_service_exception( """Test config flow encountering service exception.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass + flow.context = {} with patch( "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", @@ -100,6 +128,41 @@ async def test_with_existing_config( assert result["data"] == config_data +async def test_user_flow_sets_domain_unique_id( + hass: HomeAssistant, config_data: dict[str, str] +) -> None: + """Test the user flow stores the single-instance unique ID.""" + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + flow.context = {} + + with patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + return_value=None, + ): + result = await flow.async_step_user(config_data) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == DOMAIN + + +async def test_dhcp_discovery_aborts_if_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery aborts if iaqualink is already configured.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + async def test_reauth_success(hass: HomeAssistant, config_data: dict[str, str]) -> None: """Test successful reauthentication.""" entry = MockConfigEntry(