From 9ee7ed5cdb77b20cf3f185752ba516601dbee66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Thu, 27 Nov 2025 15:10:32 +0100 Subject: [PATCH] Fix MAC address mix-ups between WLED devices (#155491) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- homeassistant/components/wled/config_flow.py | 30 ++++++- homeassistant/components/wled/coordinator.py | 12 +++ homeassistant/components/wled/strings.json | 7 +- tests/components/wled/test_config_flow.py | 92 ++++++++++++++++++++ tests/components/wled/test_coordinator.py | 23 +++++ tests/components/wled/test_sensor.py | 29 +++++- 6 files changed, 190 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 5182d04bfdc..fb9967e321a 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -9,6 +9,7 @@ from wled import WLED, Device, WLEDConnectionError from homeassistant.components import onboarding from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -16,6 +17,7 @@ from homeassistant.config_entries import ( 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 @@ -52,6 +54,19 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( device.info.mac_address, raise_on_progress=False ) + if self.source == SOURCE_RECONFIGURE: + 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( + entry, + data_updates=user_input, + ) self._abort_if_unique_id_configured( updates={CONF_HOST: user_input[CONF_HOST]} ) @@ -61,13 +76,26 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): 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 {}, ) + 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 2ca460ee81f..fc84d508490 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -14,7 +14,9 @@ from wled import ( 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,16 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): translation_placeholders={"error": str(error)}, ) from error + if device.info.mac_address != self.config_entry.unique_id: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="mac_address_mismatch", + translation_placeholders={ + "expected_mac": format_mac(self.config_entry.unique_id).upper(), + "actual_mac": format_mac(device.info.mac_address).upper(), + }, + ) + # 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 6c4f93a088c..4cea5ef235f 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": "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%]" @@ -133,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": { diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 830ebf8a0f2..d3ed9ffa962 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -37,6 +37,98 @@ 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. + assert mock_config_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. + assert mock_config_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 e2935290f03..2460a887e19 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -14,6 +14,7 @@ from wled import ( ) 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 +196,25 @@ async def test_websocket_disconnect_on_home_assistant_stop( 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, + 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() + + 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 + ) diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index 8bd5431cf59..5b19967170b 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.core import HomeAssistant 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"