From ae306893ff594e75c0c0b9517c5ddc9faaf03e95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Apr 2025 08:09:51 -1000 Subject: [PATCH] Handle name conflicts in ESPHome config flow (#142966) --- .../components/esphome/config_flow.py | 79 +++++++++++++++- homeassistant/components/esphome/strings.json | 11 ++- tests/components/esphome/test_config_flow.py | 93 +++++++++++++++++++ 3 files changed, 179 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 95304476fae..96ffa43038d 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -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: diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 8c20fb4e95a..42862885ae9 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -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}" diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 60c93d5fb2c..440e52700b1 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -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"