diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py index e7baadd8053..3b206c0df3c 100644 --- a/custom_components/hacs/base.py +++ b/custom_components/hacs/base.py @@ -39,6 +39,7 @@ ) from .const import DOMAIN, TV, URL_BASE +from .coordinator import HacsUpdateCoordinator from .data_client import HacsDataClient from .enums import ( ConfigurationType, @@ -374,6 +375,7 @@ def __init__(self) -> None: """Initialize.""" self.common = HacsCommon() self.configuration = HacsConfiguration() + self.coordinators: dict[HacsCategory, HacsUpdateCoordinator] = {} self.core = HacsCore() self.log = LOGGER self.recurring_tasks: list[Callable[[], None]] = [] @@ -424,12 +426,14 @@ def enable_hacs_category(self, category: HacsCategory) -> None: if category not in self.common.categories: self.log.info("Enable category: %s", category) self.common.categories.add(category) + self.coordinators[category] = HacsUpdateCoordinator() def disable_hacs_category(self, category: HacsCategory) -> None: """Disable HACS category.""" if category in self.common.categories: self.log.info("Disabling category: %s", category) self.common.categories.pop(category) + self.coordinators.pop(category) async def async_save_file(self, file_path: str, content: Any) -> bool: """Save a file.""" @@ -908,6 +912,7 @@ async def async_get_category_repositories_experimental(self, category: str) -> N self.repositories.unregister(repository) self.async_dispatch(HacsDispatchEvent.REPOSITORY, {}) + self.coordinators[category].async_update_listeners() async def async_get_category_repositories(self, category: HacsCategory) -> None: """Get repositories from category.""" @@ -1073,12 +1078,37 @@ async def async_update_downloaded_custom_repositories(self, _=None) -> None: return self.log.info("Starting recurring background task for downloaded custom repositories") + repositories_to_update = 0 + repositories_updated = asyncio.Event() + + async def update_repository(repository: HacsRepository) -> None: + """Update a repository""" + nonlocal repositories_to_update + await repository.update_repository(ignore_issues=True) + repositories_to_update -= 1 + if not repositories_to_update: + repositories_updated.set() + for repository in self.repositories.list_downloaded: if ( repository.data.category in self.common.categories and not self.repositories.is_default(repository.data.id) ): - self.queue.add(repository.update_repository(ignore_issues=True)) + repositories_to_update += 1 + self.queue.add(update_repository(repository)) + + async def update_coordinators() -> None: + """Update all coordinators.""" + await repositories_updated.wait() + for coordinator in self.coordinators.values(): + coordinator.async_update_listeners() + + if config_entry := self.configuration.config_entry: + config_entry.async_create_background_task( + self.hass, update_coordinators(), "update_coordinators" + ) + else: + self.hass.async_create_background_task(update_coordinators(), "update_coordinators") self.log.debug("Recurring background task for downloaded custom repositories done") diff --git a/custom_components/hacs/coordinator.py b/custom_components/hacs/coordinator.py new file mode 100644 index 00000000000..6cb87904d84 --- /dev/null +++ b/custom_components/hacs/coordinator.py @@ -0,0 +1,37 @@ +"""Coordinator to trigger entity updates.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Callable + +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol + + +class HacsUpdateCoordinator(BaseDataUpdateCoordinatorProtocol): + """Dispatch updates to update entities.""" + + def __init__(self) -> None: + """Initialize.""" + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + + self._listeners[remove_listener] = (update_callback, context) + + return remove_listener + + @callback + def async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() diff --git a/custom_components/hacs/entity.py b/custom_components/hacs/entity.py index 870d80a0113..1d7a0ccc533 100644 --- a/custom_components/hacs/entity.py +++ b/custom_components/hacs/entity.py @@ -7,8 +7,10 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT +from .coordinator import HacsUpdateCoordinator from .enums import HacsDispatchEvent, HacsGitHubRepo if TYPE_CHECKING: @@ -39,6 +41,10 @@ def __init__(self, hacs: HacsBase) -> None: """Initialize.""" self.hacs = hacs + +class HacsDispatcherEntity(HacsBaseEntity): + """Base HACS entity listening to dispatcher signals.""" + async def async_added_to_hass(self) -> None: """Register for status events.""" self.async_on_remove( @@ -64,7 +70,7 @@ def _update_and_write_state(self, _: Any) -> None: self.async_write_ha_state() -class HacsSystemEntity(HacsBaseEntity): +class HacsSystemEntity(HacsDispatcherEntity): """Base system entity.""" _attr_icon = "hacs:hacs" @@ -76,7 +82,7 @@ def device_info(self) -> dict[str, any]: return system_info(self.hacs) -class HacsRepositoryEntity(HacsBaseEntity): +class HacsRepositoryEntity(BaseCoordinatorEntity[HacsUpdateCoordinator], HacsBaseEntity): """Base repository entity.""" def __init__( @@ -85,9 +91,11 @@ def __init__( repository: HacsRepository, ) -> None: """Initialize.""" - super().__init__(hacs=hacs) + BaseCoordinatorEntity.__init__(self, hacs.coordinators[repository.data.category]) + HacsBaseEntity.__init__(self, hacs=hacs) self.repository = repository self._attr_unique_id = str(repository.data.id) + self._repo_last_fetched = repository.data.last_fetched @property def available(self) -> bool: @@ -112,8 +120,20 @@ def device_info(self) -> dict[str, any]: } @callback - def _update_and_write_state(self, data: dict) -> None: - """Update the entity and write state.""" - if data.get("repository_id") == self.repository.data.id: - self._update() - self.async_write_ha_state() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if ( + self._repo_last_fetched is not None + and self.repository.data.last_fetched is not None + and self._repo_last_fetched >= self.repository.data.last_fetched + ): + return + + self._repo_last_fetched = self.repository.data.last_fetched + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ diff --git a/custom_components/hacs/repositories/appdaemon.py b/custom_components/hacs/repositories/appdaemon.py index 0385addef8f..a1fab2d2d01 100644 --- a/custom_components/hacs/repositories/appdaemon.py +++ b/custom_components/hacs/repositories/appdaemon.py @@ -79,7 +79,7 @@ async def update_repository(self, ignore_issues=False, force=False): # Set local path self.content.path.local = self.localpath - # Signal entities to refresh + # Signal frontend to refresh if self.data.installed: self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY, diff --git a/custom_components/hacs/repositories/integration.py b/custom_components/hacs/repositories/integration.py index 70b8b547910..f5e5613140c 100644 --- a/custom_components/hacs/repositories/integration.py +++ b/custom_components/hacs/repositories/integration.py @@ -142,7 +142,7 @@ async def update_repository(self, ignore_issues=False, force=False): # Set local path self.content.path.local = self.localpath - # Signal entities to refresh + # Signal frontend to refresh if self.data.installed: self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY, diff --git a/custom_components/hacs/repositories/netdaemon.py b/custom_components/hacs/repositories/netdaemon.py index ef892bfd7f0..194a97a55fa 100644 --- a/custom_components/hacs/repositories/netdaemon.py +++ b/custom_components/hacs/repositories/netdaemon.py @@ -82,7 +82,7 @@ async def update_repository(self, ignore_issues=False, force=False): # Set local path self.content.path.local = self.localpath - # Signal entities to refresh + # Signal frontend to refresh if self.data.installed: self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY, diff --git a/custom_components/hacs/repositories/plugin.py b/custom_components/hacs/repositories/plugin.py index 63d95e4b8be..34a2978b21b 100644 --- a/custom_components/hacs/repositories/plugin.py +++ b/custom_components/hacs/repositories/plugin.py @@ -74,7 +74,7 @@ async def update_repository(self, ignore_issues=False, force=False): if self.content.path.remote == "release": self.content.single = True - # Signal entities to refresh + # Signal frontend to refresh if self.data.installed: self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY, diff --git a/custom_components/hacs/repositories/python_script.py b/custom_components/hacs/repositories/python_script.py index b705655069f..baf0d25b171 100644 --- a/custom_components/hacs/repositories/python_script.py +++ b/custom_components/hacs/repositories/python_script.py @@ -89,7 +89,7 @@ async def update_repository(self, ignore_issues=False, force=False): # Update name self.update_filenames() - # Signal entities to refresh + # Signal frontend to refresh if self.data.installed: self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY, diff --git a/custom_components/hacs/repositories/template.py b/custom_components/hacs/repositories/template.py index a0d28616bd7..ff6af66310e 100644 --- a/custom_components/hacs/repositories/template.py +++ b/custom_components/hacs/repositories/template.py @@ -78,7 +78,7 @@ async def update_repository(self, ignore_issues=False, force=False): self.data.file_name = self.repository_manifest.filename self.content.path.local = self.localpath - # Signal entities to refresh + # Signal frontend to refresh if self.data.installed: self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY, diff --git a/custom_components/hacs/repositories/theme.py b/custom_components/hacs/repositories/theme.py index 54d417f02be..41988f8eef8 100644 --- a/custom_components/hacs/repositories/theme.py +++ b/custom_components/hacs/repositories/theme.py @@ -88,7 +88,7 @@ async def update_repository(self, ignore_issues=False, force=False): self.update_filenames() self.content.path.local = self.localpath - # Signal entities to refresh + # Signal frontend to refresh if self.data.installed: self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY, diff --git a/custom_components/hacs/websocket/repository.py b/custom_components/hacs/websocket/repository.py index 125b0e84a2a..7055a6eac78 100644 --- a/custom_components/hacs/websocket/repository.py +++ b/custom_components/hacs/websocket/repository.py @@ -289,6 +289,8 @@ async def hacs_repository_refresh( await repository.update_repository(ignore_issues=True, force=True) await hacs.data.async_write() + # Update state of update entity + hacs.coordinators[repository.data.category].async_update_listeners() connection.send_message(websocket_api.result_message(msg["id"], {})) diff --git a/tests/snapshots/hacs-test-org/appdaemon-basic/test_update_entity_state.json b/tests/snapshots/hacs-test-org/appdaemon-basic/test_update_entity_state.json index d8f8c902c18..937632592ab 100644 --- a/tests/snapshots/hacs-test-org/appdaemon-basic/test_update_entity_state.json +++ b/tests/snapshots/hacs-test-org/appdaemon-basic/test_update_entity_state.json @@ -25,7 +25,7 @@ "friendly_name": "Appdaemon Basic update", "in_progress": false, "installed_version": "1.0.0", - "latest_version": "1.0.0", + "latest_version": "2.0.0", "release_summary": null, "release_url": "https://github.com/hacs-test-org/appdaemon-basic", "skipped_version": null, @@ -37,6 +37,6 @@ "user_id": null }, "entity_id": "update.appdaemon_basic_update", - "state": "off" + "state": "on" } } \ No newline at end of file diff --git a/tests/snapshots/hacs-test-org/integration-basic/test_update_entity_state.json b/tests/snapshots/hacs-test-org/integration-basic/test_update_entity_state.json index 3b9728f5229..796672102d0 100644 --- a/tests/snapshots/hacs-test-org/integration-basic/test_update_entity_state.json +++ b/tests/snapshots/hacs-test-org/integration-basic/test_update_entity_state.json @@ -27,7 +27,7 @@ "friendly_name": "Basic integration update", "in_progress": false, "installed_version": "1.0.0", - "latest_version": "1.0.0", + "latest_version": "2.0.0", "release_summary": null, "release_url": "https://github.com/hacs-test-org/integration-basic", "skipped_version": null, @@ -39,6 +39,6 @@ "user_id": null }, "entity_id": "update.basic_integration_update", - "state": "off" + "state": "on" } } \ No newline at end of file diff --git a/tests/snapshots/hacs-test-org/plugin-basic/test_update_entity_state.json b/tests/snapshots/hacs-test-org/plugin-basic/test_update_entity_state.json index eb0cabdc0c1..96d16dab0d9 100644 --- a/tests/snapshots/hacs-test-org/plugin-basic/test_update_entity_state.json +++ b/tests/snapshots/hacs-test-org/plugin-basic/test_update_entity_state.json @@ -25,7 +25,7 @@ "friendly_name": "Plugin Basic update", "in_progress": false, "installed_version": "1.0.0", - "latest_version": "1.0.0", + "latest_version": "2.0.0", "release_summary": null, "release_url": "https://github.com/hacs-test-org/plugin-basic", "skipped_version": null, @@ -37,6 +37,6 @@ "user_id": null }, "entity_id": "update.plugin_basic_update", - "state": "off" + "state": "on" } } \ No newline at end of file diff --git a/tests/snapshots/hacs-test-org/python_script-basic/test_update_entity_state.json b/tests/snapshots/hacs-test-org/python_script-basic/test_update_entity_state.json index 7c2504017ed..2596d086e4c 100644 --- a/tests/snapshots/hacs-test-org/python_script-basic/test_update_entity_state.json +++ b/tests/snapshots/hacs-test-org/python_script-basic/test_update_entity_state.json @@ -25,7 +25,7 @@ "friendly_name": "Python Script Basic update", "in_progress": false, "installed_version": "1.0.0", - "latest_version": "1.0.0", + "latest_version": "2.0.0", "release_summary": null, "release_url": "https://github.com/hacs-test-org/python_script-basic", "skipped_version": null, @@ -37,6 +37,6 @@ "user_id": null }, "entity_id": "update.python_script_basic_update", - "state": "off" + "state": "on" } } \ No newline at end of file diff --git a/tests/snapshots/hacs-test-org/template-basic/test_update_entity_state.json b/tests/snapshots/hacs-test-org/template-basic/test_update_entity_state.json index 8680e565d8c..27177a09cc3 100644 --- a/tests/snapshots/hacs-test-org/template-basic/test_update_entity_state.json +++ b/tests/snapshots/hacs-test-org/template-basic/test_update_entity_state.json @@ -25,7 +25,7 @@ "friendly_name": "Template Basic update", "in_progress": false, "installed_version": "1.0.0", - "latest_version": "1.0.0", + "latest_version": "2.0.0", "release_summary": null, "release_url": "https://github.com/hacs-test-org/template-basic", "skipped_version": null, @@ -37,6 +37,6 @@ "user_id": null }, "entity_id": "update.template_basic_update", - "state": "off" + "state": "on" } } \ No newline at end of file diff --git a/tests/snapshots/hacs-test-org/theme-basic/test_update_entity_state.json b/tests/snapshots/hacs-test-org/theme-basic/test_update_entity_state.json index 1c03b4d526e..6e263a40ccf 100644 --- a/tests/snapshots/hacs-test-org/theme-basic/test_update_entity_state.json +++ b/tests/snapshots/hacs-test-org/theme-basic/test_update_entity_state.json @@ -25,7 +25,7 @@ "friendly_name": "Theme Basic update", "in_progress": false, "installed_version": "1.0.0", - "latest_version": "1.0.0", + "latest_version": "2.0.0", "release_summary": null, "release_url": "https://github.com/hacs-test-org/theme-basic", "skipped_version": null, @@ -37,6 +37,6 @@ "user_id": null }, "entity_id": "update.theme_basic_update", - "state": "off" + "state": "on" } } \ No newline at end of file