Skip to content

Commit 99a0380

Browse files
puddlyfrenck
authored andcommitted
Ignore discovery for existing ZHA entries (home-assistant#152984)
1 parent 68c51dc commit 99a0380

File tree

2 files changed

+115
-33
lines changed

2 files changed

+115
-33
lines changed

homeassistant/components/zha/config_flow.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
2424
from homeassistant.config_entries import (
2525
SOURCE_IGNORE,
26+
SOURCE_ZEROCONF,
2627
ConfigEntry,
2728
ConfigEntryBaseFlow,
2829
ConfigEntryState,
@@ -183,27 +184,17 @@ def hass(self, hass: HomeAssistant) -> None:
183184
self._hass = hass
184185
self._radio_mgr.hass = hass
185186

186-
async def _get_config_entry_data(self) -> dict:
187+
def _get_config_entry_data(self) -> dict[str, Any]:
187188
"""Extract ZHA config entry data from the radio manager."""
188189
assert self._radio_mgr.radio_type is not None
189190
assert self._radio_mgr.device_path is not None
190191
assert self._radio_mgr.device_settings is not None
191192

192-
try:
193-
device_path = await self.hass.async_add_executor_job(
194-
usb.get_serial_by_id, self._radio_mgr.device_path
195-
)
196-
except OSError as error:
197-
raise AbortFlow(
198-
reason="cannot_resolve_path",
199-
description_placeholders={"path": self._radio_mgr.device_path},
200-
) from error
201-
202193
return {
203194
CONF_DEVICE: DEVICE_SCHEMA(
204195
{
205196
**self._radio_mgr.device_settings,
206-
CONF_DEVICE_PATH: device_path,
197+
CONF_DEVICE_PATH: self._radio_mgr.device_path,
207198
}
208199
),
209200
CONF_RADIO_TYPE: self._radio_mgr.radio_type.name,
@@ -703,6 +694,36 @@ async def async_step_confirm(
703694
DOMAIN, include_ignore=False
704695
)
705696

697+
if self._radio_mgr.device_path is not None:
698+
# Ensure the radio manager device path is unique and will match ZHA's
699+
try:
700+
self._radio_mgr.device_path = await self.hass.async_add_executor_job(
701+
usb.get_serial_by_id, self._radio_mgr.device_path
702+
)
703+
except OSError as error:
704+
raise AbortFlow(
705+
reason="cannot_resolve_path",
706+
description_placeholders={"path": self._radio_mgr.device_path},
707+
) from error
708+
709+
# mDNS discovery can advertise the same adapter on multiple IPs or via a
710+
# hostname, which should be considered a duplicate
711+
current_device_paths = {self._radio_mgr.device_path}
712+
713+
if self.source == SOURCE_ZEROCONF:
714+
discovery_info = self.init_data
715+
current_device_paths |= {
716+
f"socket://{ip}:{discovery_info.port}"
717+
for ip in discovery_info.ip_addresses
718+
}
719+
720+
for entry in zha_config_entries:
721+
path = entry.data.get(CONF_DEVICE, {}).get(CONF_DEVICE_PATH)
722+
723+
# Abort discovery if the device path is already configured
724+
if path is not None and path in current_device_paths:
725+
return self.async_abort(reason="single_instance_allowed")
726+
706727
# Without confirmation, discovery can automatically progress into parts of the
707728
# config flow logic that interacts with hardware.
708729
if user_input is not None or (
@@ -873,7 +894,7 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult:
873894
zha_config_entries = self.hass.config_entries.async_entries(
874895
DOMAIN, include_ignore=False
875896
)
876-
data = await self._get_config_entry_data()
897+
data = self._get_config_entry_data()
877898

878899
if len(zha_config_entries) == 1:
879900
return self.async_update_reload_and_abort(
@@ -976,7 +997,7 @@ async def _async_create_radio_entry(self):
976997
# Avoid creating both `.options` and `.data` by directly writing `data` here
977998
self.hass.config_entries.async_update_entry(
978999
entry=self.config_entry,
979-
data=await self._get_config_entry_data(),
1000+
data=self._get_config_entry_data(),
9801001
options=self.config_entry.options,
9811002
)
9821003

tests/components/zha/test_config_flow.py

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,40 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non
857857
}
858858

859859

860+
async def test_discovery_via_usb_same_device_already_setup(hass: HomeAssistant) -> None:
861+
"""Test discovery aborting if ZHA is already setup."""
862+
MockConfigEntry(
863+
domain=DOMAIN,
864+
data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/serial/by-id/usb-device123"}},
865+
).add_to_hass(hass)
866+
867+
# Discovery info with the same device but different path format
868+
discovery_info = UsbServiceInfo(
869+
device="/dev/ttyUSB0",
870+
pid="AAAA",
871+
vid="AAAA",
872+
serial_number="1234",
873+
description="zigbee radio",
874+
manufacturer="test",
875+
)
876+
877+
with patch(
878+
"homeassistant.components.zha.config_flow.usb.get_serial_by_id",
879+
return_value="/dev/serial/by-id/usb-device123",
880+
) as mock_get_serial_by_id:
881+
result = await hass.config_entries.flow.async_init(
882+
DOMAIN, context={"source": SOURCE_USB}, data=discovery_info
883+
)
884+
await hass.async_block_till_done()
885+
886+
# Verify get_serial_by_id was called to normalize the path
887+
assert mock_get_serial_by_id.mock_calls == [call("/dev/ttyUSB0")]
888+
889+
# Should abort since it's the same device
890+
assert result["type"] is FlowResultType.ABORT
891+
assert result["reason"] == "single_instance_allowed"
892+
893+
860894
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
861895
@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True))
862896
async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None:
@@ -890,6 +924,39 @@ async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> N
890924
assert confirm_result["step_id"] == "choose_migration_strategy"
891925

892926

927+
async def test_zeroconf_discovery_via_socket_already_setup_with_ip_match(
928+
hass: HomeAssistant,
929+
) -> None:
930+
"""Test zeroconf discovery aborting when ZHA is already setup with socket and one IP matches."""
931+
MockConfigEntry(
932+
domain=DOMAIN,
933+
data={CONF_DEVICE: {CONF_DEVICE_PATH: "socket://192.168.1.101:6638"}},
934+
).add_to_hass(hass)
935+
936+
service_info = ZeroconfServiceInfo(
937+
ip_address=ip_address("192.168.1.100"),
938+
ip_addresses=[
939+
ip_address("192.168.1.100"),
940+
ip_address("192.168.1.101"), # Matches config entry
941+
],
942+
hostname="tube-zigbee-gw.local.",
943+
name="mock_name",
944+
port=6638,
945+
properties={"name": "tube_123456"},
946+
type="mock_type",
947+
)
948+
949+
# Discovery should abort due to single instance check
950+
result = await hass.config_entries.flow.async_init(
951+
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info
952+
)
953+
await hass.async_block_till_done()
954+
955+
# Should abort since one of the advertised IPs matches existing socket path
956+
assert result["type"] is FlowResultType.ABORT
957+
assert result["reason"] == "single_instance_allowed"
958+
959+
893960
@patch(
894961
"homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type",
895962
mock_detect_radio_type(radio_type=RadioType.deconz),
@@ -2289,34 +2356,28 @@ async def test_config_flow_serial_resolution_oserror(
22892356
) -> None:
22902357
"""Test that OSError during serial port resolution is handled."""
22912358

2292-
result = await hass.config_entries.flow.async_init(
2293-
DOMAIN,
2294-
context={"source": "manual_pick_radio_type"},
2295-
data={CONF_RADIO_TYPE: RadioType.ezsp.description},
2296-
)
2297-
2298-
result = await hass.config_entries.flow.async_configure(
2299-
result["flow_id"],
2300-
user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"},
2359+
discovery_info = UsbServiceInfo(
2360+
device="/dev/ttyZIGBEE",
2361+
pid="AAAA",
2362+
vid="AAAA",
2363+
serial_number="1234",
2364+
description="zigbee radio",
2365+
manufacturer="test",
23012366
)
23022367

2303-
assert result["type"] is FlowResultType.MENU
2304-
assert result["step_id"] == "choose_setup_strategy"
2305-
23062368
with (
23072369
patch(
2308-
"homeassistant.components.usb.get_serial_by_id",
2370+
"homeassistant.components.zha.config_flow.usb.get_serial_by_id",
23092371
side_effect=OSError("Test error"),
23102372
),
23112373
):
2312-
setup_result = await hass.config_entries.flow.async_configure(
2313-
result["flow_id"],
2314-
user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED},
2374+
result_init = await hass.config_entries.flow.async_init(
2375+
DOMAIN, context={"source": SOURCE_USB}, data=discovery_info
23152376
)
23162377

2317-
assert setup_result["type"] is FlowResultType.ABORT
2318-
assert setup_result["reason"] == "cannot_resolve_path"
2319-
assert setup_result["description_placeholders"] == {"path": "/dev/ttyUSB33"}
2378+
assert result_init["type"] is FlowResultType.ABORT
2379+
assert result_init["reason"] == "cannot_resolve_path"
2380+
assert result_init["description_placeholders"] == {"path": "/dev/ttyZIGBEE"}
23202381

23212382

23222383
@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee")

0 commit comments

Comments
 (0)