Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 20 additions & 6 deletions homeassistant/components/wled/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
WLEDDataUpdateCoordinator,
WLEDReleasesDataUpdateCoordinator,
normalize_mac_address,
normalize_repo,
)

_LOGGER = logging.getLogger(__name__)
Expand All @@ -31,22 +32,35 @@
Platform.UPDATE,
)

WLED_KEY: HassKey[WLEDReleasesDataUpdateCoordinator] = HassKey(DOMAIN)
WLED_KEY: HassKey[dict[str, WLEDReleasesDataUpdateCoordinator]] = HassKey(DOMAIN)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the WLED integration.

We set up a single coordinator for fetching WLED releases, which
is used across all WLED devices (and config entries) to avoid
fetching the same data multiple times for each.
Release coordinators are created lazily and cached per repository so we only
fetch a given repo once across all WLED devices.
"""
hass.data[WLED_KEY] = WLEDReleasesDataUpdateCoordinator(hass)
await hass.data[WLED_KEY].async_request_refresh()
hass.data[WLED_KEY] = {}
return True


async def async_get_releases_coordinator(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a bit problematic because this coordinator is never stopped when a user stops using the fork.

I'm wondering if it would be possible to update the coordinator to check which repositories the user is using and download data for all the repositories the user is currently using.

What do you think about this?

@mik-laj mik-laj May 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Another solution is to register the repository in the release coordinator in async_setup_entry and then unregister it in the async_unload_entry function.

hass.data[WLED_KEY].register_repo(entry_id, coordinator.data.info.repo)
hass.data[WLED_KEY].unregister_repo(entry_id)

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.

As I see it though, the release coordinator remains in memory, yes (and we could definitely prune that) - but we're not actively polling the repo there. So if you add a device A, with repo A - and remove device A. Then repo A remains, but is not polled. The DataUpdateCoordinator base operates with listeners (each update entity listens), and when the last listener disappears, which it hopefully does, DataUpdateCoordinator stops polling.

I'll see if we can't clean out the dict regardless.

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.

Are you on to making WLEDReleasesDataUpdateCoordinator able to manage multiple repositories in one instance?

@LordMike LordMike May 28, 2026

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.

So I looked into blindly going for entry setup/teardown, but it would introduce extra bookkeeping. On entry_setup ( a single device), we create the coordinator if not existing. But on teardown, we'd have to start reviewing if the coordinator is still in use.

It seems unlikely to be a problem, leaving hte coordinator around after the device has disappeared. It only lasts till next restart of the HA instance. And it really is only 1 instance per unique repository - so in practice its like.. at most 2..

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.

Hmm, a drawback here is that:

  • there is one release coordinator, and it makes N requests on updates - if any 1/N requests fail, the entire check fails
  • if an update entity forces a recheck, it will recheck all repos

I'm partial to the one instance per repo instead, keep it lean.. but then try to solve for cleanup - either the temporary bit like disabling the update entity, or the more permanent bit that is removing a WLED device (entry) entirely. It could be solved by a set of entry_ids in each release coordinator, and when that set is empty, we remove it.

The release coordinator already tracks subscriptions, which each update entity uses. We could in our WLED release coordinator, expose the count of subscriptions, and if after removal this becomes 0, we clean up the instance entirely. Hopefully we can then tweak disabling of update entities to unsubscribe, which in turn will make stuff auto-cleanup.

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.

There is something on .. what if we have 2 wled devices (same repo), and we do a force update on all their update entities at the same time. We will then do two forceful data updates, which means two queries for releases, parsing of those, paginating those, and so on..

If you had 10 or 100 wled devices, we might hit GH rate limits, even.

... something like a time limit on rechecks? "minimum 60s since last check" -style.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

there is one release coordinator, and it makes N requests on updates - if any 1/N requests fail, the entire check fails

We can add better error handling similarly to what is done in the system monitor integration.

if self.update_subscribers[("disks", argument)] or self._initial_update:
try:
usage: sdiskusage = self._psutil.disk_usage(argument)
_LOGGER.debug("sdiskusagefor %s: %s", argument, usage)
except PermissionError as err:
_LOGGER.warning(
"No permission to access %s, error %s", argument, err
)
except OSError as err:
_LOGGER.warning("OS error for %s, error %s", argument, err)
else:
disks[argument] = usage

if an update entity forces a recheck, it will recheck all repos

I think this is a minor issue because at most the user will have more up-to-date data.

There is something on .. what if we have 2 wled devices (same repo), and we do a force update on all their update entities at the same time.

I think that the coordinator debouncer will work here, it will prevent the same data from being downloaded multiple times in a short period of time.

I'm partial to the one instance per repo instead, keep it lean.. but then try to solve for cleanup - either the temporary bit like disabling the update entity, or the more permanent bit that is removing a WLED device (entry) entirely. It could be solved by a set of entry_ids in each release coordinator, and when that set is empty, we remove it.

I'm not sure what that would look like, but we could try to implement it. I proposed my solution because it sounded relatively simple to me. It requires the least amount of code changes to get it working now. I will open a PR with my proposal so that we can see what it looks like in full and then we can compare.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I created a draft PR: mik-laj#1

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.

Ok, I'll move closer to a single instance of update coordinator, and then tracking repos in it.

hass: HomeAssistant, repo: str | None
) -> WLEDReleasesDataUpdateCoordinator:
"""Return the cached release coordinator for a repository."""
normalized_repo = normalize_repo(repo)
releases_coordinators = hass.data[WLED_KEY]
if coordinator := releases_coordinators.get(normalized_repo):
return coordinator

coordinator = WLEDReleasesDataUpdateCoordinator(hass, repo=normalized_repo)
releases_coordinators[normalized_repo] = coordinator
await coordinator.async_request_refresh()
return coordinator


async def async_setup_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool:
"""Set up WLED from a config entry."""
entry.runtime_data = WLEDDataUpdateCoordinator(hass, entry=entry)
Expand Down
19 changes: 16 additions & 3 deletions homeassistant/components/wled/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
WLEDReleases,
WLEDUnsupportedVersionError,
)
from wled.const import DEFAULT_REPO

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
Expand All @@ -32,6 +33,15 @@
type WLEDConfigEntry = ConfigEntry[WLEDDataUpdateCoordinator]


def normalize_repo(repo: str | None) -> str:
"""Normalize a WLED repository name."""
if repo is None:
return DEFAULT_REPO

normalized_repo = repo.strip()
return normalized_repo or DEFAULT_REPO


def normalize_mac_address(mac: str) -> str:
"""Normalize a MAC address to lowercase without separators.

