From 2c66613d15639614320f48c162f9e511fe7afff4 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 4 Dec 2025 00:31:03 +0000 Subject: [PATCH 01/16] Normalize unique ID in WLED --- homeassistant/components/wled/__init__.py | 69 ++++++++++++- homeassistant/components/wled/config_flow.py | 14 +-- homeassistant/components/wled/coordinator.py | 11 +- homeassistant/components/wled/strings.json | 10 ++ tests/components/wled/conftest.py | 1 + tests/components/wled/test_config_flow.py | 7 +- tests/components/wled/test_init.py | 103 +++++++++++++++++++ 7 files changed, 206 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index a854254a262e70..5cac6faeb2bc1e 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations +import logging + from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -13,6 +15,7 @@ WLEDConfigEntry, WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator, + normalize_mac_address, ) PLATFORMS = ( @@ -27,6 +30,9 @@ WLED_KEY: HassKey[WLEDReleasesDataUpdateCoordinator] = HassKey(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +UNIQUE_ID_COLLISION_TITLE_LIMIT = 5 + +_LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -41,8 +47,69 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _find_all_entries_with_duplicated_mac( + hass: HomeAssistant, entry: WLEDConfigEntry +) -> list[WLEDConfigEntry]: + """Find all WLED config entries with the same MAC address.""" + assert entry.unique_id + + normalized_mac = normalize_mac_address(entry.unique_id) + return [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.unique_id and normalize_mac_address(entry.unique_id) == normalized_mac + ] + + +def _handle_device_conflict(hass: HomeAssistant, entry: WLEDConfigEntry) -> None: + assert entry.unique_id + duplicated_entries = _find_all_entries_with_duplicated_mac(hass, entry) + + if len(duplicated_entries) > 1: + _LOGGER.error( + "Found %d WLED configuration entries with the same MAC address: %s", + len(duplicated_entries), + entry.unique_id, + ) + titles = [f"'{entry.title}'" for entry in duplicated_entries] + translation_placeholders = { + "configure_url": f"/config/integrations/integration/{DOMAIN}", + "unique_id": str(entry.unique_id), + } + if len(titles) <= UNIQUE_ID_COLLISION_TITLE_LIMIT: + translation_key = "config_entry_unique_id_collision" + translation_placeholders["titles"] = ", ".join(titles) + else: + translation_key = "config_entry_unique_id_collision_many" + translation_placeholders["number_of_entries"] = str(len(titles)) + translation_placeholders["titles"] = ", ".join( + titles[:UNIQUE_ID_COLLISION_TITLE_LIMIT] + ) + translation_placeholders["title_limit"] = str( + UNIQUE_ID_COLLISION_TITLE_LIMIT + ) + ir.async_create_issue( + hass, + DOMAIN, + f"device_conflict_{entry.entry_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + data={"entry_id": entry.entry_id}, + ) + else: + ir.async_delete_issue(hass, DOMAIN, f"device_conflict_{entry.entry_id}") + if entry.unique_id != normalize_mac_address(entry.unique_id): + hass.config_entries.async_update_entry( + entry, unique_id=normalize_mac_address(entry.unique_id) + ) + + async def async_setup_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool: """Set up WLED from a config entry.""" + _handle_device_conflict(hass, entry) + entry.runtime_data = WLEDDataUpdateCoordinator(hass, entry=entry) await entry.runtime_data.async_config_entry_first_refresh() diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 337d608ae11dab..dc89ec588af9bc 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN -from .coordinator import WLEDConfigEntry +from .coordinator import WLEDConfigEntry, normalize_mac_address class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): @@ -53,9 +53,8 @@ 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 - ) + mac_address = normalize_mac_address(device.info.mac_address) + await self.async_set_unique_id(mac_address, raise_on_progress=False) if self.source == SOURCE_RECONFIGURE: entry = self._get_reconfigure_entry() self._abort_if_unique_id_mismatch( @@ -104,7 +103,7 @@ async def async_step_zeroconf( """Handle zeroconf discovery.""" # Abort quick if the mac address is provided by discovery info if mac := discovery_info.properties.get(CONF_MAC): - await self.async_set_unique_id(mac) + await self.async_set_unique_id(normalize_mac_address(mac)) self._abort_if_unique_id_configured( updates={CONF_HOST: discovery_info.host} ) @@ -117,7 +116,10 @@ async def async_step_zeroconf( except WLEDConnectionError: return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id(self.discovered_device.info.mac_address) + device_mac_address = normalize_mac_address( + self.discovered_device.info.mac_address + ) + await self.async_set_unique_id(device_mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) self.context.update( diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index af3d1f583cb4dd..a0e76b5459a68e 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -32,6 +32,11 @@ type WLEDConfigEntry = ConfigEntry[WLEDDataUpdateCoordinator] +def normalize_mac_address(mac: str) -> str: + """Normalize a MAC address to lowercase without separators.""" + return mac.lower().replace(":", "").replace("-", "").replace(".", "") + + class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" @@ -51,6 +56,9 @@ def __init__( self.wled = WLED(entry.data[CONF_HOST], session=async_get_clientsession(hass)) self.unsub: CALLBACK_TYPE | None = None + assert entry.unique_id + self.config_mac_address = entry.unique_id + super().__init__( hass, LOGGER, @@ -131,7 +139,8 @@ async def _async_update_data(self) -> WLEDDevice: translation_placeholders={"error": str(error)}, ) from error - if device.info.mac_address != self.config_entry.unique_id: + device_mac_address = normalize_mac_address(device.info.mac_address) + if device_mac_address != self.config_mac_address: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="mac_address_mismatch", diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 9719406472e638..aa751fca72fe85 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -148,6 +148,16 @@ "message": "The WLED device's firmware version is not supported: {error}" } }, + "issues": { + "config_entry_unique_id_collision": { + "description": "There are multiple WLED config entries with the same device MAC addres.\nThe config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates.", + "title": "Multiple WLED config entries with same device MAC address" + }, + "config_entry_unique_id_collision_many": { + "description": "There are multiple ({number_of_entries}) WLED config entries with the same device MAC address.\nThe first {title_limit} config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates.", + "title": "[%key:component::wled::issues::config_entry_unique_id_collision::title%]" + } + }, "options": { "step": { "init": { diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index 301729843a2245..b4e74094657eb6 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -22,6 +22,7 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, data={CONF_HOST: "192.168.1.123"}, unique_id="aabbccddeeff", + title="Main WLED", ) diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 984b511be7a11d..1b6aea0543139c 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -281,12 +281,15 @@ async def test_zeroconf_unsupported_version_error( @pytest.mark.usefixtures("mock_wled") +@pytest.mark.parametrize("device_mac", ["aabbccddeeff", "AABBCCDDEEFF"]) async def test_user_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock, + device_mac: str, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" + mock_wled.update.return_value.info.mac_address = device_mac mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -323,10 +326,12 @@ async def test_zeroconf_without_mac_device_exists_abort( assert result.get("reason") == "already_configured" +@pytest.mark.parametrize("device_mac", ["aabbccddeeff", "AABBCCDDEEFF"]) async def test_zeroconf_with_mac_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock, + device_mac: str, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" mock_config_entry.add_to_hass(hass) @@ -339,7 +344,7 @@ async def test_zeroconf_with_mac_device_exists_abort( hostname="example.local.", name="mock_name", port=None, - properties={CONF_MAC: "aabbccddeeff"}, + properties={CONF_MAC: device_mac}, type="mock_type", ), ) diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 9dfcabd55e3f8f..19018ef92ce39c 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -7,8 +7,10 @@ import pytest from wled import WLEDConnectionError +from homeassistant.components.wled.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from tests.common import MockConfigEntry @@ -67,3 +69,104 @@ async def test_setting_unique_id( """Test we set unique ID if not set yet.""" assert init_integration.runtime_data assert init_integration.unique_id == "aabbccddeeff" + + +@pytest.mark.parametrize( + ( + "duplicated_entries_count", + "translation_key", + ), + [ + (2, "config_entry_unique_id_collision"), + (6, "config_entry_unique_id_collision_many"), + ], +) +async def test_handle_device_conflict_creates_issue_for_duplicates( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wled: MagicMock, + duplicated_entries_count: int, + translation_key: str, +) -> None: + """When there are multiple entries for the same MAC, create an issue.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.unique_id + + duplicated_entries = [] + for i in range(1, duplicated_entries_count): + new_entry = MockConfigEntry( + domain=DOMAIN, + title=f"Duplicate WLED #{i}", + unique_id=mock_config_entry.unique_id.upper(), + data=mock_config_entry.data, + ) + new_entry.add_to_hass(hass) + duplicated_entries.append(new_entry) + + issue_reg = ir.async_get(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + issue_id = f"device_conflict_{mock_config_entry.entry_id}" + assert (issue := issue_reg.async_get_issue(DOMAIN, issue_id)) + + assert issue.domain == DOMAIN + assert issue.is_fixable is False + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_key == translation_key + + # Check translation placeholders + assert (placeholders := issue.translation_placeholders) + assert placeholders["configure_url"] == "/config/integrations/integration/wled" + assert placeholders["unique_id"] == mock_config_entry.unique_id + + # Two titles should be listed in "titles" + titles = placeholders["titles"] + assert "'Main WLED'" in titles + assert "Duplicate WLED #1" in titles + + # When there is a conflict, we do not change the unique_id + assert mock_config_entry.unique_id == "aabbccddeeff" + assert duplicated_entries[0].unique_id == "AABBCCDDEEFF" + + +async def test_handle_device_conflict_normalizes_unique_id_without_duplicates( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wled: MagicMock, +) -> None: + """When only one entry exists, normalize MAC and delete any existing issue.""" + assert mock_config_entry.unique_id + + mock_config_entry.add_to_hass(hass) + + # Create a dummy issue to be removed + issue_id = f"device_conflict_{mock_config_entry.entry_id}" + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="config_entry_unique_id_collision", + translation_placeholders={ + "configure_url": f"/config/integrations/integration/{DOMAIN}", + "unique_id": mock_config_entry.unique_id, + "titles": "'Main WLED', 'Duplicate WLED'", + }, + data={}, + ) + + issue_reg = ir.async_get(hass) + + assert issue_reg.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Issue should be removed + assert issue_id not in issue_reg.issues + + # Unique_id should be normalized + assert mock_config_entry.unique_id == "aabbccddeeff" From 3d8b5797fc5964d2c608c1055c37a8392ae910a3 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 4 Dec 2025 00:44:53 +0000 Subject: [PATCH 02/16] fixup! Normalize unique ID in WLED --- homeassistant/components/wled/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index a0e76b5459a68e..4c9091108fbb67 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -57,7 +57,7 @@ def __init__( self.unsub: CALLBACK_TYPE | None = None assert entry.unique_id - self.config_mac_address = entry.unique_id + self.config_mac_address = normalize_mac_address(entry.unique_id) super().__init__( hass, From 163441ee4e526032cd62772ea74468ae159ac0b4 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 4 Dec 2025 00:53:57 +0000 Subject: [PATCH 03/16] fixup! fixup! Normalize unique ID in WLED --- homeassistant/components/wled/strings.json | 2 +- tests/components/wled/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index aa751fca72fe85..ff68ae7791078a 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -150,7 +150,7 @@ }, "issues": { "config_entry_unique_id_collision": { - "description": "There are multiple WLED config entries with the same device MAC addres.\nThe config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates.", + "description": "There are multiple WLED config entries with the same device MAC address.\nThe config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates.", "title": "Multiple WLED config entries with same device MAC address" }, "config_entry_unique_id_collision_many": { diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 19018ef92ce39c..641d4ac9a12784 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -124,7 +124,7 @@ async def test_handle_device_conflict_creates_issue_for_duplicates( # Two titles should be listed in "titles" titles = placeholders["titles"] assert "'Main WLED'" in titles - assert "Duplicate WLED #1" in titles + assert "`Duplicate WLED #1`" in titles # When there is a conflict, we do not change the unique_id assert mock_config_entry.unique_id == "aabbccddeeff" From 88de98dc4d6bef8cda1c43f92bae94ed7386e51b Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 4 Dec 2025 01:05:39 +0000 Subject: [PATCH 04/16] fixup! fixup! fixup! Normalize unique ID in WLED --- homeassistant/components/wled/__init__.py | 5 ++++- homeassistant/components/wled/coordinator.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 5cac6faeb2bc1e..e6071158368ede 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -4,6 +4,7 @@ import logging +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, issue_registry as ir @@ -57,7 +58,9 @@ def _find_all_entries_with_duplicated_mac( return [ entry for entry in hass.config_entries.async_entries(DOMAIN) - if entry.unique_id and normalize_mac_address(entry.unique_id) == normalized_mac + if entry.unique_id + and normalize_mac_address(entry.unique_id) == normalized_mac + and entry.source != SOURCE_IGNORE ] diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 4c9091108fbb67..b42f826d96efe0 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -34,7 +34,7 @@ def normalize_mac_address(mac: str) -> str: """Normalize a MAC address to lowercase without separators.""" - return mac.lower().replace(":", "").replace("-", "").replace(".", "") + return mac.lower().replace(":", "").replace("-", "").replace(".", "").strip() class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): From f305b0d08dce1063af8cc4a9db7110d29805e462 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 4 Dec 2025 01:53:26 +0000 Subject: [PATCH 05/16] fixup! fixup! fixup! fixup! Normalize unique ID in WLED --- tests/components/wled/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 641d4ac9a12784..9027c0471fcfb9 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -124,7 +124,7 @@ async def test_handle_device_conflict_creates_issue_for_duplicates( # Two titles should be listed in "titles" titles = placeholders["titles"] assert "'Main WLED'" in titles - assert "`Duplicate WLED #1`" in titles + assert "'Duplicate WLED #1'" in titles # When there is a conflict, we do not change the unique_id assert mock_config_entry.unique_id == "aabbccddeeff" From 16f4552d83c4fffa1e4eb94a855c8bc61722b3df Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:15:37 +0000 Subject: [PATCH 06/16] fixup! fixup! fixup! fixup! fixup! Normalize unique ID in WLED --- homeassistant/components/wled/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index b42f826d96efe0..93e71dcf4fa319 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -34,7 +34,7 @@ def normalize_mac_address(mac: str) -> str: """Normalize a MAC address to lowercase without separators.""" - return mac.lower().replace(":", "").replace("-", "").replace(".", "").strip() + return mac.lower().replace(":", "").strip() class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): From ccc5a4828194c4abef3ba8f64fea913f08327c43 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:26:18 +0000 Subject: [PATCH 07/16] Remove repair for unique ID --- homeassistant/components/wled/__init__.py | 72 +-------------- tests/components/wled/test_init.py | 103 ---------------------- 2 files changed, 1 insertion(+), 174 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index e6071158368ede..a854254a262e70 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -2,12 +2,9 @@ from __future__ import annotations -import logging - -from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -16,7 +13,6 @@ WLEDConfigEntry, WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator, - normalize_mac_address, ) PLATFORMS = ( @@ -31,9 +27,6 @@ WLED_KEY: HassKey[WLEDReleasesDataUpdateCoordinator] = HassKey(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -UNIQUE_ID_COLLISION_TITLE_LIMIT = 5 - -_LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -48,71 +41,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _find_all_entries_with_duplicated_mac( - hass: HomeAssistant, entry: WLEDConfigEntry -) -> list[WLEDConfigEntry]: - """Find all WLED config entries with the same MAC address.""" - assert entry.unique_id - - normalized_mac = normalize_mac_address(entry.unique_id) - return [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.unique_id - and normalize_mac_address(entry.unique_id) == normalized_mac - and entry.source != SOURCE_IGNORE - ] - - -def _handle_device_conflict(hass: HomeAssistant, entry: WLEDConfigEntry) -> None: - assert entry.unique_id - duplicated_entries = _find_all_entries_with_duplicated_mac(hass, entry) - - if len(duplicated_entries) > 1: - _LOGGER.error( - "Found %d WLED configuration entries with the same MAC address: %s", - len(duplicated_entries), - entry.unique_id, - ) - titles = [f"'{entry.title}'" for entry in duplicated_entries] - translation_placeholders = { - "configure_url": f"/config/integrations/integration/{DOMAIN}", - "unique_id": str(entry.unique_id), - } - if len(titles) <= UNIQUE_ID_COLLISION_TITLE_LIMIT: - translation_key = "config_entry_unique_id_collision" - translation_placeholders["titles"] = ", ".join(titles) - else: - translation_key = "config_entry_unique_id_collision_many" - translation_placeholders["number_of_entries"] = str(len(titles)) - translation_placeholders["titles"] = ", ".join( - titles[:UNIQUE_ID_COLLISION_TITLE_LIMIT] - ) - translation_placeholders["title_limit"] = str( - UNIQUE_ID_COLLISION_TITLE_LIMIT - ) - ir.async_create_issue( - hass, - DOMAIN, - f"device_conflict_{entry.entry_id}", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - data={"entry_id": entry.entry_id}, - ) - else: - ir.async_delete_issue(hass, DOMAIN, f"device_conflict_{entry.entry_id}") - if entry.unique_id != normalize_mac_address(entry.unique_id): - hass.config_entries.async_update_entry( - entry, unique_id=normalize_mac_address(entry.unique_id) - ) - - async def async_setup_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool: """Set up WLED from a config entry.""" - _handle_device_conflict(hass, entry) - entry.runtime_data = WLEDDataUpdateCoordinator(hass, entry=entry) await entry.runtime_data.async_config_entry_first_refresh() diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 9027c0471fcfb9..9dfcabd55e3f8f 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -7,10 +7,8 @@ import pytest from wled import WLEDConnectionError -from homeassistant.components.wled.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir from tests.common import MockConfigEntry @@ -69,104 +67,3 @@ async def test_setting_unique_id( """Test we set unique ID if not set yet.""" assert init_integration.runtime_data assert init_integration.unique_id == "aabbccddeeff" - - -@pytest.mark.parametrize( - ( - "duplicated_entries_count", - "translation_key", - ), - [ - (2, "config_entry_unique_id_collision"), - (6, "config_entry_unique_id_collision_many"), - ], -) -async def test_handle_device_conflict_creates_issue_for_duplicates( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_wled: MagicMock, - duplicated_entries_count: int, - translation_key: str, -) -> None: - """When there are multiple entries for the same MAC, create an issue.""" - mock_config_entry.add_to_hass(hass) - assert mock_config_entry.unique_id - - duplicated_entries = [] - for i in range(1, duplicated_entries_count): - new_entry = MockConfigEntry( - domain=DOMAIN, - title=f"Duplicate WLED #{i}", - unique_id=mock_config_entry.unique_id.upper(), - data=mock_config_entry.data, - ) - new_entry.add_to_hass(hass) - duplicated_entries.append(new_entry) - - issue_reg = ir.async_get(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - issue_id = f"device_conflict_{mock_config_entry.entry_id}" - assert (issue := issue_reg.async_get_issue(DOMAIN, issue_id)) - - assert issue.domain == DOMAIN - assert issue.is_fixable is False - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_key == translation_key - - # Check translation placeholders - assert (placeholders := issue.translation_placeholders) - assert placeholders["configure_url"] == "/config/integrations/integration/wled" - assert placeholders["unique_id"] == mock_config_entry.unique_id - - # Two titles should be listed in "titles" - titles = placeholders["titles"] - assert "'Main WLED'" in titles - assert "'Duplicate WLED #1'" in titles - - # When there is a conflict, we do not change the unique_id - assert mock_config_entry.unique_id == "aabbccddeeff" - assert duplicated_entries[0].unique_id == "AABBCCDDEEFF" - - -async def test_handle_device_conflict_normalizes_unique_id_without_duplicates( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_wled: MagicMock, -) -> None: - """When only one entry exists, normalize MAC and delete any existing issue.""" - assert mock_config_entry.unique_id - - mock_config_entry.add_to_hass(hass) - - # Create a dummy issue to be removed - issue_id = f"device_conflict_{mock_config_entry.entry_id}" - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="config_entry_unique_id_collision", - translation_placeholders={ - "configure_url": f"/config/integrations/integration/{DOMAIN}", - "unique_id": mock_config_entry.unique_id, - "titles": "'Main WLED', 'Duplicate WLED'", - }, - data={}, - ) - - issue_reg = ir.async_get(hass) - - assert issue_reg.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Issue should be removed - assert issue_id not in issue_reg.issues - - # Unique_id should be normalized - assert mock_config_entry.unique_id == "aabbccddeeff" From fc4c49258d67f1a244d57f969cd46c1ada636e7b Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:28:09 +0000 Subject: [PATCH 08/16] Code cleanup --- tests/components/wled/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index b4e74094657eb6..301729843a2245 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -22,7 +22,6 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, data={CONF_HOST: "192.168.1.123"}, unique_id="aabbccddeeff", - title="Main WLED", ) From b23dcda9c30695b4c42003bd98e38d9edabd2830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Fri, 5 Dec 2025 00:22:16 +0100 Subject: [PATCH 09/16] Clean up string.json --- homeassistant/components/wled/strings.json | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index ff68ae7791078a..9719406472e638 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -148,16 +148,6 @@ "message": "The WLED device's firmware version is not supported: {error}" } }, - "issues": { - "config_entry_unique_id_collision": { - "description": "There are multiple WLED config entries with the same device MAC address.\nThe config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates.", - "title": "Multiple WLED config entries with same device MAC address" - }, - "config_entry_unique_id_collision_many": { - "description": "There are multiple ({number_of_entries}) WLED config entries with the same device MAC address.\nThe first {title_limit} config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates.", - "title": "[%key:component::wled::issues::config_entry_unique_id_collision::title%]" - } - }, "options": { "step": { "init": { From 10f81898bcda7db41ae4abfde4c11a6010a63849 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Fri, 5 Dec 2025 00:10:56 +0000 Subject: [PATCH 10/16] Add migration to normalize unique ID --- homeassistant/components/wled/__init__.py | 74 ++++++++ homeassistant/components/wled/config_flow.py | 1 + tests/components/wled/conftest.py | 1 + tests/components/wled/test_init.py | 179 ++++++++++++++++++- 4 files changed, 254 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index a854254a262e70..a42d8ac31de07b 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -2,6 +2,10 @@ from __future__ import annotations +import asyncio +import logging + +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -13,8 +17,11 @@ WLEDConfigEntry, WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator, + normalize_mac_address, ) +_LOGGER = logging.getLogger(__name__) + PLATFORMS = ( Platform.BUTTON, Platform.LIGHT, @@ -63,3 +70,70 @@ async def async_unload_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> boo coordinator.unsub() return unload_ok + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: WLEDConfigEntry +) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # The user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + if config_entry.unique_id is None: + _LOGGER.warning( + "Config entry is missing unique ID, cannot migrate to version 1.2" + ) + return False + normalized_mac_address = normalize_mac_address(config_entry.unique_id) + other_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.unique_id + and normalize_mac_address(entry.unique_id) == normalized_mac_address + ] + ignored_entries = [ + entry + for entry in other_entries + if entry.entry_id != config_entry.entry_id + if entry.source == SOURCE_IGNORE + ] + if ignored_entries: + _LOGGER.info( + "Found %d ignored WLED config entries with the same MAC address, removing them", + len(ignored_entries), + ) + asyncio.gather( + *[ + hass.config_entries.async_remove(entry.entry_id) + for entry in ignored_entries + ] + ) + if len(other_entries) > len(ignored_entries) + 1: + _LOGGER.warning( + "Found multiple WLED config entries with the same MAC address, cannot migrate to version 1.2" + ) + return False + + hass.config_entries.async_update_entry( + config_entry, + unique_id=normalized_mac_address, + version=1, + minor_version=2, + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index dc89ec588af9bc..3db0b696c5c75a 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -28,6 +28,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a WLED config flow.""" VERSION = 1 + MINOR_VERSION = 2 discovered_host: str discovered_device: Device diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index 301729843a2245..32acd40c853420 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -22,6 +22,7 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, data={CONF_HOST: "192.168.1.123"}, unique_id="aabbccddeeff", + minor_version=2, ) diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 9dfcabd55e3f8f..d8254f2807d1ef 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -7,7 +7,9 @@ import pytest from wled import WLEDConnectionError -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.wled.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntryState +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -67,3 +69,178 @@ async def test_setting_unique_id( """Test we set unique ID if not set yet.""" assert init_integration.runtime_data assert init_integration.unique_id == "aabbccddeeff" + + +@pytest.fixture +def config_entry_v1() -> MockConfigEntry: + """Return a WLED config entry at version 1.0 with a specific MAC.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123"}, + unique_id="AABBCCDDEEFF", + minor_version=1, + ) + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_future_version_is_downgrade( + hass: HomeAssistant, +) -> None: + """Return False when user downgraded from a future version.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="WLED Future", + unique_id="AABBCCDDEEFF", + version=2, + minor_version=0, + data={CONF_HOST: "wled.local"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert entry.state == ConfigEntryState.MIGRATION_ERROR + assert entry.version == 2 + assert entry.minor_version == 0 + assert entry.unique_id == "AABBCCDDEEFF" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_v1_missing_unique_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Abort migration when unique_id is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="WLED No UID", + unique_id=None, + version=1, + minor_version=0, + data={"host": "wled.local"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert entry.state == ConfigEntryState.MIGRATION_ERROR + assert entry.version == 1 + assert entry.minor_version == 0 + assert "missing unique ID" in caplog.text + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_v1_to_1_2_no_duplicates( + hass: HomeAssistant, config_entry_v1: MockConfigEntry +) -> None: + """Migrate from 1.x to 1.2 when there are no other entries with same MAC.""" + config_entry_v1.add_to_hass(hass) + + result = await hass.config_entries.async_setup(config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert result is True + assert config_entry_v1.state == ConfigEntryState.LOADED + assert config_entry_v1.version == 1 + assert config_entry_v1.minor_version == 2 + assert config_entry_v1.unique_id == "aabbccddeeff" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_v1_with_ignored_duplicates( + hass: HomeAssistant, config_entry_v1: MockConfigEntry +) -> None: + """Remove ignored entries with the same MAC and then migrate.""" + config_entry_v1.add_to_hass(hass) + + ignored_1 = MockConfigEntry( + domain=DOMAIN, + title="Ignored 1", + unique_id="aabbccddeeff", + source=SOURCE_IGNORE, + version=1, + minor_version=0, + data={"host": "wled-ignored-1.local"}, + ) + ignored_2 = MockConfigEntry( + domain=DOMAIN, + title="Ignored 2", + unique_id="aabbccddeeff", + source=SOURCE_IGNORE, + version=1, + minor_version=0, + data={"host": "wled-ignored-2.local"}, + ) + + ignored_1.add_to_hass(hass) + ignored_2.add_to_hass(hass) + + result = await hass.config_entries.async_setup(config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert result is True + assert config_entry_v1.state == ConfigEntryState.LOADED + assert config_entry_v1.version == 1 + assert config_entry_v1.minor_version == 2 + assert config_entry_v1.unique_id == "aabbccddeeff" + + assert ignored_1.state is ConfigEntryState.NOT_LOADED + assert ignored_2.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_v1_with_non_ignored_duplicate_aborts( + hass: HomeAssistant, + config_entry_v1: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Abort migration when there is another non-ignored entry with the same MAC.""" + config_entry_v1.add_to_hass(hass) + + duplicate_active = MockConfigEntry( + domain=DOMAIN, + title="Active duplicate", + unique_id="aabbccddeeff", + version=1, + minor_version=0, + data={"host": "wled-duplicate.local"}, + ) + duplicate_active.add_to_hass(hass) + + result = await hass.config_entries.async_setup(config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry_v1.state == ConfigEntryState.MIGRATION_ERROR + assert config_entry_v1.version == 1 + assert config_entry_v1.minor_version == 1 + assert config_entry_v1.unique_id == "AABBCCDDEEFF" + assert "multiple WLED config entries with the same MAC address" in caplog.text + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_already_at_1_2_is_noop( + hass: HomeAssistant, +) -> None: + """Do nothing when entry is already at version 1.2.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="WLED Already 1.2", + unique_id="aabbccddeeff", + version=1, + minor_version=2, + data={"host": "wled.local"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert entry.state == ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "aabbccddeeff" From acae2fee593d9b05cf016ce3ef1043e562be519a Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Fri, 5 Dec 2025 00:14:01 +0000 Subject: [PATCH 11/16] fixup! Add migration to normalize unique ID --- homeassistant/components/wled/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index a42d8ac31de07b..83b4c9979a6a4b 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -94,7 +94,7 @@ async def async_migrate_entry( ) return False normalized_mac_address = normalize_mac_address(config_entry.unique_id) - other_entries = [ + duplicate_entries = [ entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id @@ -102,7 +102,7 @@ async def async_migrate_entry( ] ignored_entries = [ entry - for entry in other_entries + for entry in duplicate_entries if entry.entry_id != config_entry.entry_id if entry.source == SOURCE_IGNORE ] @@ -117,7 +117,7 @@ async def async_migrate_entry( for entry in ignored_entries ] ) - if len(other_entries) > len(ignored_entries) + 1: + if len(duplicate_entries) - len(ignored_entries) > 1: _LOGGER.warning( "Found multiple WLED config entries with the same MAC address, cannot migrate to version 1.2" ) From dbe9cc07410e3767dabd5ff50ffc7b6f2dda0dc4 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Fri, 5 Dec 2025 00:49:59 +0000 Subject: [PATCH 12/16] fixup! fixup! Add migration to normalize unique ID --- homeassistant/components/wled/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 83b4c9979a6a4b..39238b30abc28c 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -104,14 +104,14 @@ async def async_migrate_entry( entry for entry in duplicate_entries if entry.entry_id != config_entry.entry_id - if entry.source == SOURCE_IGNORE + and entry.source == SOURCE_IGNORE ] if ignored_entries: _LOGGER.info( "Found %d ignored WLED config entries with the same MAC address, removing them", len(ignored_entries), ) - asyncio.gather( + await asyncio.gather( *[ hass.config_entries.async_remove(entry.entry_id) for entry in ignored_entries From 57b3f421f21c8784ccb0eae6624579522a325350 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:16:13 +0000 Subject: [PATCH 13/16] fixup! fixup! fixup! Add migration to normalize unique ID --- homeassistant/components/wled/__init__.py | 6 +++--- homeassistant/components/wled/config_flow.py | 12 +++++------- homeassistant/components/wled/coordinator.py | 18 ++++++++---------- tests/components/wled/conftest.py | 2 +- tests/components/wled/test_config_flow.py | 6 +++--- tests/components/wled/test_init.py | 12 ++---------- 6 files changed, 22 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 39238b30abc28c..7d92161d4e9b58 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -9,6 +9,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -17,7 +18,6 @@ WLEDConfigEntry, WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator, - normalize_mac_address, ) _LOGGER = logging.getLogger(__name__) @@ -93,12 +93,12 @@ async def async_migrate_entry( "Config entry is missing unique ID, cannot migrate to version 1.2" ) return False - normalized_mac_address = normalize_mac_address(config_entry.unique_id) + normalized_mac_address = format_mac(config_entry.unique_id) duplicate_entries = [ entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id - and normalize_mac_address(entry.unique_id) == normalized_mac_address + and format_mac(entry.unique_id) == normalized_mac_address ] ignored_entries = [ entry diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 3db0b696c5c75a..afba3a0067a8aa 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN -from .coordinator import WLEDConfigEntry, normalize_mac_address +from .coordinator import WLEDConfigEntry class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): @@ -54,7 +54,7 @@ async def async_step_user( except WLEDConnectionError: errors["base"] = "cannot_connect" else: - mac_address = normalize_mac_address(device.info.mac_address) + mac_address = format_mac(device.info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) if self.source == SOURCE_RECONFIGURE: entry = self._get_reconfigure_entry() @@ -62,7 +62,7 @@ async def async_step_user( reason="unique_id_mismatch", description_placeholders={ "expected_mac": format_mac(entry.unique_id).upper(), - "actual_mac": format_mac(self.unique_id).upper(), + "actual_mac": mac_address.upper(), }, ) return self.async_update_reload_and_abort( @@ -104,7 +104,7 @@ async def async_step_zeroconf( """Handle zeroconf discovery.""" # Abort quick if the mac address is provided by discovery info if mac := discovery_info.properties.get(CONF_MAC): - await self.async_set_unique_id(normalize_mac_address(mac)) + await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured( updates={CONF_HOST: discovery_info.host} ) @@ -117,9 +117,7 @@ async def async_step_zeroconf( except WLEDConnectionError: return self.async_abort(reason="cannot_connect") - device_mac_address = normalize_mac_address( - self.discovered_device.info.mac_address - ) + device_mac_address = format_mac(self.discovered_device.info.mac_address) await self.async_set_unique_id(device_mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 93e71dcf4fa319..3777145274c586 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from wled import ( WLED, Device as WLEDDevice, @@ -32,11 +34,6 @@ type WLEDConfigEntry = ConfigEntry[WLEDDataUpdateCoordinator] -def normalize_mac_address(mac: str) -> str: - """Normalize a MAC address to lowercase without separators.""" - return mac.lower().replace(":", "").strip() - - class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" @@ -56,8 +53,9 @@ def __init__( self.wled = WLED(entry.data[CONF_HOST], session=async_get_clientsession(hass)) self.unsub: CALLBACK_TYPE | None = None - assert entry.unique_id - self.config_mac_address = normalize_mac_address(entry.unique_id) + if TYPE_CHECKING: + assert entry.unique_id + self.config_mac_address = format_mac(entry.unique_id) super().__init__( hass, @@ -139,14 +137,14 @@ async def _async_update_data(self) -> WLEDDevice: translation_placeholders={"error": str(error)}, ) from error - device_mac_address = normalize_mac_address(device.info.mac_address) + device_mac_address = format_mac(device.info.mac_address) if device_mac_address != self.config_mac_address: 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(), + "expected_mac": self.config_mac_address.upper(), + "actual_mac": device_mac_address.upper(), }, ) diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index 32acd40c853420..0a49982f7d20bf 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -21,7 +21,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "192.168.1.123"}, - unique_id="aabbccddeeff", + unique_id="aa:bb:cc:dd:ee:ff", minor_version=2, ) diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 1b6aea0543139c..ceda9b50fdd9a8 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -34,7 +34,7 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: assert result.get("title") == "WLED RGB Light" assert result.get("type") is FlowResultType.CREATE_ENTRY assert result["data"][CONF_HOST] == "192.168.1.123" - assert result["result"].unique_id == "aabbccddeeff" + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") @@ -166,7 +166,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: assert "data" in result2 assert result2["data"][CONF_HOST] == "192.168.1.123" assert "result" in result2 - assert result2["result"].unique_id == "aabbccddeeff" + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" @pytest.mark.usefixtures("mock_wled") @@ -195,7 +195,7 @@ async def test_zeroconf_during_onboarding( assert result.get("data") == {CONF_HOST: "192.168.1.123"} assert "result" in result - assert result["result"].unique_id == "aabbccddeeff" + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index d8254f2807d1ef..9577bef0d78893 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -63,14 +63,6 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_setting_unique_id( - hass: HomeAssistant, init_integration: MockConfigEntry -) -> None: - """Test we set unique ID if not set yet.""" - assert init_integration.runtime_data - assert init_integration.unique_id == "aabbccddeeff" - - @pytest.fixture def config_entry_v1() -> MockConfigEntry: """Return a WLED config entry at version 1.0 with a specific MAC.""" @@ -146,7 +138,7 @@ async def test_migrate_entry_v1_to_1_2_no_duplicates( assert config_entry_v1.state == ConfigEntryState.LOADED assert config_entry_v1.version == 1 assert config_entry_v1.minor_version == 2 - assert config_entry_v1.unique_id == "aabbccddeeff" + assert config_entry_v1.unique_id == "aa:bb:cc:dd:ee:ff" @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") @@ -185,7 +177,7 @@ async def test_migrate_entry_v1_with_ignored_duplicates( assert config_entry_v1.state == ConfigEntryState.LOADED assert config_entry_v1.version == 1 assert config_entry_v1.minor_version == 2 - assert config_entry_v1.unique_id == "aabbccddeeff" + assert config_entry_v1.unique_id == "aa:bb:cc:dd:ee:ff" assert ignored_1.state is ConfigEntryState.NOT_LOADED assert ignored_2.state is ConfigEntryState.NOT_LOADED From 5a3b79ecc446c1a178b2e2cfbfd14369afc772da Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Sat, 6 Dec 2025 01:21:20 +0000 Subject: [PATCH 14/16] fixup! fixup! fixup! fixup! Add migration to normalize unique ID --- homeassistant/components/wled/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 7d92161d4e9b58..5b92e5095eda53 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -4,6 +4,7 @@ import asyncio import logging +from typing import TYPE_CHECKING from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import Platform @@ -88,11 +89,8 @@ async def async_migrate_entry( if config_entry.version == 1: if config_entry.minor_version < 2: - if config_entry.unique_id is None: - _LOGGER.warning( - "Config entry is missing unique ID, cannot migrate to version 1.2" - ) - return False + if TYPE_CHECKING: + assert config_entry.unique_id normalized_mac_address = format_mac(config_entry.unique_id) duplicate_entries = [ entry From 30c190619b79f14617d272525fa556a10a6805cf Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Wed, 10 Dec 2025 19:05:43 +0000 Subject: [PATCH 15/16] fixup! fixup! fixup! fixup! fixup! Add migration to normalize unique ID --- homeassistant/components/wled/__init__.py | 6 ++-- homeassistant/components/wled/config_flow.py | 10 ++++--- homeassistant/components/wled/coordinator.py | 19 ++++++++++--- tests/components/wled/conftest.py | 2 +- tests/components/wled/test_config_flow.py | 6 ++-- tests/components/wled/test_init.py | 29 ++------------------ 6 files changed, 30 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 5b92e5095eda53..07a4295dfcadb3 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -10,7 +10,6 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -19,6 +18,7 @@ WLEDConfigEntry, WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator, + normalize_mac_address, ) _LOGGER = logging.getLogger(__name__) @@ -91,12 +91,12 @@ async def async_migrate_entry( if config_entry.minor_version < 2: if TYPE_CHECKING: assert config_entry.unique_id - normalized_mac_address = format_mac(config_entry.unique_id) + normalized_mac_address = normalize_mac_address(config_entry.unique_id) duplicate_entries = [ entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id - and format_mac(entry.unique_id) == normalized_mac_address + and normalize_mac_address(entry.unique_id) == normalized_mac_address ] ignored_entries = [ entry diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index afba3a0067a8aa..5adb7f126d184f 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN -from .coordinator import WLEDConfigEntry +from .coordinator import WLEDConfigEntry, normalize_mac_address class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): @@ -54,7 +54,7 @@ async def async_step_user( except WLEDConnectionError: errors["base"] = "cannot_connect" else: - mac_address = format_mac(device.info.mac_address) + mac_address = normalize_mac_address(device.info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) if self.source == SOURCE_RECONFIGURE: entry = self._get_reconfigure_entry() @@ -104,7 +104,7 @@ async def async_step_zeroconf( """Handle zeroconf discovery.""" # Abort quick if the mac address is provided by discovery info if mac := discovery_info.properties.get(CONF_MAC): - await self.async_set_unique_id(format_mac(mac)) + await self.async_set_unique_id(normalize_mac_address(mac)) self._abort_if_unique_id_configured( updates={CONF_HOST: discovery_info.host} ) @@ -117,7 +117,9 @@ async def async_step_zeroconf( except WLEDConnectionError: return self.async_abort(reason="cannot_connect") - device_mac_address = format_mac(self.discovered_device.info.mac_address) + device_mac_address = normalize_mac_address( + self.discovered_device.info.mac_address + ) await self.async_set_unique_id(device_mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 3777145274c586..eb876985c57412 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -34,6 +34,17 @@ type WLEDConfigEntry = ConfigEntry[WLEDDataUpdateCoordinator] +def normalize_mac_address(mac: str) -> str: + """Normalize a MAC address to lowercase without separators. + + This format is used by WLED firmware as well as unique IDs in Home Assistant. + + The homeassistant.helpers.device_registry.format_mac function is preferred but + returns MAC addresses with colons as separators. + """ + return mac.lower().replace(":", "").replace(".", "").replace("-", "").strip() + + class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" @@ -55,7 +66,7 @@ def __init__( if TYPE_CHECKING: assert entry.unique_id - self.config_mac_address = format_mac(entry.unique_id) + self.config_mac_address = normalize_mac_address(entry.unique_id) super().__init__( hass, @@ -137,14 +148,14 @@ async def _async_update_data(self) -> WLEDDevice: translation_placeholders={"error": str(error)}, ) from error - device_mac_address = format_mac(device.info.mac_address) + device_mac_address = normalize_mac_address(device.info.mac_address) if device_mac_address != self.config_mac_address: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="mac_address_mismatch", translation_placeholders={ - "expected_mac": self.config_mac_address.upper(), - "actual_mac": device_mac_address.upper(), + "expected_mac": format_mac(self.config_mac_address).upper(), + "actual_mac": format_mac(device_mac_address).upper(), }, ) diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index 0a49982f7d20bf..32acd40c853420 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -21,7 +21,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "192.168.1.123"}, - unique_id="aa:bb:cc:dd:ee:ff", + unique_id="aabbccddeeff", minor_version=2, ) diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index ceda9b50fdd9a8..1b6aea0543139c 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -34,7 +34,7 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: assert result.get("title") == "WLED RGB Light" assert result.get("type") is FlowResultType.CREATE_ENTRY assert result["data"][CONF_HOST] == "192.168.1.123" - assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + assert result["result"].unique_id == "aabbccddeeff" @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") @@ -166,7 +166,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: assert "data" in result2 assert result2["data"][CONF_HOST] == "192.168.1.123" assert "result" in result2 - assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + assert result2["result"].unique_id == "aabbccddeeff" @pytest.mark.usefixtures("mock_wled") @@ -195,7 +195,7 @@ async def test_zeroconf_during_onboarding( assert result.get("data") == {CONF_HOST: "192.168.1.123"} assert "result" in result - assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + assert result["result"].unique_id == "aabbccddeeff" assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 9577bef0d78893..25d2ee3530d2c8 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -99,31 +99,6 @@ async def test_migrate_entry_future_version_is_downgrade( assert entry.unique_id == "AABBCCDDEEFF" -@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") -async def test_migrate_entry_v1_missing_unique_id( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Abort migration when unique_id is missing.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="WLED No UID", - unique_id=None, - version=1, - minor_version=0, - data={"host": "wled.local"}, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert result is False - assert entry.state == ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.minor_version == 0 - assert "missing unique ID" in caplog.text - - @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") async def test_migrate_entry_v1_to_1_2_no_duplicates( hass: HomeAssistant, config_entry_v1: MockConfigEntry @@ -138,7 +113,7 @@ async def test_migrate_entry_v1_to_1_2_no_duplicates( assert config_entry_v1.state == ConfigEntryState.LOADED assert config_entry_v1.version == 1 assert config_entry_v1.minor_version == 2 - assert config_entry_v1.unique_id == "aa:bb:cc:dd:ee:ff" + assert config_entry_v1.unique_id == "aabbccddeeff" @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") @@ -177,7 +152,7 @@ async def test_migrate_entry_v1_with_ignored_duplicates( assert config_entry_v1.state == ConfigEntryState.LOADED assert config_entry_v1.version == 1 assert config_entry_v1.minor_version == 2 - assert config_entry_v1.unique_id == "aa:bb:cc:dd:ee:ff" + assert config_entry_v1.unique_id == "aabbccddeeff" assert ignored_1.state is ConfigEntryState.NOT_LOADED assert ignored_2.state is ConfigEntryState.NOT_LOADED From 85ed1986f78f70cb1878fb79fc6037dd2b3b9ce0 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Wed, 10 Dec 2025 19:09:18 +0000 Subject: [PATCH 16/16] fixup! fixup! fixup! fixup! fixup! fixup! Add migration to normalize unique ID --- homeassistant/components/wled/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 07a4295dfcadb3..945b68a74cf66e 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -89,6 +89,8 @@ async def async_migrate_entry( if config_entry.version == 1: if config_entry.minor_version < 2: + # 1.2: Normalize unique ID to be lowercase MAC address without separators. + # This matches the format used by WLED firmware. if TYPE_CHECKING: assert config_entry.unique_id normalized_mac_address = normalize_mac_address(config_entry.unique_id)