Improve human-readable name for new/reauth/reconfig in ESPHome (#143302)

* Improve human-readable prompt when requesting ESPHome credentials

Users reported difficulty identifying which device needs reauthentication, especially when names are similar (e.g., `power-meter` vs `power-meter-EEFF`). Previously, only the hostname was shown, which led to confusion. This change includes the config entry title or friendly name—when available—in the prompt to make device identification easier.

* Update homeassistant/components/esphome/config_flow.py

* add missing cover

* tweaks

* one more

* one more

* cover

* some are ``, some are not, make them all ``

* Apply suggestions from code review

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
J. Nick Koston 2025-04-21 04:25:14 -10:00 committed by GitHub
parent ba6ce28d3c
commit 849121a124
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 146 additions and 16 deletions

View File

@ -57,6 +57,7 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
_LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
DEFAULT_NAME = "ESPHome"
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@ -117,8 +118,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._host = entry_data[CONF_HOST]
self._port = entry_data[CONF_PORT]
self._password = entry_data[CONF_PASSWORD]
self._name = self._reauth_entry.title
self._device_name = entry_data.get(CONF_DEVICE_NAME)
self._name = self._reauth_entry.title
# Device without encryption allows fetching device info. We can then check
# if the device is no longer using a password. If we did try with a password,
@ -147,7 +148,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="reauth_encryption_removed_confirm",
description_placeholders={"name": self._name},
description_placeholders={"name": self._async_get_human_readable_name()},
)
async def async_step_reauth_confirm(
@ -172,7 +173,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
errors=errors,
description_placeholders={"name": self._name},
description_placeholders={"name": self._async_get_human_readable_name()},
)
async def async_step_reconfigure(
@ -189,12 +190,14 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@property
def _name(self) -> str:
return self.__name or "ESPHome"
return self.__name or DEFAULT_NAME
@_name.setter
def _name(self, value: str) -> None:
self.__name = value
self.context["title_placeholders"] = {"name": self._name}
self.context["title_placeholders"] = {
"name": self._async_get_human_readable_name()
}
async def _async_try_fetch_device_info(self) -> ConfigFlowResult:
"""Try to fetch device info and return any errors."""
@ -254,7 +257,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
return await self._async_try_fetch_device_info()
return self.async_show_form(
step_id="discovery_confirm", description_placeholders={"name": self._name}
step_id="discovery_confirm",
description_placeholders={"name": self._async_get_human_readable_name()},
)
async def async_step_zeroconf(
@ -274,8 +278,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Hostname is format: livingroom.local.
device_name = discovery_info.hostname.removesuffix(".local.")
self._name = discovery_info.properties.get("friendly_name", device_name)
self._device_name = device_name
self._name = discovery_info.properties.get("friendly_name", device_name)
self._host = discovery_info.host
self._port = discovery_info.port
self._noise_required = bool(discovery_info.properties.get("api_encryption"))
@ -593,9 +597,30 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="encryption_key",
data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
errors=errors,
description_placeholders={"name": self._name},
description_placeholders={"name": self._async_get_human_readable_name()},
)
@callback
def _async_get_human_readable_name(self) -> str:
"""Return a human readable name for the entry."""
entry: ConfigEntry | None = None
if self.source == SOURCE_REAUTH:
entry = self._reauth_entry
elif self.source == SOURCE_RECONFIGURE:
entry = self._reconfig_entry
friendly_name = self._name
device_name = self._device_name
if (
device_name
and friendly_name in (DEFAULT_NAME, device_name)
and entry
and entry.title != friendly_name
):
friendly_name = entry.title
if not device_name or friendly_name == device_name:
return friendly_name
return f"{friendly_name} ({device_name})"
async def async_step_authenticate(
self, user_input: dict[str, Any] | None = None, error: str | None = None
) -> ConfigFlowResult:
@ -614,7 +639,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="authenticate",
data_schema=vol.Schema({vol.Required("password"): str}),
description_placeholders={"name": self._name},
description_placeholders={"name": self._async_get_human_readable_name()},
errors=errors,
)
@ -648,9 +673,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return "connection_error"
finally:
await cli.disconnect(force=True)
self._name = self._device_info.friendly_name or self._device_info.name
self._device_name = self._device_info.name
self._device_mac = format_mac(self._device_info.mac_address)
self._device_name = self._device_info.name
self._name = self._device_info.friendly_name or self._device_info.name
return None
async def fetch_device_info(self) -> str | None:

View File

