From 79654b2ca79c38d108df7f4880990e6fe1a5d9ab Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:13:32 +0000 Subject: [PATCH 1/7] Add reconfiguration flow for WLED --- homeassistant/components/wled/config_flow.py | 14 +++ homeassistant/components/wled/coordinator.py | 9 ++ homeassistant/components/wled/strings.json | 4 +- tests/components/wled/test_config_flow.py | 96 ++++++++++++++++++++ tests/components/wled/test_coordinator.py | 25 +++++ tests/components/wled/test_sensor.py | 29 +++++- 6 files changed, 175 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 5182d04bfdca6d..65ba0c2b439d34 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -9,6 +9,7 @@ from homeassistant.components import onboarding from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -49,6 +50,13 @@ async def async_step_user( except WLEDConnectionError: errors["base"] = "cannot_connect" else: + if self.source == SOURCE_RECONFIGURE: + await self.async_set_unique_id(device.info.mac_address) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) await self.async_set_unique_id( device.info.mac_address, raise_on_progress=False ) @@ -68,6 +76,12 @@ async def async_step_user( errors=errors or {}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for WLED entry.""" + return await self.async_step_user(user_input) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 2ca460ee81ffda..e8fb319b08f6d3 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -14,7 +14,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -120,6 +122,13 @@ async def _async_update_data(self) -> WLEDDevice: translation_placeholders={"error": str(error)}, ) from error + if device.info.mac_address != self.config_entry.unique_id: + raise ConfigEntryError( + f"MAC address mismatch. " + f"Expected to connect to device with MAC: {format_mac(self.config_entry.unique_id)}, " + f"but connected to device with MAC: {format_mac(device.info.mac_address)}" + ) + # If the device supports a WebSocket, try activating it. if ( device.info.websocket is not None diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 6c4f93a088ce5f..68e1efcda49ed6 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 15db188af5eeee..32c50528f48e49 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -37,6 +37,102 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: assert result["result"].unique_id == "aabbccddeeff" +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_full_reconfigure_flow_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the full reconfigure flow from start to finish.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + # Assert show form initially + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "10.10.0.10"} + ) + + # Assert show text message and close flow + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + # Assert config entry has been updated. + new_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert new_entry is not None + assert new_entry.data[CONF_HOST] == "10.10.0.10" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_full_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test reconfiguration failure when the unique ID changes.""" + mock_config_entry.add_to_hass(hass) + + # Change mac address + device = mock_wled.update.return_value + device.info.mac_address = "invalid" + + result = await mock_config_entry.start_reconfigure_flow(hass) + + # Assert show form initially + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + # Input new host value + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "10.10.0.10"} + ) + + # Assert Show text message and close flow + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unique_id_mismatch" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_full_reconfigure_flow_connection_error_and_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test we show user form on WLED connection error and allows user to change host.""" + mock_config_entry.add_to_hass(hass) + + # Mock connection error + mock_wled.update.side_effect = WLEDConnectionError + + result = await mock_config_entry.start_reconfigure_flow(hass) + + # Assert show form initially + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + # Input new host value + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "10.10.0.10"} + ) + + # Assert form with errors + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + # Remove mock for connection error + mock_wled.update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "10.10.0.10"} + ) + + # Assert show text message and close flow + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + # Assert config entry has been updated. + new_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert new_entry is not None + assert new_entry.data[CONF_HOST] == "10.10.0.10" + + @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: """Test the full manual user flow from start to finish.""" diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index e2935290f03740..0141093c0b2ae4 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -5,6 +5,7 @@ from copy import deepcopy from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from wled import ( Device as WLEDDevice, @@ -14,6 +15,7 @@ ) from homeassistant.components.wled.const import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_OFF, @@ -195,3 +197,26 @@ async def connect(callback: Callable[[WLEDDevice], None]): await hass.async_block_till_done() await hass.async_block_till_done() assert mock_wled.disconnect.call_count == 2 + + +async def test_fail_when_other_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_wled: MagicMock, +) -> None: + """Ensure entry fails to setup when mac mismatch.""" + device = mock_wled.update.return_value + device.info.mac_address = "invalid" + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry is not None + assert entry.state == ConfigEntryState.SETUP_ERROR + assert entry.reason + assert "MAC address mismatch." in entry.reason diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index 8bd5431cf592f1..5b19967170be1a 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -3,9 +3,11 @@ from datetime import datetime from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.wled.const import SCAN_INTERVAL from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -21,7 +23,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_wled") @@ -189,3 +191,28 @@ async def test_no_current_measurement( assert hass.states.get("sensor.wled_rgb_light_max_current") is None assert hass.states.get("sensor.wled_rgb_light_estimated_current") is None + + +async def test_fail_when_other_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_wled: MagicMock, +) -> None: + """Ensure no data are updated when mac address mismatch.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.wled_rgb_light_ip")) + assert state.state == "127.0.0.1" + + device = mock_wled.update.return_value + device.info.mac_address = "invalid" + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.wled_rgb_light_ip")) + assert state.state == "unavailable" From 48ef15e3fd3ef771ca8d8af4a14d02de9a8b0561 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:30:21 +0000 Subject: [PATCH 2/7] fixup! Add reconfiguration flow for WLED --- homeassistant/components/wled/coordinator.py | 9 ++++++--- homeassistant/components/wled/strings.json | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index e8fb319b08f6d3..609d02241b7eb7 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -124,9 +124,12 @@ async def _async_update_data(self) -> WLEDDevice: if device.info.mac_address != self.config_entry.unique_id: raise ConfigEntryError( - f"MAC address mismatch. " - f"Expected to connect to device with MAC: {format_mac(self.config_entry.unique_id)}, " - f"but connected to device with MAC: {format_mac(device.info.mac_address)}" + translation_domain=DOMAIN, + translation_key="mac_address_mismatch", + translation_placeholders={ + "expected_mac": format_mac(self.config_entry.unique_id), + "actual_mac": format_mac(device.info.mac_address), + }, ) # If the device supports a WebSocket, try activating it. diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 68e1efcda49ed6..a91d672a25ad32 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -135,6 +135,9 @@ }, "invalid_response_wled_error": { "message": "Invalid response from WLED API: {error}" + }, + "mac_address_mismatch": { + "message": "MAC address does not match the configured device. Expected to connect to device with MAC: {expected_mac}, but connected to device with MAC: {actual_mac}." } }, "options": { From 78f00046e40ab9ffabbe39222616305cbc38036d Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:36:18 +0000 Subject: [PATCH 3/7] fixup! fixup! Add reconfiguration flow for WLED --- tests/components/wled/test_coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 0141093c0b2ae4..e38e5897bc0a75 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -219,4 +219,4 @@ async def test_fail_when_other_device( assert entry is not None assert entry.state == ConfigEntryState.SETUP_ERROR assert entry.reason - assert "MAC address mismatch." in entry.reason + assert "MAC address does not match the configured device." in entry.reason From 4b534244e76c4dc66e3c533446d5a622d70667c5 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Sun, 23 Nov 2025 22:57:44 +0000 Subject: [PATCH 4/7] Improve error message --- homeassistant/components/wled/config_flow.py | 19 +++++++++++++------ homeassistant/components/wled/coordinator.py | 4 ++-- homeassistant/components/wled/strings.json | 2 +- tests/components/wled/test_coordinator.py | 2 -- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 65ba0c2b439d34..db700d900d2572 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN @@ -50,16 +51,22 @@ async def async_step_user( except WLEDConnectionError: errors["base"] = "cannot_connect" else: + await self.async_set_unique_id( + device.info.mac_address, raise_on_progress=False + ) if self.source == SOURCE_RECONFIGURE: - await self.async_set_unique_id(device.info.mac_address) - self._abort_if_unique_id_mismatch() + entry = self._get_reconfigure_entry() + self._abort_if_unique_id_mismatch( + reason="unique_id_mismatch", + description_placeholders={ + "expected_mac": format_mac(entry.unique_id).upper(), + "actual_mac": format_mac(self.unique_id).upper(), + }, + ) return self.async_update_reload_and_abort( - self._get_reconfigure_entry(), + entry, data_updates=user_input, ) - await self.async_set_unique_id( - device.info.mac_address, raise_on_progress=False - ) self._abort_if_unique_id_configured( updates={CONF_HOST: user_input[CONF_HOST]} ) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 609d02241b7eb7..fc84d5084909b8 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -127,8 +127,8 @@ async def _async_update_data(self) -> WLEDDevice: translation_domain=DOMAIN, translation_key="mac_address_mismatch", translation_placeholders={ - "expected_mac": format_mac(self.config_entry.unique_id), - "actual_mac": format_mac(device.info.mac_address), + "expected_mac": format_mac(self.config_entry.unique_id).upper(), + "actual_mac": format_mac(device.info.mac_address).upper(), }, ) diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index a91d672a25ad32..4cea5ef235f2b7 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "unique_id_mismatch": "Please ensure you reconfigure against the same device." + "unique_id_mismatch": "MAC address does not match the configured device. Expected to connect to device with MAC: `{expected_mac}`, but connected to device with MAC: `{actual_mac}`. \n\nPlease ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index e38e5897bc0a75..1b520d9df941c0 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -5,7 +5,6 @@ from copy import deepcopy from unittest.mock import MagicMock -from freezegun.api import FrozenDateTimeFactory import pytest from wled import ( Device as WLEDDevice, @@ -202,7 +201,6 @@ async def connect(callback: Callable[[WLEDDevice], None]): async def test_fail_when_other_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Ensure entry fails to setup when mac mismatch.""" From 8d97e3da000197a652197e3e27d27d2f637d32a9 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Sun, 23 Nov 2025 23:26:19 +0000 Subject: [PATCH 5/7] Display current host in reconfiguration form --- homeassistant/components/wled/config_flow.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index db700d900d2572..fb9967e321a227 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -76,10 +76,17 @@ async def async_step_user( CONF_HOST: user_input[CONF_HOST], }, ) + data_schema = vol.Schema({vol.Required(CONF_HOST): str}) + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + data_schema = self.add_suggested_values_to_schema( + data_schema, + entry.data, + ) return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + data_schema=data_schema, errors=errors or {}, ) From 595c1a0adec131bd3f3b3a0ffb9951c3b58ead44 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Nov 2025 14:58:41 +0100 Subject: [PATCH 6/7] Apply suggestions from code review --- tests/components/wled/test_config_flow.py | 8 ++------ tests/components/wled/test_coordinator.py | 8 +++----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 7aff58927cf5df..d3ed9ffa962213 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -58,9 +58,7 @@ async def test_full_reconfigure_flow_success( assert result.get("reason") == "reconfigure_successful" # Assert config entry has been updated. - new_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) - assert new_entry is not None - assert new_entry.data[CONF_HOST] == "10.10.0.10" + assert mock_config_entry.data[CONF_HOST] == "10.10.0.10" @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") @@ -128,9 +126,7 @@ async def test_full_reconfigure_flow_connection_error_and_success( assert result.get("reason") == "reconfigure_successful" # Assert config entry has been updated. - new_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) - assert new_entry is not None - assert new_entry.data[CONF_HOST] == "10.10.0.10" + assert mock_config_entry.data[CONF_HOST] == "10.10.0.10" @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 1b520d9df941c0..72318cd43776a7 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -213,8 +213,6 @@ async def test_fail_when_other_device( await hass.async_block_till_done() - entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) - assert entry is not None - assert entry.state == ConfigEntryState.SETUP_ERROR - assert entry.reason - assert "MAC address does not match the configured device." in entry.reason + assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR + assert mock_config_entry.reason + assert "MAC address does not match the configured device." in mock_config_entry.reason From fb1d0b654afa52d094d620a9fa1bc0925535a058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Thu, 27 Nov 2025 15:03:19 +0100 Subject: [PATCH 7/7] Update tests/components/wled/test_coordinator.py --- tests/components/wled/test_coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 72318cd43776a7..2460a887e19165 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -215,4 +215,6 @@ async def test_fail_when_other_device( assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR assert mock_config_entry.reason - assert "MAC address does not match the configured device." in mock_config_entry.reason + assert ( + "MAC address does not match the configured device." in mock_config_entry.reason + )