Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions homeassistant/components/wled/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,8 +17,11 @@
WLEDConfigEntry,
WLEDDataUpdateCoordinator,
WLEDReleasesDataUpdateCoordinator,
normalize_mac_address,
)

_LOGGER = logging.getLogger(__name__)

PLATFORMS = (
Platform.BUTTON,
Platform.LIGHT,
Expand Down Expand Up @@ -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
Comment thread
mik-laj marked this conversation as resolved.
Outdated
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 normalize_mac_address(entry.unique_id) == normalized_mac_address
]
ignored_entries = [
entry
for entry in duplicate_entries
if entry.entry_id != config_entry.entry_id
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),
)
await asyncio.gather(
*[
hass.config_entries.async_remove(entry.entry_id)
for entry in ignored_entries
]
)
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"
)
return False
Comment on lines +120 to +124

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would the other ones be? mDNS? Maybe disabled? Which ones are ones we don't know what to do with?

@mik-laj mik-laj Dec 5, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is with those entries that were created with an incorrect unique ID, e.g. AABBCCDDEEFF. The WLED API always returns a lowercase value, so I don't know where the invalid entries came from or how many there are. My guess is that if someone managed to get two non-ignored entries to work, one will likely have no entities, because entities always assign a unique ID based on the API data. This seems to me to be a very special edge case and it won't happen in practice now.


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
15 changes: 9 additions & 6 deletions homeassistant/components/wled/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@
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):
"""Handle a WLED config flow."""

VERSION = 1
MINOR_VERSION = 2
discovered_host: str
discovered_device: Device

Expand All @@ -53,9 +54,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(
Expand Down Expand Up @@ -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(mac)
await self.async_set_unique_id(normalize_mac_address(mac))
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info.host}
)
Expand All @@ -117,7 +117,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(
Expand Down
11 changes: 10 additions & 1 deletion homeassistant/components/wled/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(":", "").strip()


class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]):
"""Class to manage fetching WLED data from single endpoint."""

Expand All @@ -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 = normalize_mac_address(entry.unique_id)

super().__init__(
hass,
LOGGER,
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/components/wled/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def mock_config_entry() -> MockConfigEntry:
domain=DOMAIN,
data={CONF_HOST: "192.168.1.123"},
unique_id="aabbccddeeff",
minor_version=2,
)


Expand Down
7 changes: 6 additions & 1 deletion tests/components/wled/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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",
),
)
Expand Down
Loading
Loading