Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions homeassistant/components/esphome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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()
Comment on lines +285 to +287
):
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
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/esphome/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +74 to +75
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
Expand Down
91 changes: 91 additions & 0 deletions tests/components/esphome/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2390,6 +2390,97 @@
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"] == {

Check failure on line 2432 in tests/components/esphome/test_config_flow.py

View workflow job for this annotation

GitHub Actions / Run tests Python 3.14.4 (esphome)

test_reconfig_can_save_changed_unreachable_connection[2001:db8::2-ResolveAPIError-[2001:db8::2]] AssertionError: assert {'host': '[20...port': '6053'} == {'host': '[20...port': '6053'} Omitting 2 identical items, use -vv to show Differing items: {'name': 'Kitchen Sensor (test)'} != {'name': 'Kitchen Sensor'} Full diff: { 'host': '[2001:db8::2]', - 'name': 'Kitchen Sensor', + 'name': 'Kitchen Sensor (test)', ? +++++++ 'port': '6053', }

Check failure on line 2432 in tests/components/esphome/test_config_flow.py

View workflow job for this annotation

GitHub Actions / Run tests Python 3.14.4 (esphome)

test_reconfig_can_save_changed_unreachable_connection[127.0.0.2-APIConnectionError-127.0.0.2] AssertionError: assert {'host': '127...port': '6053'} == {'host': '127...port': '6053'} Omitting 2 identical items, use -vv to show Differing items: {'name': 'Kitchen Sensor (test)'} != {'name': 'Kitchen Sensor'} Full diff: { 'host': '127.0.0.2', - 'name': 'Kitchen Sensor', + 'name': 'Kitchen Sensor (test)', ? +++++++ 'port': '6053', }
"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
Expand Down
Loading