Buckets:
| """Base HACS class.""" | |
| from __future__ import annotations | |
| import asyncio | |
| from collections.abc import Awaitable, Callable | |
| from dataclasses import asdict, dataclass, field | |
| from datetime import timedelta | |
| import gzip | |
| import math | |
| import os | |
| import pathlib | |
| import shutil | |
| from typing import TYPE_CHECKING, Any | |
| from aiogithubapi import ( | |
| AIOGitHubAPIException, | |
| GitHub, | |
| GitHubAPI, | |
| GitHubAuthenticationException, | |
| GitHubException, | |
| GitHubNotModifiedException, | |
| GitHubRatelimitException, | |
| ) | |
| from aiogithubapi.objects.repository import AIOGitHubAPIRepository | |
| from aiohttp.client import ClientSession, ClientTimeout | |
| from awesomeversion import AwesomeVersion | |
| from homeassistant.components.persistent_notification import ( | |
| async_create as async_create_persistent_notification, | |
| ) | |
| from homeassistant.config_entries import ConfigEntry | |
| from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform | |
| from homeassistant.core import HomeAssistant, callback | |
| from homeassistant.helpers.dispatcher import async_dispatcher_send | |
| from homeassistant.helpers.event import async_track_time_interval | |
| from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue | |
| from homeassistant.loader import Integration | |
| from homeassistant.util import dt | |
| from .const import DOMAIN, TV, URL_BASE | |
| from .coordinator import HacsUpdateCoordinator | |
| from .data_client import HacsDataClient | |
| from .enums import ( | |
| HacsCategory, | |
| HacsDisabledReason, | |
| HacsDispatchEvent, | |
| HacsGitHubRepo, | |
| HacsStage, | |
| LovelaceMode, | |
| ) | |
| from .exceptions import ( | |
| AddonRepositoryException, | |
| HacsException, | |
| HacsExecutionStillInProgress, | |
| HacsExpectedException, | |
| HacsNotModifiedException, | |
| HacsRepositoryArchivedException, | |
| HacsRepositoryExistException, | |
| HomeAssistantCoreRepositoryException, | |
| ) | |
| from .repositories import REPOSITORY_CLASSES | |
| from .repositories.base import HACS_MANIFEST_KEYS_TO_EXPORT, REPOSITORY_KEYS_TO_EXPORT | |
| from .utils.file_system import async_exists | |
| from .utils.json import json_loads | |
| from .utils.logger import LOGGER | |
| from .utils.queue_manager import QueueManager | |
| from .utils.store import async_load_from_store, async_save_to_store | |
| from .utils.workarounds import async_register_static_path | |
| if TYPE_CHECKING: | |
| from .repositories.base import HacsRepository | |
| from .utils.data import HacsData | |
| from .validate.manager import ValidationManager | |
| class RemovedRepository: | |
| """Removed repository.""" | |
| repository: str | None = None | |
| reason: str | None = None | |
| link: str | None = None | |
| removal_type: str = None # archived, not_compliant, critical, dev, broken | |
| acknowledged: bool = False | |
| def update_data(self, data: dict): | |
| """Update data of the repository.""" | |
| for key in data: | |
| if data[key] is None: | |
| continue | |
| if key in ( | |
| "reason", | |
| "link", | |
| "removal_type", | |
| "acknowledged", | |
| ): | |
| self.__setattr__(key, data[key]) | |
| def to_json(self): | |
| """Return a JSON representation of the data.""" | |
| return { | |
| "repository": self.repository, | |
| "reason": self.reason, | |
| "link": self.link, | |
| "removal_type": self.removal_type, | |
| "acknowledged": self.acknowledged, | |
| } | |
| class HacsConfiguration: | |
| """HacsConfiguration class.""" | |
| appdaemon_path: str = "appdaemon/apps/" | |
| appdaemon: bool = False | |
| config: dict[str, Any] = field(default_factory=dict) | |
| config_entry: ConfigEntry | None = None | |
| country: str = "ALL" | |
| debug: bool = False | |
| dev: bool = False | |
| frontend_repo_url: str = "" | |
| frontend_repo: str = "" | |
| plugin_path: str = "www/community/" | |
| python_script_path: str = "python_scripts/" | |
| python_script: bool = False | |
| release_limit: int = 5 | |
| sidepanel_icon: str = "hacs:hacs" | |
| sidepanel_title: str = "HACS" | |
| theme_path: str = "themes/" | |
| theme: bool = False | |
| token: str = None | |
| def to_json(self) -> str: | |
| """Return a json string.""" | |
| return asdict(self) | |
| def update_from_dict(self, data: dict) -> None: | |
| """Set attributes from dicts.""" | |
| if not isinstance(data, dict): | |
| raise HacsException("Configuration is not valid.") | |
| for key in data: | |
| if key in {"experimental", "netdaemon", "release_limit", "debug"}: | |
| continue | |
| self.__setattr__(key, data[key]) | |
| class HacsCore: | |
| """HACS Core info.""" | |
| config_path: pathlib.Path | None = None | |
| ha_version: AwesomeVersion | None = None | |
| lovelace_mode = LovelaceMode("yaml") | |
| class HacsCommon: | |
| """Common for HACS.""" | |
| categories: set[str] = field(default_factory=set) | |
| renamed_repositories: dict[str, str] = field(default_factory=dict) | |
| archived_repositories: set[str] = field(default_factory=set) | |
| ignored_repositories: set[str] = field(default_factory=set) | |
| skip: set[str] = field(default_factory=set) | |
| class HacsStatus: | |
| """HacsStatus.""" | |
| startup: bool = True | |
| new: bool = False | |
| active_frontend_endpoint_plugin: bool = False | |
| active_frontend_endpoint_theme: bool = False | |
| inital_fetch_done: bool = False | |
| class HacsSystem: | |
| """HACS System info.""" | |
| disabled_reason: HacsDisabledReason | None = None | |
| running: bool = False | |
| stage = HacsStage.SETUP | |
| action: bool = False | |
| generator: bool = False | |
| def disabled(self) -> bool: | |
| """Return if HACS is disabled.""" | |
| return self.disabled_reason is not None | |
| class HacsRepositories: | |
| """HACS Repositories.""" | |
| _default_repositories: set[str] = field(default_factory=set) | |
| _repositories: set[HacsRepository] = field(default_factory=set) | |
| _repositories_by_full_name: dict[str, HacsRepository] = field(default_factory=dict) | |
| _repositories_by_id: dict[str, HacsRepository] = field(default_factory=dict) | |
| _removed_repositories_by_full_name: dict[str, RemovedRepository] = field(default_factory=dict) | |
| def list_all(self) -> list[HacsRepository]: | |
| """Return a list of repositories.""" | |
| return list(self._repositories) | |
| def list_removed(self) -> list[RemovedRepository]: | |
| """Return a list of removed repositories.""" | |
| return list(self._removed_repositories_by_full_name.values()) | |
| def list_downloaded(self) -> list[HacsRepository]: | |
| """Return a list of downloaded repositories.""" | |
| return [repo for repo in self._repositories if repo.data.installed] | |
| def category_downloaded(self, category: HacsCategory) -> bool: | |
| """Check if a given category has been downloaded.""" | |
| for repository in self.list_downloaded: | |
| if repository.data.category == category: | |
| return True | |
| return False | |
| def register(self, repository: HacsRepository, default: bool = False) -> None: | |
| """Register a repository.""" | |
| repo_id = str(repository.data.id) | |
| if repo_id == "0": | |
| return | |
| if registered_repo := self._repositories_by_id.get(repo_id): | |
| if registered_repo.data.full_name == repository.data.full_name: | |
| return | |
| self.unregister(registered_repo) | |
| registered_repo.data.full_name = repository.data.full_name | |
| registered_repo.data.new = False | |
| repository = registered_repo | |
| if repository not in self._repositories: | |
| self._repositories.add(repository) | |
| self._repositories_by_id[repo_id] = repository | |
| self._repositories_by_full_name[repository.data.full_name_lower] = repository | |
| if default: | |
| self.mark_default(repository) | |
| def unregister(self, repository: HacsRepository) -> None: | |
| """Unregister a repository.""" | |
| repo_id = str(repository.data.id) | |
| if repo_id == "0": | |
| return | |
| if not self.is_registered(repository_id=repo_id): | |
| return | |
| if self.is_default(repo_id): | |
| self._default_repositories.remove(repo_id) | |
| if repository in self._repositories: | |
| self._repositories.remove(repository) | |
| self._repositories_by_id.pop(repo_id, None) | |
| self._repositories_by_full_name.pop(repository.data.full_name_lower, None) | |
| def mark_default(self, repository: HacsRepository) -> None: | |
| """Mark a repository as default.""" | |
| repo_id = str(repository.data.id) | |
| if repo_id == "0": | |
| return | |
| if not self.is_registered(repository_id=repo_id): | |
| return | |
| self._default_repositories.add(repo_id) | |
| def set_repository_id(self, repository: HacsRepository, repo_id: str): | |
| """Update a repository id.""" | |
| existing_repo_id = str(repository.data.id) | |
| if existing_repo_id == repo_id: | |
| return | |
| if existing_repo_id != "0": | |
| raise ValueError( | |
| f"The repo id for {repository.data.full_name_lower} " | |
| f"is already set to {existing_repo_id}" | |
| ) | |
| repository.data.id = repo_id | |
| self.register(repository) | |
| def is_default(self, repository_id: str | None = None) -> bool: | |
| """Check if a repository is default.""" | |
| if not repository_id: | |
| return False | |
| return repository_id in self._default_repositories | |
| def is_registered( | |
| self, | |
| repository_id: str | None = None, | |
| repository_full_name: str | None = None, | |
| ) -> bool: | |
| """Check if a repository is registered.""" | |
| if repository_id is not None: | |
| return repository_id in self._repositories_by_id | |
| if repository_full_name is not None: | |
| return repository_full_name in self._repositories_by_full_name | |
| return False | |
| def is_downloaded( | |
| self, | |
| repository_id: str | None = None, | |
| repository_full_name: str | None = None, | |
| ) -> bool: | |
| """Check if a repository is registered.""" | |
| if repository_id is not None: | |
| repo = self.get_by_id(repository_id) | |
| if repository_full_name is not None: | |
| repo = self.get_by_full_name(repository_full_name) | |
| if repo is None: | |
| return False | |
| return repo.data.installed | |
| def get_by_id(self, repository_id: str | None) -> HacsRepository | None: | |
| """Get repository by id.""" | |
| if not repository_id: | |
| return None | |
| return self._repositories_by_id.get(str(repository_id)) | |
| def get_by_full_name(self, repository_full_name: str | None) -> HacsRepository | None: | |
| """Get repository by full name.""" | |
| if not repository_full_name: | |
| return None | |
| return self._repositories_by_full_name.get(repository_full_name.lower()) | |
| def is_removed(self, repository_full_name: str) -> bool: | |
| """Check if a repository is removed.""" | |
| return repository_full_name in self._removed_repositories_by_full_name | |
| def removed_repository(self, repository_full_name: str) -> RemovedRepository: | |
| """Get repository by full name.""" | |
| if removed := self._removed_repositories_by_full_name.get(repository_full_name): | |
| return removed | |
| removed = RemovedRepository(repository=repository_full_name) | |
| self._removed_repositories_by_full_name[repository_full_name] = removed | |
| return removed | |
| class HacsBase: | |
| """Base HACS class.""" | |
| data: HacsData | None = None | |
| data_client: HacsDataClient | None = None | |
| frontend_version: str | None = None | |
| github: GitHub | None = None | |
| githubapi: GitHubAPI | None = None | |
| hass: HomeAssistant | None = None | |
| integration: Integration | None = None | |
| queue: QueueManager | None = None | |
| repository: AIOGitHubAPIRepository | None = None | |
| session: ClientSession | None = None | |
| stage: HacsStage | None = None | |
| validation: ValidationManager | None = None | |
| version: AwesomeVersion | None = None | |
| 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]] = [] | |
| self.repositories = HacsRepositories() | |
| self.status = HacsStatus() | |
| self.system = HacsSystem() | |
| def integration_dir(self) -> pathlib.Path: | |
| """Return the HACS integration dir.""" | |
| return self.integration.file_path | |
| def set_stage(self, stage: HacsStage | None) -> None: | |
| """Set HACS stage.""" | |
| if stage and self.stage == stage: | |
| return | |
| self.stage = stage | |
| if stage is not None: | |
| self.log.info("Stage changed: %s", self.stage) | |
| self.async_dispatch(HacsDispatchEvent.STAGE, {"stage": self.stage}) | |
| def disable_hacs(self, reason: HacsDisabledReason) -> None: | |
| """Disable HACS.""" | |
| if self.system.disabled_reason == reason: | |
| return | |
| self.system.disabled_reason = reason | |
| if reason != HacsDisabledReason.REMOVED: | |
| self.log.error("HACS is disabled - %s", reason) | |
| if reason == HacsDisabledReason.INVALID_TOKEN: | |
| self.hass.add_job(self.configuration.config_entry.async_start_reauth, self.hass) | |
| def enable_hacs(self) -> None: | |
| """Enable HACS.""" | |
| if self.system.disabled_reason is not None: | |
| self.system.disabled_reason = None | |
| self.log.info("HACS is enabled") | |
| def enable_hacs_category(self, category: HacsCategory) -> None: | |
| """Enable HACS category.""" | |
| 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.""" | |
| def _write_file(): | |
| with open( | |
| file_path, | |
| mode="w" if isinstance(content, str) else "wb", | |
| encoding="utf-8" if isinstance(content, str) else None, | |
| errors="ignore" if isinstance(content, str) else None, | |
| ) as file_handler: | |
| file_handler.write(content) | |
| # Create gz for .js files | |
| if os.path.isfile(file_path): | |
| if file_path.endswith(".js"): | |
| with open(file_path, "rb") as f_in: | |
| with gzip.open(file_path + ".gz", "wb") as f_out: | |
| shutil.copyfileobj(f_in, f_out) | |
| # LEGACY! Remove with 2.0 | |
| if "themes" in file_path and file_path.endswith(".yaml"): | |
| filename = file_path.split("/")[-1] | |
| base = file_path.split("/themes/")[0] | |
| combined = f"{base}/themes/{filename}" | |
| if os.path.exists(combined): | |
| self.log.info("Removing old theme file %s", combined) | |
| os.remove(combined) | |
| try: | |
| await self.hass.async_add_executor_job(_write_file) | |
| except ( | |
| # lgtm [py/catch-base-exception] pylint: disable=broad-except | |
| BaseException | |
| ) as error: | |
| self.log.error("Could not write data to %s - %s", file_path, error) | |
| return False | |
| return await async_exists(self.hass, file_path) | |
| async def async_can_update(self) -> int: | |
| """Helper to calculate the number of repositories we can fetch data for.""" | |
| try: | |
| response = await self.async_github_api_method(self.githubapi.rate_limit) | |
| if ((limit := response.data.resources.core.remaining or 0) - 1000) >= 10: | |
| return math.floor((limit - 1000) / 10) | |
| reset = dt.as_local(dt.utc_from_timestamp(response.data.resources.core.reset)) | |
| self.log.info( | |
| "GitHub API ratelimited - %s remaining (%s)", | |
| response.data.resources.core.remaining, | |
| f"{reset.hour}:{reset.minute}:{reset.second}", | |
| ) | |
| self.disable_hacs(HacsDisabledReason.RATE_LIMIT) | |
| except ( | |
| # lgtm [py/catch-base-exception] pylint: disable=broad-except | |
| BaseException | |
| ) as exception: | |
| self.log.exception(exception) | |
| return 0 | |
| async def async_github_api_method( | |
| self, | |
| method: Callable[[], Awaitable[TV]], | |
| *args, | |
| raise_exception: bool = True, | |
| **kwargs, | |
| ) -> TV | None: | |
| """Call a GitHub API method""" | |
| _exception = None | |
| try: | |
| return await method(*args, **kwargs) | |
| except GitHubAuthenticationException as exception: | |
| self.disable_hacs(HacsDisabledReason.INVALID_TOKEN) | |
| _exception = exception | |
| except GitHubRatelimitException as exception: | |
| self.disable_hacs(HacsDisabledReason.RATE_LIMIT) | |
| _exception = exception | |
| except GitHubNotModifiedException as exception: | |
| raise exception | |
| except GitHubException as exception: | |
| _exception = exception | |
| except ( | |
| # lgtm [py/catch-base-exception] pylint: disable=broad-except | |
| BaseException | |
| ) as exception: | |
| self.log.exception(exception) | |
| _exception = exception | |
| if raise_exception and _exception is not None: | |
| raise HacsException(_exception) | |
| return None | |
| async def async_register_repository( | |
| self, | |
| repository_full_name: str, | |
| category: HacsCategory, | |
| *, | |
| check: bool = True, | |
| ref: str | None = None, | |
| repository_id: str | None = None, | |
| default: bool = False, | |
| ) -> None: | |
| """Register a repository.""" | |
| if repository_full_name in self.common.skip: | |
| if repository_full_name != HacsGitHubRepo.INTEGRATION: | |
| raise HacsExpectedException(f"Skipping {repository_full_name}") | |
| if repository_full_name == "home-assistant/core": | |
| raise HomeAssistantCoreRepositoryException() | |
| if repository_full_name == "home-assistant/addons" or repository_full_name.startswith( | |
| "hassio-addons/" | |
| ): | |
| raise AddonRepositoryException() | |
| if category not in REPOSITORY_CLASSES: | |
| self.log.warning( | |
| "%s is not a valid repository category, %s will not be registered.", | |
| category, | |
| repository_full_name, | |
| ) | |
| return | |
| if (renamed := self.common.renamed_repositories.get(repository_full_name)) is not None: | |
| repository_full_name = renamed | |
| repository: HacsRepository = REPOSITORY_CLASSES[category](self, repository_full_name) | |
| if check: | |
| try: | |
| await repository.async_registration(ref) | |
| if repository.validate.errors: | |
| self.common.skip.add(repository.data.full_name) | |
| if not self.status.startup: | |
| self.log.error("Validation for %s failed.", repository_full_name) | |
| if self.system.action: | |
| raise HacsException( | |
| f"::error:: Validation for { | |
| repository_full_name} failed." | |
| ) | |
| return repository.validate.errors | |
| if self.system.action: | |
| repository.logger.info("%s Validation completed", repository.string) | |
| else: | |
| repository.logger.info("%s Registration completed", repository.string) | |
| except (HacsRepositoryExistException, HacsRepositoryArchivedException) as exception: | |
| if self.system.generator: | |
| repository.logger.error( | |
| "%s Registration Failed - %s", repository.string, exception | |
| ) | |
| return | |
| except AIOGitHubAPIException as exception: | |
| self.common.skip.add(repository.data.full_name) | |
| raise HacsException( | |
| f"Validation for { | |
| repository_full_name} failed with {exception}." | |
| ) from exception | |
| if self.status.new: | |
| repository.data.new = False | |
| if repository_id is not None: | |
| repository.data.id = repository_id | |
| else: | |
| if self.hass is not None and check and repository.data.new: | |
| self.async_dispatch( | |
| HacsDispatchEvent.REPOSITORY, | |
| { | |
| "action": "registration", | |
| "repository": repository.data.full_name, | |
| "repository_id": repository.data.id, | |
| }, | |
| ) | |
| self.repositories.register(repository, default) | |
| async def startup_tasks(self, _=None) -> None: | |
| """Tasks that are started after setup.""" | |
| self.set_stage(HacsStage.STARTUP) | |
| await self.async_load_hacs_from_github() | |
| if critical := await async_load_from_store(self.hass, "critical"): | |
| for repo in critical: | |
| if not repo["acknowledged"]: | |
| self.log.critical("URGENT!: Check the HACS panel!") | |
| async_create_persistent_notification( | |
| self.hass, title="URGENT!", message="**Check the HACS panel!**" | |
| ) | |
| break | |
| self.recurring_tasks.append( | |
| async_track_time_interval( | |
| self.hass, | |
| self.async_load_hacs_from_github, | |
| timedelta(hours=48), | |
| ) | |
| ) | |
| self.recurring_tasks.append( | |
| async_track_time_interval( | |
| self.hass, self.async_update_downloaded_custom_repositories, timedelta(hours=48) | |
| ) | |
| ) | |
| self.recurring_tasks.append( | |
| async_track_time_interval( | |
| self.hass, self.async_get_all_category_repositories, timedelta(hours=6) | |
| ) | |
| ) | |
| self.recurring_tasks.append( | |
| async_track_time_interval(self.hass, self.async_check_rate_limit, timedelta(minutes=5)) | |
| ) | |
| self.recurring_tasks.append( | |
| async_track_time_interval(self.hass, self.async_process_queue, timedelta(minutes=10)) | |
| ) | |
| self.recurring_tasks.append( | |
| async_track_time_interval( | |
| self.hass, self.async_handle_critical_repositories, timedelta(hours=6) | |
| ) | |
| ) | |
| unsub = self.hass.bus.async_listen_once( | |
| EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write | |
| ) | |
| if config_entry := self.configuration.config_entry: | |
| config_entry.async_on_unload(unsub) | |
| self.log.debug("There are %s scheduled recurring tasks", len(self.recurring_tasks)) | |
| self.status.startup = False | |
| self.async_dispatch(HacsDispatchEvent.STATUS, {}) | |
| await self.async_handle_removed_repositories() | |
| await self.async_get_all_category_repositories() | |
| self.set_stage(HacsStage.RUNNING) | |
| self.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True}) | |
| await self.async_handle_critical_repositories() | |
| await self.async_process_queue() | |
| self.async_dispatch(HacsDispatchEvent.STATUS, {}) | |
| async def async_download_file( | |
| self, | |
| url: str, | |
| *, | |
| headers: dict | None = None, | |
| keep_url: bool = False, | |
| nolog: bool = False, | |
| **_, | |
| ) -> bytes | None: | |
| """Download files, and return the content.""" | |
| if url is None: | |
| return None | |
| if not keep_url and "tags/" in url: | |
| url = url.replace("tags/", "") | |
| self.log.debug("Trying to download %s", url) | |
| timeouts = 0 | |
| while timeouts < 5: | |
| try: | |
| request = await self.session.get( | |
| url=url, | |
| timeout=ClientTimeout(total=60), | |
| headers=headers, | |
| ) | |
| # Make sure that we got a valid result | |
| if request.status == 200: | |
| return await request.read() | |
| raise HacsException( | |
| f"Got status code { | |
| request.status} when trying to download {url}" | |
| ) | |
| except TimeoutError: | |
| self.log.warning( | |
| "A timeout of 60! seconds was encountered while downloading %s, " | |
| "using over 60 seconds to download a single file is not normal. " | |
| "This is not a problem with HACS but how your host communicates with GitHub. " | |
| "Retrying up to 5 times to mask/hide your host/network problems to " | |
| "stop the flow of issues opened about it. " | |
| "Tries left %s", | |
| url, | |
| (4 - timeouts), | |
| ) | |
| timeouts += 1 | |
| await asyncio.sleep(1) | |
| continue | |
| except ( | |
| # lgtm [py/catch-base-exception] pylint: disable=broad-except | |
| BaseException | |
| ) as exception: | |
| if not nolog: | |
| self.log.exception("Download failed - %s", exception) | |
| return None | |
| async def async_recreate_entities(self) -> None: | |
| """Recreate entities.""" | |
| platforms = [Platform.UPDATE] | |
| # Workaround for core versions without https://github.com/home-assistant/core/pull/117084 | |
| if self.core.ha_version < AwesomeVersion("2024.6.0"): | |
| unload_platforms_lock = asyncio.Lock() | |
| async with unload_platforms_lock: | |
| on_unload = self.configuration.config_entry._on_unload | |
| self.configuration.config_entry._on_unload = [] | |
| await self.hass.config_entries.async_unload_platforms( | |
| entry=self.configuration.config_entry, | |
| platforms=platforms, | |
| ) | |
| self.configuration.config_entry._on_unload = on_unload | |
| else: | |
| await self.hass.config_entries.async_unload_platforms( | |
| entry=self.configuration.config_entry, | |
| platforms=platforms, | |
| ) | |
| await self.hass.config_entries.async_forward_entry_setups( | |
| self.configuration.config_entry, platforms | |
| ) | |
| def async_dispatch(self, signal: HacsDispatchEvent, data: dict | None = None) -> None: | |
| """Dispatch a signal with data.""" | |
| async_dispatcher_send(self.hass, signal, data) | |
| def set_active_categories(self) -> None: | |
| """Set the active categories.""" | |
| self.common.categories = set() | |
| for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN, HacsCategory.TEMPLATE): | |
| self.enable_hacs_category(HacsCategory(category)) | |
| if ( | |
| HacsCategory.PYTHON_SCRIPT in self.hass.config.components | |
| or self.repositories.category_downloaded(HacsCategory.PYTHON_SCRIPT) | |
| ): | |
| self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT) | |
| if self.hass.services.has_service( | |
| "frontend", "reload_themes" | |
| ) or self.repositories.category_downloaded(HacsCategory.THEME): | |
| self.enable_hacs_category(HacsCategory.THEME) | |
| if self.configuration.appdaemon: | |
| self.enable_hacs_category(HacsCategory.APPDAEMON) | |
| async def async_load_hacs_from_github(self, _=None) -> None: | |
| """Load HACS from GitHub.""" | |
| if self.status.inital_fetch_done: | |
| return | |
| try: | |
| repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION) | |
| should_recreate_entities = False | |
| if repository is None: | |
| should_recreate_entities = True | |
| await self.async_register_repository( | |
| repository_full_name=HacsGitHubRepo.INTEGRATION, | |
| category=HacsCategory.INTEGRATION, | |
| default=True, | |
| ) | |
| repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION) | |
| elif not self.status.startup: | |
| self.log.error("Scheduling update of hacs/integration") | |
| self.queue.add(repository.common_update()) | |
| if repository is None: | |
| raise HacsException("Unknown error") | |
| repository.data.installed = True | |
| repository.data.installed_version = self.integration.version.string | |
| repository.data.new = False | |
| repository.data.releases = True | |
| if should_recreate_entities: | |
| await self.async_recreate_entities() | |
| self.repository = repository.repository_object | |
| self.repositories.mark_default(repository) | |
| except HacsException as exception: | |
| if "403" in str(exception): | |
| self.log.critical( | |
| "GitHub API is ratelimited, or the token is wrong.", | |
| ) | |
| else: | |
| self.log.critical("Could not load HACS! - %s", exception) | |
| self.disable_hacs(HacsDisabledReason.LOAD_HACS) | |
| async def async_get_all_category_repositories(self, _=None) -> None: | |
| """Get all category repositories.""" | |
| if self.system.disabled: | |
| return | |
| self.log.info("Loading known repositories") | |
| await asyncio.gather( | |
| *[ | |
| self.async_get_category_repositories_experimental(category) | |
| for category in self.common.categories or [] | |
| ] | |
| ) | |
| async def async_get_category_repositories_experimental(self, category: str) -> None: | |
| """Update all category repositories.""" | |
| self.log.debug("Fetching updated content for %s", category) | |
| try: | |
| category_data = await self.data_client.get_data(category, validate=True) | |
| except HacsNotModifiedException: | |
| self.log.debug("No updates for %s", category) | |
| return | |
| except HacsException as exception: | |
| self.log.error("Could not update %s - %s", category, exception) | |
| return | |
| await self.data.register_unknown_repositories(category_data, category) | |
| for repo_id, repo_data in category_data.items(): | |
| repo_name = repo_data["full_name"] | |
| if self.common.renamed_repositories.get(repo_name): | |
| repo_name = self.common.renamed_repositories[repo_name] | |
| if self.repositories.is_removed(repo_name): | |
| continue | |
| if repo_name in self.common.archived_repositories: | |
| continue | |
| if repository := self.repositories.get_by_full_name(repo_name): | |
| self.repositories.set_repository_id(repository, repo_id) | |
| self.repositories.mark_default(repository) | |
| if repository.data.last_fetched is None or ( | |
| repository.data.last_fetched.timestamp() < repo_data["last_fetched"] | |
| ): | |
| repository.data.update_data({**dict(REPOSITORY_KEYS_TO_EXPORT), **repo_data}) | |
| if (manifest := repo_data.get("manifest")) is not None: | |
| repository.repository_manifest.update_data( | |
| {**dict(HACS_MANIFEST_KEYS_TO_EXPORT), **manifest} | |
| ) | |
| if category == "integration": | |
| self.status.inital_fetch_done = True | |
| if self.stage == HacsStage.STARTUP: | |
| for repository in self.repositories.list_all: | |
| if ( | |
| repository.data.category == category | |
| and not repository.data.installed | |
| and not self.repositories.is_default(repository.data.id) | |
| ): | |
| repository.logger.debug( | |
| "%s Unregister stale custom repository", repository.string | |
| ) | |
| self.repositories.unregister(repository) | |
| self.async_dispatch(HacsDispatchEvent.REPOSITORY, {}) | |
| self.coordinators[category].async_update_listeners() | |
| async def async_check_rate_limit(self, _=None) -> None: | |
| """Check rate limit.""" | |
| if not self.system.disabled or self.system.disabled_reason != HacsDisabledReason.RATE_LIMIT: | |
| return | |
| self.log.debug("Checking if ratelimit has lifted") | |
| can_update = await self.async_can_update() | |
| self.log.debug("Ratelimit indicate we can update %s", can_update) | |
| if can_update > 0: | |
| self.enable_hacs() | |
| await self.async_process_queue() | |
| async def async_process_queue(self, _=None) -> None: | |
| """Process the queue.""" | |
| if self.system.disabled: | |
| self.log.debug("HACS is disabled") | |
| return | |
| if not self.queue.has_pending_tasks: | |
| self.log.debug("Nothing in the queue") | |
| return | |
| if self.queue.running: | |
| self.log.debug("Queue is already running") | |
| return | |
| async def _handle_queue(): | |
| if not self.queue.has_pending_tasks: | |
| await self.data.async_write() | |
| return | |
| can_update = await self.async_can_update() | |
| self.log.debug( | |
| "Can update %s repositories, items in queue %s", | |
| can_update, | |
| self.queue.pending_tasks, | |
| ) | |
| if can_update != 0: | |
| try: | |
| await self.queue.execute(can_update) | |
| except HacsExecutionStillInProgress: | |
| return | |
| await _handle_queue() | |
| await _handle_queue() | |
| async def async_handle_removed_repositories(self, _=None) -> None: | |
| """Handle removed repositories.""" | |
| if self.system.disabled: | |
| return | |
| need_to_save = False | |
| self.log.info("Loading removed repositories") | |
| try: | |
| removed_repositories = await self.data_client.get_data("removed", validate=True) | |
| except HacsException: | |
| return | |
| for item in removed_repositories: | |
| removed = self.repositories.removed_repository(item["repository"]) | |
| removed.update_data(item) | |
| for removed in self.repositories.list_removed: | |
| if (repository := self.repositories.get_by_full_name(removed.repository)) is None: | |
| continue | |
| if repository.data.full_name in self.common.ignored_repositories: | |
| continue | |
| if repository.data.installed: | |
| if removed.removal_type != "critical": | |
| async_create_issue( | |
| hass=self.hass, | |
| domain=DOMAIN, | |
| issue_id=f"removed_{repository.data.id}", | |
| is_fixable=False, | |
| issue_domain=DOMAIN, | |
| severity=IssueSeverity.WARNING, | |
| translation_key="removed", | |
| translation_placeholders={ | |
| "name": repository.data.full_name, | |
| "reason": removed.reason, | |
| "repositry_id": repository.data.id, | |
| }, | |
| ) | |
| self.log.warning( | |
| "You have '%s' installed with HACS " | |
| "this repository has been removed from HACS, please consider removing it. " | |
| "Removal reason (%s)", | |
| repository.data.full_name, | |
| removed.reason, | |
| ) | |
| else: | |
| need_to_save = True | |
| repository.remove() | |
| if need_to_save: | |
| await self.data.async_write() | |
| async def async_update_downloaded_custom_repositories(self, _=None) -> None: | |
| """Execute the task.""" | |
| if self.system.disabled: | |
| 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) | |
| ): | |
| 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") | |
| async def async_handle_critical_repositories(self, _=None) -> None: | |
| """Handle critical repositories.""" | |
| critical_queue = QueueManager(hass=self.hass) | |
| instored = [] | |
| critical = [] | |
| was_installed = False | |
| try: | |
| critical = await self.data_client.get_data("critical", validate=True) | |
| except (GitHubNotModifiedException, HacsNotModifiedException): | |
| return | |
| except HacsException: | |
| pass | |
| if not critical: | |
| self.log.debug("No critical repositories") | |
| return | |
| stored_critical = await async_load_from_store(self.hass, "critical") | |
| for stored in stored_critical or []: | |
| instored.append(stored["repository"]) | |
| stored_critical = [] | |
| for repository in critical: | |
| removed_repo = self.repositories.removed_repository(repository["repository"]) | |
| removed_repo.removal_type = "critical" | |
| repo = self.repositories.get_by_full_name(repository["repository"]) | |
| stored = { | |
| "repository": repository["repository"], | |
| "reason": repository["reason"], | |
| "link": repository["link"], | |
| "acknowledged": True, | |
| } | |
| if repository["repository"] not in instored: | |
| if repo is not None and repo.data.installed: | |
| self.log.critical( | |
| "Removing repository %s, it is marked as critical", | |
| repository["repository"], | |
| ) | |
| was_installed = True | |
| stored["acknowledged"] = False | |
| # Remove from HACS | |
| critical_queue.add(repo.uninstall()) | |
| repo.remove() | |
| stored_critical.append(stored) | |
| removed_repo.update_data(stored) | |
| # Uninstall | |
| await critical_queue.execute() | |
| # Save to FS | |
| await async_save_to_store(self.hass, "critical", stored_critical) | |
| # Restart HASS | |
| if was_installed: | |
| self.log.critical("Restarting Home Assistant") | |
| self.hass.async_create_task(self.hass.async_stop(100)) | |
| async def async_setup_frontend_endpoint_plugin(self) -> None: | |
| """Setup the http endpoints for plugins if its not already handled.""" | |
| if self.status.active_frontend_endpoint_plugin or not await async_exists( | |
| self.hass, self.hass.config.path("www/community") | |
| ): | |
| return | |
| self.log.info("Setting up plugin endpoint") | |
| use_cache = self.core.lovelace_mode == "storage" | |
| self.log.info( | |
| "<HacsFrontend> %s mode, cache for /hacsfiles/: %s", | |
| self.core.lovelace_mode, | |
| use_cache, | |
| ) | |
| await async_register_static_path( | |
| self.hass, | |
| URL_BASE, | |
| self.hass.config.path("www/community"), | |
| cache_headers=use_cache, | |
| ) | |
| self.status.active_frontend_endpoint_plugin = True | |
Xet Storage Details
- Size:
- 41.2 kB
- Xet hash:
- b1f9286a56ffbcdab2d984b67e2042b5da8fe3b6a93ecce8d23b68ab8e3bbd62
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.