Handle name conflicts in ESPHome config flow (#142966)

This commit is contained in:
J. Nick Koston 2025-04-15 08:09:51 -10:00 committed by GitHub
parent 5fd17d092b
commit ae306893ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 179 additions and 4 deletions

View File

@ -47,6 +47,7 @@ from .const import (
DOMAIN,
)
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
from .manager import async_replace_device
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
@ -74,6 +75,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# The ESPHome name as per its config
self._device_name: str | None = None
self._device_mac: str | None = None
self._entry_with_name_conflict: ConfigEntry | None = None
async def _async_step_user_base(
self, user_input: dict[str, Any] | None = None, error: str | None = None
@ -137,7 +139,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle reauthorization flow when encryption was removed."""
if user_input is not None:
self._noise_psk = None
return self._async_get_entry()
return await self._async_get_entry_or_resolve_conflict()
return self.async_show_form(
step_id="reauth_encryption_removed_confirm",
@ -227,7 +229,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_authenticate()
self._password = ""
return self._async_get_entry()
return await self._async_get_entry_or_resolve_conflict()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
@ -354,6 +356,77 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
)
return self.async_abort(reason="service_received")
async def async_step_name_conflict(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle name conflict resolution."""
assert self._entry_with_name_conflict is not None
assert self._entry_with_name_conflict.unique_id is not None
assert self.unique_id is not None
assert self._device_name is not None
return self.async_show_menu(
step_id="name_conflict",
menu_options=["name_conflict_migrate", "name_conflict_overwrite"],
description_placeholders={
"existing_mac": format_mac(self._entry_with_name_conflict.unique_id),
"existing_title": self._entry_with_name_conflict.title,
"mac": format_mac(self.unique_id),
"name": self._device_name,
},
)
async def async_step_name_conflict_migrate(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle migration of existing entry."""
assert self._entry_with_name_conflict is not None
assert self._entry_with_name_conflict.unique_id is not None
assert self.unique_id is not None
assert self._device_name is not None
assert self._host is not None
old_mac = format_mac(self._entry_with_name_conflict.unique_id)
new_mac = format_mac(self.unique_id)
entry_id = self._entry_with_name_conflict.entry_id
self.hass.config_entries.async_update_entry(
self._entry_with_name_conflict,
data={
**self._entry_with_name_conflict.data,
CONF_HOST: self._host,
CONF_PORT: self._port or 6053,
CONF_PASSWORD: self._password or "",
CONF_NOISE_PSK: self._noise_psk or "",
},
)
await async_replace_device(self.hass, entry_id, old_mac, new_mac)
self.hass.config_entries.async_schedule_reload(entry_id)
return self.async_abort(
reason="name_conflict_migrated",
description_placeholders={
"existing_mac": old_mac,
"mac": new_mac,
"name": self._device_name,
},
)
async def async_step_name_conflict_overwrite(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle creating a new entry by removing the old one and creating new."""
assert self._entry_with_name_conflict is not None
await self.hass.config_entries.async_remove(
self._entry_with_name_conflict.entry_id
)
return self._async_get_entry()
async def _async_get_entry_or_resolve_conflict(self) -> ConfigFlowResult:
"""Return the entry or resolve a conflict."""
if self.source != SOURCE_REAUTH:
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = entry
return await self.async_step_name_conflict()
return self._async_get_entry()
@callback
def _async_get_entry(self) -> ConfigFlowResult:
config_data = {
@ -407,7 +480,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
error = await self.try_login()
if error:
return await self.async_step_authenticate(error=error)
return self._async_get_entry()
return await self._async_get_entry_or_resolve_conflict()
errors = {}
if error is not None:

View File

@ -9,7 +9,8 @@
"mqtt_missing_mac": "Missing MAC address in MQTT properties.",
"mqtt_missing_api": "Missing API port in MQTT properties.",
"mqtt_missing_ip": "Missing IP address in MQTT properties.",
"mqtt_missing_payload": "Missing MQTT Payload."
"mqtt_missing_payload": "Missing MQTT Payload.",
"name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`."
},
"error": {
"resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address",
@ -49,6 +50,14 @@
"discovery_confirm": {
"description": "Do you want to add the device `{name}` to Home Assistant?",
"title": "Discovered ESPHome device"
},
"name_conflict": {
"title": "Name conflict",
"description": "**The name `{name}` is already being used by another device: {existing_title} (MAC address: `{existing_mac}`)**\n\nTo continue, please choose one of the following options:\n\n**Migrate Configuration to New Device:** If this is a replacement, migrate the existing settings to the new device (`{mac}`).\n**Overwrite the Existing Configuration:** If this is not a replacement, delete the old configuration for `{existing_mac}` and use the new device instead.",
"menu_options": {
"name_conflict_migrate": "Migrate configuration to new device",
"name_conflict_overwrite": "Overwrite the existing configuration"
}
}
},
"flow_title": "{name}"

View File

@ -1622,3 +1622,96 @@ async def test_discovery_mqtt_initiation(
assert result["result"]
assert result["result"].unique_id == "11:22:33:44:55:aa"
@pytest.mark.usefixtures("mock_zeroconf")
async def test_user_flow_name_conflict_migrate(
hass: HomeAssistant,
mock_client,
mock_setup_entry: None,
) -> None:
"""Test handle migration on name conflict."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE_NAME: "test"},
unique_id="11:22:33:44:55:cc",
)
existing_entry.add_to_hass(hass)
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(
uses_password=False,
name="test",
mac_address="11:22:33:44:55:AA",
)
)
result = await hass.config_entries.flow.async_init(
"esphome",
context={"source": config_entries.SOURCE_USER},
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "name_conflict"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "name_conflict_migrated"
assert existing_entry.data == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: "",
CONF_DEVICE_NAME: "test",
}
assert existing_entry.unique_id == "11:22:33:44:55:aa"
@pytest.mark.usefixtures("mock_zeroconf")
async def test_user_flow_name_conflict_overwrite(
hass: HomeAssistant,
mock_client,
mock_setup_entry: None,
) -> None:
"""Test handle overwrite on name conflict."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE_NAME: "test"},
unique_id="11:22:33:44:55:cc",
)
existing_entry.add_to_hass(hass)
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(
uses_password=False,
name="test",
mac_address="11:22:33:44:55:AA",
)
)
result = await hass.config_entries.flow.async_init(
"esphome",
context={"source": config_entries.SOURCE_USER},
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "name_conflict"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: "",
CONF_DEVICE_NAME: "test",
}
assert result["context"]["unique_id"] == "11:22:33:44:55:aa"