@ -43,7 +43,7 @@
"data_description": {
"password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead."
},
"description": "Please enter the password you set in your ESPHome device YAML configuration for {name}."
"description": "Please enter the password you set in your ESPHome device YAML configuration for `{name}`."
},
"encryption_key": {
"data": {
@ -52,7 +52,7 @@
"data_description": {
"noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration."
},
"description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
"description": "Please enter the encryption key for `{name}`. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
},
"reauth_confirm": {
"data": {
@ -61,10 +61,10 @@
"data_description": {
"noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]"
},
"description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
"description": "The ESPHome device `{name}` enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
},
"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."
"description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
},
"discovery_confirm": {
"description": "Do you want to add the device `{name}` to Home Assistant?",

View File

@ -27,6 +27,7 @@ from homeassistant.components.esphome.const import (
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
)
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -50,6 +51,17 @@ def mock_setup_entry():
yield
def get_flow_context(hass: HomeAssistant, result: ConfigFlowResult) -> dict[str, Any]:
"""Get the flow context from the result of async_init or async_configure."""
flow = next(
flow
for flow in hass.config_entries.flow.async_progress()
if flow["flow_id"] == result["flow_id"]
)
return flow["context"]
@pytest.mark.usefixtures("mock_zeroconf")
async def test_user_connection_works(
hass: HomeAssistant, mock_client, mock_setup_entry: None
@ -150,6 +162,9 @@ async def test_user_sets_unique_id(
assert discovery_result["type"] is FlowResultType.FORM
assert discovery_result["step_id"] == "discovery_confirm"
assert discovery_result["description_placeholders"] == {
"name": "test8266",
}
discovery_result = await hass.config_entries.flow.async_configure(
discovery_result["flow_id"],
@ -234,6 +249,9 @@ async def test_user_causes_zeroconf_to_abort(
assert discovery_result["type"] is FlowResultType.FORM
assert discovery_result["step_id"] == "discovery_confirm"
assert discovery_result["description_placeholders"] == {
"name": "test8266",
}
result = await hass.config_entries.flow.async_init(
"esphome",
@ -297,6 +315,7 @@ async def test_user_with_password(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PASSWORD: "password1"}
@ -326,6 +345,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None:
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
mock_client.connect.side_effect = InvalidAuthAPIError
@ -335,6 +355,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None:
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
assert result["errors"] == {"base": "invalid_auth"}
@ -348,7 +369,7 @@ async def test_user_dashboard_has_wrong_key(
"""Test user step with key from dashboard that is incorrect."""
mock_client.device_info.side_effect = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo(
uses_password=False,
name="test",
@ -369,6 +390,7 @@ async def test_user_dashboard_has_wrong_key(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -477,6 +499,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -532,6 +555,7 @@ async def test_user_discovers_name_and_dashboard_is_unavailable(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -563,6 +587,7 @@ async def test_login_connection_error(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
mock_client.connect.side_effect = APIConnectionError
@ -572,6 +597,7 @@ async def test_login_connection_error(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
assert result["errors"] == {"base": "connection_error"}
@ -588,12 +614,18 @@ async def test_discovery_initiation(
port=6053,
properties={
"mac": "1122334455aa",
"friendly_name": "The Test",
},
type="mock_type",
)
flow = await hass.config_entries.flow.async_init(
"esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info
)
assert get_flow_context(hass, flow) == {
"source": config_entries.SOURCE_ZEROCONF,
"title_placeholders": {"name": "The Test (test)"},
"unique_id": "11:22:33:44:55:aa",
}
result = await hass.config_entries.flow.async_configure(
flow["flow_id"], user_input={}
@ -682,6 +714,7 @@ async def test_discovery_duplicate_data(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_init(
"esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF}
@ -742,6 +775,7 @@ async def test_user_requires_psk(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["errors"] == {}
assert result["description_placeholders"] == {"name": "ESPHome"}
assert len(mock_client.connect.mock_calls) == 2
assert len(mock_client.device_info.mock_calls) == 2
@ -764,6 +798,7 @@ async def test_encryption_key_valid_psk(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "ESPHome"}
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
@ -799,6 +834,7 @@ async def test_encryption_key_invalid_psk(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "ESPHome"}
mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError
result = await hass.config_entries.flow.async_configure(
@ -808,6 +844,7 @@ async def test_encryption_key_invalid_psk(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["errors"] == {"base": "invalid_psk"}
assert result["description_placeholders"] == {"name": "ESPHome"}
assert mock_client.noise_psk == INVALID_NOISE_PSK
@ -823,6 +860,9 @@ async def test_reauth_initiation(hass: HomeAssistant, mock_client) -> None:
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
@pytest.mark.usefixtures("mock_zeroconf")
@ -1025,6 +1065,9 @@ async def test_reauth_fixed_via_dashboard_at_confirm(
assert result["type"] is FlowResultType.FORM, result
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
mock_dashboard["configured"].append(
{
@ -1070,6 +1113,9 @@ async def test_reauth_confirm_invalid(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
assert result["errors"]
assert result["errors"]["base"] == "invalid_psk"
@ -1108,6 +1154,9 @@ async def test_reauth_confirm_invalid_with_unique_id(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
assert result["errors"]
assert result["errors"]["base"] == "invalid_psk"
@ -1145,6 +1194,9 @@ async def test_reauth_encryption_key_removed(
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_encryption_removed_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
@ -1370,6 +1422,7 @@ async def test_zeroconf_encryption_key_via_dashboard(
assert flow["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
mock_dashboard["configured"].append(
{
@ -1437,6 +1490,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop(
assert flow["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
mock_dashboard["configured"].append(
{
@ -1502,6 +1556,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
assert flow["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
await dashboard.async_get_dashboard(hass).async_refresh()
@ -1513,6 +1568,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test8266"}
async def test_option_flow_allow_service_calls(
@ -1625,6 +1681,7 @@ async def test_user_discovers_name_no_dashboard(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -1917,6 +1974,54 @@ 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")
async def test_reconfig_success_noise_psk_changes(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with new ip and new noise psk."""
entry = MockConfigEntry(
domain=DOMAIN,
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 = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"),
]
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"] == "encryption_key"
assert result["description_placeholders"] == {"name": "Mock Title (test)"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "Mock Title (test)"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_HOST] == "127.0.0.1"
assert entry.data[CONF_DEVICE_NAME] == "test"
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_with_existing_entry(
hass: HomeAssistant, mock_client: APIClient