diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 29591cb379dd4b..c2a6ed36b99465 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -2,6 +2,7 @@ from collections import OrderedDict from collections.abc import Mapping +from ipaddress import IPv6Address, ip_address import json import logging from typing import Any, cast @@ -69,6 +70,17 @@ DEFAULT_NAME = "ESPHome" +def _format_host_for_display(host: str) -> str: + """Return host formatted for host:port display.""" + try: + address = ip_address(host) + except ValueError: + return host + if isinstance(address, IPv6Address): + return f"[{host}]" + return host + + class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a esphome config flow.""" @@ -269,9 +281,50 @@ async def _async_try_fetch_device_info(self) -> ConfigFlowResult: if response == ERROR_REQUIRES_ENCRYPTION_KEY: return await self.async_step_encryption_key() if response is not None: + if ( + self.source == SOURCE_RECONFIGURE + and response in ("connection_error", "resolve_error") + and self._async_reconfigure_connection_changed() + ): + return await self.async_step_reconfigure_confirm_unreachable() return await self._async_step_user_base(error=response) return await self._async_authenticate_or_add() + @callback + def _async_reconfigure_connection_changed(self) -> bool: + """Return if the reconfigure flow has a new host or port.""" + assert self._host is not None + assert self._port is not None + return self._host != self._reconfig_entry.data.get( + CONF_HOST + ) or self._port != self._reconfig_entry.data.get(CONF_PORT, DEFAULT_PORT) + + async def async_step_reconfigure_confirm_unreachable( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm saving a reconfigured host when the device is unreachable.""" + assert self._host is not None + assert self._port is not None + + if user_input is not None: + return self.async_update_reload_and_abort( + self._reconfig_entry, + data=self._reconfig_entry.data + | { + CONF_HOST: self._host, + CONF_PORT: self._port, + }, + ) + + return self.async_show_form( + step_id="reconfigure_confirm_unreachable", + description_placeholders={ + "host": _format_host_for_display(self._host), + "name": self._async_get_human_readable_name(), + "port": str(self._port), + }, + ) + async def _async_authenticate_or_add(self) -> ConfigFlowResult: # Only show authentication step if device uses password assert self._device_info is not None diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 2ef93c2a820e58..f077ff4748de7c 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -70,6 +70,10 @@ "reauth_encryption_removed_confirm": { "description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." }, + "reconfigure_confirm_unreachable": { + "description": "Home Assistant could not validate `{host}:{port}` for `{name}` because the device is currently unreachable.\n\nOnly continue if you are sure this is the correct new address. Home Assistant will keep the existing device identity and update only the host and port.", + "title": "Confirm unreachable ESPHome address" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 1d32ada5cbca8f..28a0ebdc726718 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -2390,6 +2390,97 @@ async def test_reconfig_success_with_new_ip_same_name( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +@pytest.mark.parametrize( + ("host", "error", "placeholder_host"), + [ + ("127.0.0.2", APIConnectionError, "127.0.0.2"), + ("2001:db8::2", ResolveAPIError, "[2001:db8::2]"), + ], +) +async def test_reconfig_can_save_changed_unreachable_connection( + hass: HomeAssistant, + mock_client: APIClient, + host: str, + error: type[Exception], + placeholder_host: str, +) -> None: + """Test reconfig can save changed connection details when unreachable.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Kitchen Sensor", + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.side_effect = error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: host, CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm_unreachable" + assert result["description_placeholders"] == { + "host": placeholder_host, + "name": "Kitchen Sensor", + "port": "6053", + } + assert entry.data[CONF_HOST] == "127.0.0.1" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_HOST: host, + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_same_unreachable_host_shows_error( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig does not confirm unchanged unreachable connection details.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.side_effect = APIConnectionError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_error"} + assert entry.data[CONF_HOST] == "127.0.0.1" + + @pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") async def test_reconfig_success_noise_psk_changes( hass: HomeAssistant, mock_client: APIClient