Expand Down Expand Up @@ -186,14 +196,17 @@ async def _async_update_data(self) -> WLEDDevice:
class WLEDReleasesDataUpdateCoordinator(DataUpdateCoordinator[Releases]):
"""Class to manage fetching WLED releases."""

def __init__(self, hass: HomeAssistant) -> None:
repo: str

def __init__(self, hass: HomeAssistant, *, repo: str | None = None) -> None:
"""Initialize global WLED releases updater."""
self.wled = WLEDReleases(session=async_get_clientsession(hass))
self.repo = normalize_repo(repo)
self.wled = WLEDReleases(repo=self.repo, session=async_get_clientsession(hass))
super().__init__(
hass,
LOGGER,
config_entry=None,
name=DOMAIN,
name=f"{DOMAIN}:{self.repo}",
update_interval=RELEASES_SCAN_INTERVAL,
)

Expand Down
22 changes: 16 additions & 6 deletions homeassistant/components/wled/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from . import WLED_KEY
from . import async_get_releases_coordinator
from .coordinator import (
WLEDConfigEntry,
WLEDDataUpdateCoordinator,
Expand All @@ -28,7 +28,10 @@
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WLED update based on a config entry."""
async_add_entities([WLEDUpdateEntity(entry.runtime_data, hass.data[WLED_KEY])])
releases_coordinator = await async_get_releases_coordinator(
hass, entry.runtime_data.data.info.repo

Check failure on line 32 in homeassistant/components/wled/update.py

View workflow job for this annotation

GitHub Actions / Check mypy

"Info" has no attribute "repo" [attr-defined]
)
async_add_entities([WLEDUpdateEntity(entry.runtime_data, releases_coordinator)])


class WLEDUpdateEntity(WLEDEntity, UpdateEntity):
Expand All @@ -38,6 +41,7 @@
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION
)
_attr_name = "Firmware"
_attr_title = "WLED"

def __init__(
Expand Down Expand Up @@ -77,20 +81,24 @@
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
releases = self.releases_coordinator.data
if releases is None:
return None

Check failure on line 86 in homeassistant/components/wled/update.py

View workflow job for this annotation

GitHub Actions / Check mypy

Statement is unreachable [unreachable]

# If we already run a pre-release, we consider being on the beta channel.
# Offer beta version upgrade, unless stable is newer
if (
(beta := self.releases_coordinator.data.beta) is not None
(beta := releases.beta) is not None
and (current := self.coordinator.data.info.version) is not None
and (current.alpha or current.beta or current.release_candidate)
and (
(stable := self.releases_coordinator.data.stable) is None
(stable := releases.stable) is None
or (stable is not None and stable < beta and current > stable)
)
):
return str(beta)

if (stable := self.releases_coordinator.data.stable) is not None:
if (stable := releases.stable) is not None:
return str(stable)

return None
Expand All @@ -100,7 +108,9 @@
"""URL to the full release notes of the latest version available."""
if (version := self.latest_version) is None:
return None
return f"https://github.com/wled/WLED/releases/tag/v{version}"
if (releases := self.releases_coordinator.data) is None:
return None

Check failure on line 112 in homeassistant/components/wled/update.py

View workflow job for this annotation

GitHub Actions / Check mypy

Statement is unreachable [unreachable]
return f"https://github.com/{releases.repo}/releases/tag/v{version}"

@wled_exception_handler
async def async_install(
Expand Down
46 changes: 46 additions & 0 deletions tests/components/wled/test_releases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Tests for WLED release coordination."""

from unittest.mock import AsyncMock, MagicMock, call, patch

from homeassistant.components.wled import WLED_KEY, async_get_releases_coordinator
from homeassistant.components.wled.coordinator import normalize_repo
from homeassistant.core import HomeAssistant


def test_normalize_repo() -> None:
"""Test repo normalization."""
assert normalize_repo(None) == "wled/WLED"
assert normalize_repo("") == "wled/WLED"
assert normalize_repo(" LordMike/Wled ") == "LordMike/Wled"


async def test_release_coordinator_is_cached_per_repo(hass: HomeAssistant) -> None:
"""Test release coordinators are deduplicated by repository."""
hass.data[WLED_KEY] = {}

created: list[MagicMock] = []

def _create_coordinator(*args: object, **kwargs: object) -> MagicMock:
coordinator = MagicMock()
coordinator.async_request_refresh = AsyncMock()
created.append(coordinator)
return coordinator

with patch(
"homeassistant.components.wled.WLEDReleasesDataUpdateCoordinator",
autospec=True,
side_effect=_create_coordinator,
) as mock_coordinator:
first = await async_get_releases_coordinator(hass, "LordMike/Wled")
second = await async_get_releases_coordinator(hass, "LordMike/Wled")
default = await async_get_releases_coordinator(hass, None)

assert first is second
assert first is not default
assert created == [first, default]
assert mock_coordinator.call_args_list == [
call(hass, repo="LordMike/Wled"),
call(hass, repo="wled/WLED"),
]
assert first.async_request_refresh.await_count == 1
assert default.async_request_refresh.await_count == 1
5 changes: 4 additions & 1 deletion tests/components/wled/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ async def test_update_error(

assert (state := hass.states.get("update.wled_rgb_light_firmware"))
assert state.state == STATE_UNAVAILABLE
assert "Invalid response from WLED API" in caplog.text
assert (
"Invalid response from WLED API" in caplog.text
or "invalid_response_wled_error" in caplog.text
)


async def test_update_stay_stable(
Expand Down
Loading