Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 2 additions & 4 deletions homeassistant/components/wled/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,10 @@
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.
A single releases coordinator tracks the repositories used by all WLED
devices and fetches each distinct repository once per refresh.
"""
hass.data[WLED_KEY] = WLEDReleasesDataUpdateCoordinator(hass)
await hass.data[WLED_KEY].async_request_refresh()
return True


Expand Down
75 changes: 67 additions & 8 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,18 @@
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().lower()
if normalized_repo == DEFAULT_REPO.lower():
return DEFAULT_REPO

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 @@ -183,27 +196,73 @@ async def _async_update_data(self) -> WLEDDevice:
return device


class WLEDReleasesDataUpdateCoordinator(DataUpdateCoordinator[Releases]):
class WLEDReleasesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Releases]]):
"""Class to manage fetching WLED releases."""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize global WLED releases updater."""
self.wled = WLEDReleases(session=async_get_clientsession(hass))
super().__init__(
hass,
LOGGER,
config_entry=None,
name=DOMAIN,
update_interval=RELEASES_SCAN_INTERVAL,
)
self._repos_by_entry_id: dict[str, str] = {}

async def async_set_repo(self, entry_id: str, repo: str | None) -> None:
"""Set the repository currently used by a WLED config entry."""
normalized_repo = normalize_repo(repo)
if self._repos_by_entry_id.get(entry_id) == normalized_repo:
return

self._repos_by_entry_id[entry_id] = normalized_repo
await self.async_request_refresh()

@callback
def async_unset_repo(self, entry_id: str) -> None:
"""Stop tracking the repository used by a WLED config entry."""
repo = self._repos_by_entry_id.pop(entry_id, None)
if repo is None or repo in self._repos_by_entry_id.values():
return

if self.data is not None and repo in self.data:
self.data = {key: value for key, value in self.data.items() if key != repo}
self.async_update_listeners()

async def _async_update_data(self) -> Releases:
async def _async_update_data(self) -> dict[str, Releases]:
"""Fetch release data from WLED."""
try:
return await self.wled.releases()
except WLEDError as error:
active_repos = set(self._repos_by_entry_id.values())
releases_by_repo = {
repo: releases
for repo, releases in (self.data or {}).items()
if repo in active_repos
}

# Preserve existing release data for repos with transient fetch failures,
# while dropping repos that are no longer used by any WLED entry.
first_error: WLEDError | None = None
success_count = 0
for repo in active_repos:
try:
releases_by_repo[repo] = await WLEDReleases(
repo=repo,
session=async_get_clientsession(self.hass),
).releases()
except WLEDError as error:
first_error = first_error or error
self.logger.warning(
"Error fetching WLED releases for repo %s: %s", repo, error
)
else:
success_count += 1

if active_repos and not success_count:
assert first_error is not None
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_response_github_error",
translation_placeholders={"error": str(error)},
) from error
translation_placeholders={"error": str(first_error)},
) from first_error

return releases_by_repo
60 changes: 52 additions & 8 deletions homeassistant/components/wled/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@

from typing import Any, cast

from wled import Releases

from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from . import WLED_KEY
from .coordinator import (
WLEDConfigEntry,
WLEDDataUpdateCoordinator,
WLEDReleasesDataUpdateCoordinator,
normalize_repo,
)
from .entity import WLEDEntity
from .helpers import wled_exception_handler
Expand All @@ -38,6 +41,7 @@
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION
)
_attr_name = "Firmware"
_attr_title = "WLED"

def __init__(
Expand All @@ -58,14 +62,51 @@
await super().async_added_to_hass()
self.async_on_remove(
self.releases_coordinator.async_add_listener(
self._handle_coordinator_update
self._handle_releases_coordinator_update
)
)
await self.releases_coordinator.async_set_repo(
self.coordinator.config_entry.entry_id, self._repo
)

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the device coordinator."""
self.hass.async_create_task(
self.releases_coordinator.async_set_repo(
self.coordinator.config_entry.entry_id, self._repo
)
)
super()._handle_coordinator_update()

@callback
def _handle_releases_coordinator_update(self) -> None:
"""Handle updated data from the releases coordinator."""
self.async_write_ha_state()

async def async_will_remove_from_hass(self) -> None:
"""When removed from hass."""
self.releases_coordinator.async_unset_repo(
self.coordinator.config_entry.entry_id
)
await super().async_will_remove_from_hass()

@property
def _repo(self) -> str:
"""Return the repo to fetch releases for."""
return normalize_repo(getattr(self.coordinator.data.info, "repo", None))

@property
def _release_info(self) -> Releases | None:
"""Return the release info for the current repo."""
if (releases_by_repo := self.releases_coordinator.data) is None:
return None

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

View workflow job for this annotation

GitHub Actions / Check mypy

Statement is unreachable [unreachable]
return releases_by_repo.get(self._repo)

@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.releases_coordinator.last_update_success
return super().available and self._release_info is not None

@property
def installed_version(self) -> str | None:
Expand All @@ -77,20 +118,23 @@
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
if (releases := self._release_info) is None:
return None

# 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 +144,7 @@
"""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}"
return f"https://github.com/{self._repo}/releases/tag/v{version}"

@wled_exception_handler
async def async_install(
Expand All @@ -110,7 +154,7 @@
if version is None:
# We cast here, as we know that the latest_version is a string.
version = cast(str, self.latest_version)
await self.coordinator.wled.upgrade(version=version)
await self.coordinator.wled.upgrade(version=version, repo=self._repo)
await self.coordinator.async_refresh()

async def async_update(self) -> None:
Expand Down
Loading
Loading