From b01474380097731bfa6c193fe22dae507c38a977 Mon Sep 17 00:00:00 2001 From: Rob Treacy Date: Fri, 17 Apr 2026 21:14:26 +0100 Subject: [PATCH 1/2] Fix WiZ Light config flow timeout by properly closing UDP connections The WiZ config flow was creating wizlight instances without properly closing them, leaving dangling UDP datagram endpoints in Home Assistant's event loop. This caused subsequent connection attempts to fail with a timeout after exactly 13 seconds (pywizlight's retry limit). The issue manifested as intermittent failures when manually adding WiZ bulbs through the UI, even though the bulbs responded immediately to direct UDP requests. Add await bulb.async_close() in finally blocks to ensure UDP connections are cleaned up in three config flow methods: - _async_connect_discovered_or_abort() - for discovery flows - async_step_pick_device() - for device picking - async_step_user() - for manual IP entry Fixes timeout errors and prevents UDP endpoint accumulation. Tested with WiZ Tunable White (ESP20_SHTWC_01) FW 1.37.0. --- homeassistant/components/wiz/config_flow.py | 6 + tests/components/wiz/test_config_flow.py | 117 ++++++++++++++++++-- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index a676c77688d042..84bee623ad99e8 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -81,6 +81,8 @@ async def _async_connect_discovered_or_abort(self) -> None: exc_info=True, ) raise AbortFlow("cannot_connect") from ex + finally: + await bulb.async_close() self._name = name_from_bulb_type_and_mac(bulbtype, device.mac_address) async def async_step_discovery_confirm( @@ -118,6 +120,8 @@ async def async_step_pick_device( bulbtype = await bulb.get_bulbtype() except WIZ_CONNECT_EXCEPTIONS: return self.async_abort(reason="cannot_connect") + finally: + await bulb.async_close() return self.async_create_entry( title=name_from_bulb_type_and_mac(bulbtype, device.mac_address), @@ -182,6 +186,8 @@ async def async_step_user( title=name, data=user_input, ) + finally: + await bulb.async_close() return self.async_show_form( step_id="user", diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index 946eb032f8ede2..12fe80431a7d53 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -78,6 +78,32 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_form_closes_connection(hass: HomeAssistant) -> None: + """Test the user flow closes the bulb connection after setup.""" + bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + _patch_wizlight(device=bulb), + patch( + "homeassistant.components.wiz.async_setup_entry", + return_value=True, + ), + patch("homeassistant.components.wiz.async_setup", return_value=True), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + bulb.async_close.assert_awaited_once() + + async def test_user_flow_enters_dns_name(hass: HomeAssistant) -> None: """Test we reject dns names and want ips.""" result = await hass.config_entries.flow.async_init( @@ -137,10 +163,10 @@ async def test_user_form_exceptions( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.wiz.wizlight.getBulbConfig", - side_effect=side_effect, - ): + bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) + bulb.get_bulbtype = AsyncMock(side_effect=side_effect) + + with _patch_wizlight(device=bulb): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_CONNECTION, @@ -148,6 +174,7 @@ async def test_user_form_exceptions( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error_base} + bulb.async_close.assert_awaited_once() async def test_form_updates_unique_id(hass: HomeAssistant) -> None: @@ -185,10 +212,10 @@ async def test_discovered_by_dhcp_connection_fails( hass: HomeAssistant, source, data ) -> None: """Test we abort on connection failure.""" - with patch( - "homeassistant.components.wiz.wizlight.getBulbConfig", - side_effect=WizLightTimeOutError, - ): + bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) + bulb.get_bulbtype = AsyncMock(side_effect=WizLightTimeOutError) + + with _patch_wizlight(device=bulb): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) @@ -196,6 +223,50 @@ async def test_discovered_by_dhcp_connection_fails( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + bulb.async_close.assert_awaited_once() + + +@pytest.mark.parametrize( + ("source", "data"), + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY), + ], +) +async def test_discovered_by_dhcp_or_integration_discovery_closes_connection( + hass: HomeAssistant, source, data +) -> None: + """Test discovery closes the bulb connection during confirmation.""" + bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) + + with _patch_wizlight(device=bulb): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + bulb.async_close.assert_awaited_once() + + bulb.async_close.reset_mock() + + with ( + _patch_wizlight(device=bulb), + patch( + "homeassistant.components.wiz.async_setup_entry", + return_value=True, + ), + patch("homeassistant.components.wiz.async_setup", return_value=True), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + bulb.async_close.assert_awaited_once() @pytest.mark.parametrize( @@ -432,6 +503,36 @@ async def test_setup_via_discovery(hass: HomeAssistant) -> None: assert result2["reason"] == "no_devices_found" +async def test_setup_via_discovery_closes_connection(hass: HomeAssistant) -> None: + """Test selecting a discovered device closes the bulb connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with _patch_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "pick_device" + + bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) + + with ( + _patch_wizlight(device=bulb), + patch("homeassistant.components.wiz.async_setup", return_value=True), + patch("homeassistant.components.wiz.async_setup_entry", return_value=True), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: FAKE_MAC}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + bulb.async_close.assert_awaited_once() + + async def test_setup_via_discovery_cannot_connect(hass: HomeAssistant) -> None: """Test setting up via discovery and we fail to connect to the discovered device.""" result = await hass.config_entries.flow.async_init( From 4b55c7bd213f94e8272a4572cfcbb9a49ff9d6ed Mon Sep 17 00:00:00 2001 From: Rob Treacy Date: Fri, 1 May 2026 08:57:58 +0100 Subject: [PATCH 2/2] PR 168456 Combine wiz config tests Instead of creating a new set of tests to just test the connection close on wiz bulbs, add the assertion to the existing happy / non-happy path tests. --- tests/components/wiz/test_config_flow.py | 121 +++-------------------- 1 file changed, 15 insertions(+), 106 deletions(-) diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index 12fe80431a7d53..b58ba138f9fb00 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -47,6 +47,8 @@ async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" + bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -54,7 +56,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] == {} # Patch functions with ( - _patch_wizlight(), + _patch_wizlight(device=bulb), patch( "homeassistant.components.wiz.async_setup_entry", return_value=True, @@ -76,31 +78,6 @@ async def test_form(hass: HomeAssistant) -> None: } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_user_form_closes_connection(hass: HomeAssistant) -> None: - """Test the user flow closes the bulb connection after setup.""" - bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - _patch_wizlight(device=bulb), - patch( - "homeassistant.components.wiz.async_setup_entry", - return_value=True, - ), - patch("homeassistant.components.wiz.async_setup", return_value=True), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY bulb.async_close.assert_awaited_once() @@ -226,49 +203,6 @@ async def test_discovered_by_dhcp_connection_fails( bulb.async_close.assert_awaited_once() -@pytest.mark.parametrize( - ("source", "data"), - [ - (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), - (config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY), - ], -) -async def test_discovered_by_dhcp_or_integration_discovery_closes_connection( - hass: HomeAssistant, source, data -) -> None: - """Test discovery closes the bulb connection during confirmation.""" - bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) - - with _patch_wizlight(device=bulb): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data=data - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "discovery_confirm" - bulb.async_close.assert_awaited_once() - - bulb.async_close.reset_mock() - - with ( - _patch_wizlight(device=bulb), - patch( - "homeassistant.components.wiz.async_setup_entry", - return_value=True, - ), - patch("homeassistant.components.wiz.async_setup", return_value=True), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - bulb.async_close.assert_awaited_once() - - @pytest.mark.parametrize( ("source", "data", "bulb_type", "extended_white_range", "name"), [ @@ -334,9 +268,9 @@ async def test_discovered_by_dhcp_or_integration_discovery( hass: HomeAssistant, source, data, bulb_type, extended_white_range, name ) -> None: """Test we can configure when discovered from dhcp or discovery.""" - with _patch_wizlight( - device=None, extended_white_range=extended_white_range, bulb_type=bulb_type - ): + bulb = _mocked_wizlight(None, extended_white_range, bulb_type) + + with _patch_wizlight(device=bulb): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) @@ -344,11 +278,12 @@ async def test_discovered_by_dhcp_or_integration_discovery( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" + bulb.async_close.assert_awaited_once() + + bulb.async_close.reset_mock() with ( - _patch_wizlight( - device=None, extended_white_range=extended_white_range, bulb_type=bulb_type - ), + _patch_wizlight(device=bulb), patch( "homeassistant.components.wiz.async_setup_entry", return_value=True, @@ -370,6 +305,7 @@ async def test_discovered_by_dhcp_or_integration_discovery( } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + bulb.async_close.assert_awaited_once() @pytest.mark.parametrize( @@ -464,8 +400,10 @@ async def test_setup_via_discovery(hass: HomeAssistant) -> None: assert result2["step_id"] == "pick_device" assert not result2["errors"] + bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) + with ( - _patch_wizlight(), + _patch_wizlight(device=bulb), patch( "homeassistant.components.wiz.async_setup", return_value=True ) as mock_setup, @@ -486,6 +424,7 @@ async def test_setup_via_discovery(hass: HomeAssistant) -> None: } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + bulb.async_close.assert_awaited_once() # ignore configured devices result = await hass.config_entries.flow.async_init( @@ -503,36 +442,6 @@ async def test_setup_via_discovery(hass: HomeAssistant) -> None: assert result2["reason"] == "no_devices_found" -async def test_setup_via_discovery_closes_connection(hass: HomeAssistant) -> None: - """Test selecting a discovered device closes the bulb connection.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with _patch_discovery(): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pick_device" - - bulb = _mocked_wizlight(None, None, FAKE_DIMMABLE_BULB) - - with ( - _patch_wizlight(device=bulb), - patch("homeassistant.components.wiz.async_setup", return_value=True), - patch("homeassistant.components.wiz.async_setup_entry", return_value=True), - ): - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_DEVICE: FAKE_MAC}, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - bulb.async_close.assert_awaited_once() - - async def test_setup_via_discovery_cannot_connect(hass: HomeAssistant) -> None: """Test setting up via discovery and we fail to connect to the discovered device.""" result = await hass.config_entries.flow.async_init(