Make ignoring Z-Wave JS discoveries work (#179595)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Balloob Bot
2026-08-20 17:17:08 -04:00
committed by GitHub
co-authored by Claude Paulus Schoutsen
parent 414bd3d878
commit 8379f19107
2 changed files with 195 additions and 5 deletions
@@ -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)
@@ -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."""