From 8379f19107ce1041f3696aaac9b3477b5f0180f9 Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Thu, 20 Aug 2026 23:17:08 +0200 Subject: [PATCH] Make ignoring Z-Wave JS discoveries work (#179595) Co-authored-by: Claude Co-authored-by: Paulus Schoutsen --- .../components/zwave_js/config_flow.py | 45 ++++- tests/components/zwave_js/test_config_flow.py | 155 ++++++++++++++++++ 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index c1315d8619cc..034fae1e0157 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.components.hassio import ( ) from homeassistant.config_entries import ( SOURCE_ESPHOME, + SOURCE_IGNORE, SOURCE_USB, SOURCE_ZEROCONF, ConfigEntry, @@ -235,6 +236,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._recommended_install = False self._rf_region: str | None = None self._entry_unloaded_by_flow = False + # Set if the flow unique id is a placeholder that must be replaced + # with the home ID before a config entry is created. + self._unique_id_is_placeholder = False async def async_step_install_addon( self, user_input: dict[str, Any] | None = None @@ -571,10 +575,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" ) - # We don't need to check if the unique_id is already configured - # since we will update the unique_id before finishing the flow. - # The unique_id set above is just a temporary value to avoid - # duplicate discovery flows. + # The unique id set above is a placeholder that is replaced with the + # home ID before an entry is created, so only check ignored entries. + if any( + entry.source == SOURCE_IGNORE and entry.unique_id == self.unique_id + for entry in self._async_current_entries(include_ignore=True) + ): + return self.async_abort(reason="already_configured") dev_path = discovery_info.device self.usb_path = dev_path if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2": @@ -1025,7 +1032,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id or self.source == SOURCE_USB: + if ( + not self.unique_id + or self.source == SOURCE_USB + or self._unique_id_is_placeholder + ): if not self.version_info: try: self.version_info = await async_get_version_info( @@ -1037,6 +1048,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( str(self.version_info.home_id), raise_on_progress=False ) + self._unique_id_is_placeholder = False if ( existing_entry := next( @@ -1659,6 +1671,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): if not is_hassio(self.hass): return self.async_abort(reason="not_hassio") + # The adapter may first be discovered without a home ID and get the + # placeholder unique id below, then report a home ID on a later + # discovery. Track the placeholder id so such a discovery can be + # deduplicated against a pending prompt or an ignored entry. + placeholder_unique_id = f"esphome_{discovery_info.name}" if discovery_info.zwave_home_id: existing_entry: ConfigEntry | None = None if ( @@ -1706,6 +1723,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="already_configured") + if any( + flow["context"].get("unique_id") == placeholder_unique_id + for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") # We are not aborting if home ID configured # here, we just want to make sure that it's set # We will update a USB based config entry @@ -1714,6 +1736,19 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( str(discovery_info.zwave_home_id), raise_on_progress=False ) + else: + # Set a placeholder unique id so the discovery can be ignored + # also when the adapter doesn't report a home ID yet. + # It is replaced with the home ID before an entry is created. + self._unique_id_is_placeholder = True + await self.async_set_unique_id(placeholder_unique_id) + + if any( + entry.source == SOURCE_IGNORE + and entry.unique_id in (self.unique_id, placeholder_unique_id) + for entry in self._async_current_entries(include_ignore=True) + ): + return self.async_abort(reason="already_configured") self.socket_path = discovery_info.socket_path home_id_display = format_home_id_for_display(discovery_info.zwave_home_id) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 99d114455ece..fe5ac5b6384b 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1722,6 +1722,66 @@ async def test_esphome_discovery_no_home_id_configured_socket_no_migration( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_placeholder_then_home_id( + hass: HomeAssistant, +) -> None: + """Test a home ID discovery dedups against a pending placeholder prompt.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + # The same adapter now reports a home ID while its prompt is open. + home_id_info = ESPHomeServiceInfo( + name=ESPHOME_DISCOVERY_INFO_CLEAN.name, + zwave_home_id=1234, + ip_address=ESPHOME_DISCOVERY_INFO_CLEAN.ip_address, + port=ESPHOME_DISCOVERY_INFO_CLEAN.port, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=home_id_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_placeholder_ignored_then_home_id( + hass: HomeAssistant, +) -> None: + """Test a home ID discovery honors a placeholder-based ignore.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id="esphome_mock-name", + ) + entry.add_to_hass(hass) + + # The adapter that was ignored without a home ID now reports one. + home_id_info = ESPHomeServiceInfo( + name="mock-name", + zwave_home_id=1234, + ip_address="192.168.1.100", + port=6053, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=home_id_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_same_socket_no_reload( hass: HomeAssistant, @@ -1765,6 +1825,78 @@ async def test_esphome_discovery_same_socket_no_reload( } +@pytest.mark.usefixtures("supervisor", "addon_running") +@pytest.mark.parametrize( + ("esphome_discovery_info", "ignored_unique_id"), + [ + pytest.param(ESPHOME_DISCOVERY_INFO, "1234", id="home_id"), + pytest.param( + ESPHOME_DISCOVERY_INFO_CLEAN, "esphome_mock-name", id="no_home_id" + ), + ], +) +async def test_esphome_discovery_ignored( + hass: HomeAssistant, + esphome_discovery_info: ESPHomeServiceInfo, + ignored_unique_id: str, +) -> None: + """Test ESPHome discovery aborts when the discovery was ignored.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id=ignored_unique_id, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=esphome_discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_without_home_id_can_be_ignored( + hass: HomeAssistant, +) -> None: + """Test a discovery without a home ID gets a unique id for ignoring.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + flows = hass.config_entries.flow.async_progress_by_handler( + DOMAIN, match_context={"source": config_entries.SOURCE_ESPHOME} + ) + assert len(flows) == 1 + assert flows[0]["context"]["unique_id"] == "esphome_mock-name" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": "esphome_mock-name", "title": "ZWA-2 proxy"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + # The discovery prompt is gone and rediscovery aborts. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_already_configured_unmanaged_addon( hass: HomeAssistant, @@ -2420,6 +2552,29 @@ async def test_usb_discovery_leaves_manual_entry_alone( assert entry.data == {"url": "ws://external-server:3000"} +@pytest.mark.usefixtures("supervisor", "addon_info") +async def test_usb_discovery_ignored( + hass: HomeAssistant, + mock_usb_serial_by_id: MagicMock, +) -> None: + """Test USB discovery aborts when the discovery was ignored.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id="AAAA:AAAA_1234_test_zwave radio", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_info") async def test_abort_usb_discovery_addon_required(hass: HomeAssistant) -> None: """Test usb discovery aborted when existing entry not using add-on."""