base.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987
  1. """Base HACS class."""
  2. from __future__ import annotations
  3. import asyncio
  4. from dataclasses import asdict, dataclass, field
  5. from datetime import timedelta
  6. import gzip
  7. import logging
  8. import math
  9. import os
  10. import pathlib
  11. import shutil
  12. from typing import TYPE_CHECKING, Any, Awaitable, Callable
  13. from aiogithubapi import (
  14. AIOGitHubAPIException,
  15. GitHub,
  16. GitHubAPI,
  17. GitHubAuthenticationException,
  18. GitHubException,
  19. GitHubNotModifiedException,
  20. GitHubRatelimitException,
  21. )
  22. from aiogithubapi.objects.repository import AIOGitHubAPIRepository
  23. from aiohttp.client import ClientSession, ClientTimeout
  24. from awesomeversion import AwesomeVersion
  25. from homeassistant.config_entries import ConfigEntry, ConfigEntryState
  26. from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform
  27. from homeassistant.core import HomeAssistant, callback
  28. from homeassistant.helpers.dispatcher import async_dispatcher_send
  29. from homeassistant.helpers.issue_registry import async_create_issue, IssueSeverity
  30. from homeassistant.loader import Integration
  31. from homeassistant.util import dt
  32. from .const import DOMAIN, TV
  33. from .enums import (
  34. ConfigurationType,
  35. HacsCategory,
  36. HacsDisabledReason,
  37. HacsDispatchEvent,
  38. HacsGitHubRepo,
  39. HacsStage,
  40. LovelaceMode,
  41. )
  42. from .exceptions import (
  43. AddonRepositoryException,
  44. HacsException,
  45. HacsExecutionStillInProgress,
  46. HacsExpectedException,
  47. HacsRepositoryArchivedException,
  48. HacsRepositoryExistException,
  49. HomeAssistantCoreRepositoryException,
  50. )
  51. from .repositories import RERPOSITORY_CLASSES
  52. from .utils.decode import decode_content
  53. from .utils.json import json_loads
  54. from .utils.logger import LOGGER
  55. from .utils.queue_manager import QueueManager
  56. from .utils.store import async_load_from_store, async_save_to_store
  57. if TYPE_CHECKING:
  58. from .repositories.base import HacsRepository
  59. from .utils.data import HacsData
  60. from .validate.manager import ValidationManager
  61. @dataclass
  62. class RemovedRepository:
  63. """Removed repository."""
  64. repository: str | None = None
  65. reason: str | None = None
  66. link: str | None = None
  67. removal_type: str = None # archived, not_compliant, critical, dev, broken
  68. acknowledged: bool = False
  69. def update_data(self, data: dict):
  70. """Update data of the repository."""
  71. for key in data:
  72. if data[key] is None:
  73. continue
  74. if key in (
  75. "reason",
  76. "link",
  77. "removal_type",
  78. "acknowledged",
  79. ):
  80. self.__setattr__(key, data[key])
  81. def to_json(self):
  82. """Return a JSON representation of the data."""
  83. return {
  84. "repository": self.repository,
  85. "reason": self.reason,
  86. "link": self.link,
  87. "removal_type": self.removal_type,
  88. "acknowledged": self.acknowledged,
  89. }
  90. @dataclass
  91. class HacsConfiguration:
  92. """HacsConfiguration class."""
  93. appdaemon_path: str = "appdaemon/apps/"
  94. appdaemon: bool = False
  95. config: dict[str, Any] = field(default_factory=dict)
  96. config_entry: ConfigEntry | None = None
  97. config_type: ConfigurationType | None = None
  98. country: str = "ALL"
  99. debug: bool = False
  100. dev: bool = False
  101. experimental: bool = False
  102. frontend_repo_url: str = ""
  103. frontend_repo: str = ""
  104. netdaemon_path: str = "netdaemon/apps/"
  105. netdaemon: bool = False
  106. plugin_path: str = "www/community/"
  107. python_script_path: str = "python_scripts/"
  108. python_script: bool = False
  109. release_limit: int = 5
  110. sidepanel_icon: str = "hacs:hacs"
  111. sidepanel_title: str = "HACS"
  112. theme_path: str = "themes/"
  113. theme: bool = False
  114. token: str = None
  115. def to_json(self) -> str:
  116. """Return a json string."""
  117. return asdict(self)
  118. def update_from_dict(self, data: dict) -> None:
  119. """Set attributes from dicts."""
  120. if not isinstance(data, dict):
  121. raise HacsException("Configuration is not valid.")
  122. for key in data:
  123. self.__setattr__(key, data[key])
  124. @dataclass
  125. class HacsCore:
  126. """HACS Core info."""
  127. config_path: pathlib.Path | None = None
  128. ha_version: AwesomeVersion | None = None
  129. lovelace_mode = LovelaceMode("yaml")
  130. @dataclass
  131. class HacsCommon:
  132. """Common for HACS."""
  133. categories: set[str] = field(default_factory=set)
  134. renamed_repositories: dict[str, str] = field(default_factory=dict)
  135. archived_repositories: list[str] = field(default_factory=list)
  136. ignored_repositories: list[str] = field(default_factory=list)
  137. skip: list[str] = field(default_factory=list)
  138. @dataclass
  139. class HacsStatus:
  140. """HacsStatus."""
  141. startup: bool = True
  142. new: bool = False
  143. @dataclass
  144. class HacsSystem:
  145. """HACS System info."""
  146. disabled_reason: HacsDisabledReason | None = None
  147. running: bool = False
  148. stage = HacsStage.SETUP
  149. action: bool = False
  150. @property
  151. def disabled(self) -> bool:
  152. """Return if HACS is disabled."""
  153. return self.disabled_reason is not None
  154. @dataclass
  155. class HacsRepositories:
  156. """HACS Repositories."""
  157. _default_repositories: set[str] = field(default_factory=set)
  158. _repositories: list[HacsRepository] = field(default_factory=list)
  159. _repositories_by_full_name: dict[str, HacsRepository] = field(default_factory=dict)
  160. _repositories_by_id: dict[str, HacsRepository] = field(default_factory=dict)
  161. _removed_repositories: list[RemovedRepository] = field(default_factory=list)
  162. @property
  163. def list_all(self) -> list[HacsRepository]:
  164. """Return a list of repositories."""
  165. return self._repositories
  166. @property
  167. def list_removed(self) -> list[RemovedRepository]:
  168. """Return a list of removed repositories."""
  169. return self._removed_repositories
  170. @property
  171. def list_downloaded(self) -> list[HacsRepository]:
  172. """Return a list of downloaded repositories."""
  173. return [repo for repo in self._repositories if repo.data.installed]
  174. def register(self, repository: HacsRepository, default: bool = False) -> None:
  175. """Register a repository."""
  176. repo_id = str(repository.data.id)
  177. if repo_id == "0":
  178. return
  179. if registered_repo := self._repositories_by_id.get(repo_id):
  180. if registered_repo.data.full_name == repository.data.full_name:
  181. return
  182. self.unregister(registered_repo)
  183. registered_repo.data.full_name = repository.data.full_name
  184. registered_repo.data.new = False
  185. repository = registered_repo
  186. if repository not in self._repositories:
  187. self._repositories.append(repository)
  188. self._repositories_by_id[repo_id] = repository
  189. self._repositories_by_full_name[repository.data.full_name_lower] = repository
  190. if default:
  191. self.mark_default(repository)
  192. def unregister(self, repository: HacsRepository) -> None:
  193. """Unregister a repository."""
  194. repo_id = str(repository.data.id)
  195. if repo_id == "0":
  196. return
  197. if not self.is_registered(repository_id=repo_id):
  198. return
  199. if self.is_default(repo_id):
  200. self._default_repositories.remove(repo_id)
  201. if repository in self._repositories:
  202. self._repositories.remove(repository)
  203. self._repositories_by_id.pop(repo_id, None)
  204. self._repositories_by_full_name.pop(repository.data.full_name_lower, None)
  205. def mark_default(self, repository: HacsRepository) -> None:
  206. """Mark a repository as default."""
  207. repo_id = str(repository.data.id)
  208. if repo_id == "0":
  209. return
  210. if not self.is_registered(repository_id=repo_id):
  211. return
  212. self._default_repositories.add(repo_id)
  213. def set_repository_id(self, repository, repo_id):
  214. """Update a repository id."""
  215. existing_repo_id = str(repository.data.id)
  216. if existing_repo_id == repo_id:
  217. return
  218. if existing_repo_id != "0":
  219. raise ValueError(
  220. f"The repo id for {repository.data.full_name_lower} "
  221. f"is already set to {existing_repo_id}"
  222. )
  223. repository.data.id = repo_id
  224. self.register(repository)
  225. def is_default(self, repository_id: str | None = None) -> bool:
  226. """Check if a repository is default."""
  227. if not repository_id:
  228. return False
  229. return repository_id in self._default_repositories
  230. def is_registered(
  231. self,
  232. repository_id: str | None = None,
  233. repository_full_name: str | None = None,
  234. ) -> bool:
  235. """Check if a repository is registered."""
  236. if repository_id is not None:
  237. return repository_id in self._repositories_by_id
  238. if repository_full_name is not None:
  239. return repository_full_name in self._repositories_by_full_name
  240. return False
  241. def is_downloaded(
  242. self,
  243. repository_id: str | None = None,
  244. repository_full_name: str | None = None,
  245. ) -> bool:
  246. """Check if a repository is registered."""
  247. if repository_id is not None:
  248. repo = self.get_by_id(repository_id)
  249. if repository_full_name is not None:
  250. repo = self.get_by_full_name(repository_full_name)
  251. if repo is None:
  252. return False
  253. return repo.data.installed
  254. def get_by_id(self, repository_id: str | None) -> HacsRepository | None:
  255. """Get repository by id."""
  256. if not repository_id:
  257. return None
  258. return self._repositories_by_id.get(str(repository_id))
  259. def get_by_full_name(self, repository_full_name: str | None) -> HacsRepository | None:
  260. """Get repository by full name."""
  261. if not repository_full_name:
  262. return None
  263. return self._repositories_by_full_name.get(repository_full_name.lower())
  264. def is_removed(self, repository_full_name: str) -> bool:
  265. """Check if a repository is removed."""
  266. return repository_full_name in (
  267. repository.repository for repository in self._removed_repositories
  268. )
  269. def removed_repository(self, repository_full_name: str) -> RemovedRepository:
  270. """Get repository by full name."""
  271. if self.is_removed(repository_full_name):
  272. if removed := [
  273. repository
  274. for repository in self._removed_repositories
  275. if repository.repository == repository_full_name
  276. ]:
  277. return removed[0]
  278. removed = RemovedRepository(repository=repository_full_name)
  279. self._removed_repositories.append(removed)
  280. return removed
  281. class HacsBase:
  282. """Base HACS class."""
  283. common = HacsCommon()
  284. configuration = HacsConfiguration()
  285. core = HacsCore()
  286. data: HacsData | None = None
  287. frontend_version: str | None = None
  288. github: GitHub | None = None
  289. githubapi: GitHubAPI | None = None
  290. hass: HomeAssistant | None = None
  291. integration: Integration | None = None
  292. log: logging.Logger = LOGGER
  293. queue: QueueManager | None = None
  294. recuring_tasks = []
  295. repositories: HacsRepositories = HacsRepositories()
  296. repository: AIOGitHubAPIRepository | None = None
  297. session: ClientSession | None = None
  298. stage: HacsStage | None = None
  299. status = HacsStatus()
  300. system = HacsSystem()
  301. validation: ValidationManager | None = None
  302. version: str | None = None
  303. @property
  304. def integration_dir(self) -> pathlib.Path:
  305. """Return the HACS integration dir."""
  306. return self.integration.file_path
  307. def set_stage(self, stage: HacsStage | None) -> None:
  308. """Set HACS stage."""
  309. if stage and self.stage == stage:
  310. return
  311. self.stage = stage
  312. if stage is not None:
  313. self.log.info("Stage changed: %s", self.stage)
  314. self.async_dispatch(HacsDispatchEvent.STAGE, {"stage": self.stage})
  315. def disable_hacs(self, reason: HacsDisabledReason) -> None:
  316. """Disable HACS."""
  317. if self.system.disabled_reason == reason:
  318. return
  319. self.system.disabled_reason = reason
  320. if reason != HacsDisabledReason.REMOVED:
  321. self.log.error("HACS is disabled - %s", reason)
  322. if (
  323. reason == HacsDisabledReason.INVALID_TOKEN
  324. and self.configuration.config_type == ConfigurationType.CONFIG_ENTRY
  325. ):
  326. self.configuration.config_entry.state = ConfigEntryState.SETUP_ERROR
  327. self.configuration.config_entry.reason = "Authentication failed"
  328. self.hass.add_job(self.configuration.config_entry.async_start_reauth, self.hass)
  329. def enable_hacs(self) -> None:
  330. """Enable HACS."""
  331. if self.system.disabled_reason is not None:
  332. self.system.disabled_reason = None
  333. self.log.info("HACS is enabled")
  334. def enable_hacs_category(self, category: HacsCategory) -> None:
  335. """Enable HACS category."""
  336. if category not in self.common.categories:
  337. self.log.info("Enable category: %s", category)
  338. self.common.categories.add(category)
  339. def disable_hacs_category(self, category: HacsCategory) -> None:
  340. """Disable HACS category."""
  341. if category in self.common.categories:
  342. self.log.info("Disabling category: %s", category)
  343. self.common.categories.pop(category)
  344. async def async_save_file(self, file_path: str, content: Any) -> bool:
  345. """Save a file."""
  346. def _write_file():
  347. with open(
  348. file_path,
  349. mode="w" if isinstance(content, str) else "wb",
  350. encoding="utf-8" if isinstance(content, str) else None,
  351. errors="ignore" if isinstance(content, str) else None,
  352. ) as file_handler:
  353. file_handler.write(content)
  354. # Create gz for .js files
  355. if os.path.isfile(file_path):
  356. if file_path.endswith(".js"):
  357. with open(file_path, "rb") as f_in:
  358. with gzip.open(file_path + ".gz", "wb") as f_out:
  359. shutil.copyfileobj(f_in, f_out)
  360. # LEGACY! Remove with 2.0
  361. if "themes" in file_path and file_path.endswith(".yaml"):
  362. filename = file_path.split("/")[-1]
  363. base = file_path.split("/themes/")[0]
  364. combined = f"{base}/themes/{filename}"
  365. if os.path.exists(combined):
  366. self.log.info("Removing old theme file %s", combined)
  367. os.remove(combined)
  368. try:
  369. await self.hass.async_add_executor_job(_write_file)
  370. except BaseException as error: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  371. self.log.error("Could not write data to %s - %s", file_path, error)
  372. return False
  373. return os.path.exists(file_path)
  374. async def async_can_update(self) -> int:
  375. """Helper to calculate the number of repositories we can fetch data for."""
  376. try:
  377. response = await self.async_github_api_method(self.githubapi.rate_limit)
  378. if ((limit := response.data.resources.core.remaining or 0) - 1000) >= 10:
  379. return math.floor((limit - 1000) / 10)
  380. reset = dt.as_local(dt.utc_from_timestamp(response.data.resources.core.reset))
  381. self.log.info(
  382. "GitHub API ratelimited - %s remaining (%s)",
  383. response.data.resources.core.remaining,
  384. f"{reset.hour}:{reset.minute}:{reset.second}",
  385. )
  386. self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
  387. except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  388. self.log.exception(exception)
  389. return 0
  390. async def async_github_get_hacs_default_file(self, filename: str) -> list:
  391. """Get the content of a default file."""
  392. response = await self.async_github_api_method(
  393. method=self.githubapi.repos.contents.get,
  394. repository=HacsGitHubRepo.DEFAULT,
  395. path=filename,
  396. )
  397. if response is None:
  398. return []
  399. return json_loads(decode_content(response.data.content))
  400. async def async_github_api_method(
  401. self,
  402. method: Callable[[], Awaitable[TV]],
  403. *args,
  404. raise_exception: bool = True,
  405. **kwargs,
  406. ) -> TV | None:
  407. """Call a GitHub API method"""
  408. _exception = None
  409. try:
  410. return await method(*args, **kwargs)
  411. except GitHubAuthenticationException as exception:
  412. self.disable_hacs(HacsDisabledReason.INVALID_TOKEN)
  413. _exception = exception
  414. except GitHubRatelimitException as exception:
  415. self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
  416. _exception = exception
  417. except GitHubNotModifiedException as exception:
  418. raise exception
  419. except GitHubException as exception:
  420. _exception = exception
  421. except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  422. self.log.exception(exception)
  423. _exception = exception
  424. if raise_exception and _exception is not None:
  425. raise HacsException(_exception)
  426. return None
  427. async def async_register_repository(
  428. self,
  429. repository_full_name: str,
  430. category: HacsCategory,
  431. *,
  432. check: bool = True,
  433. ref: str | None = None,
  434. repository_id: str | None = None,
  435. default: bool = False,
  436. ) -> None:
  437. """Register a repository."""
  438. if repository_full_name in self.common.skip:
  439. if repository_full_name != HacsGitHubRepo.INTEGRATION:
  440. raise HacsExpectedException(f"Skipping {repository_full_name}")
  441. if repository_full_name == "home-assistant/core":
  442. raise HomeAssistantCoreRepositoryException()
  443. if repository_full_name == "home-assistant/addons" or repository_full_name.startswith(
  444. "hassio-addons/"
  445. ):
  446. raise AddonRepositoryException()
  447. if category not in RERPOSITORY_CLASSES:
  448. raise HacsException(f"{category} is not a valid repository category.")
  449. if (renamed := self.common.renamed_repositories.get(repository_full_name)) is not None:
  450. repository_full_name = renamed
  451. repository: HacsRepository = RERPOSITORY_CLASSES[category](self, repository_full_name)
  452. if check:
  453. try:
  454. await repository.async_registration(ref)
  455. if self.status.new:
  456. repository.data.new = False
  457. if repository.validate.errors:
  458. self.common.skip.append(repository.data.full_name)
  459. if not self.status.startup:
  460. self.log.error("Validation for %s failed.", repository_full_name)
  461. if self.system.action:
  462. raise HacsException(
  463. f"::error:: Validation for {repository_full_name} failed."
  464. )
  465. return repository.validate.errors
  466. if self.system.action:
  467. repository.logger.info("%s Validation completed", repository.string)
  468. else:
  469. repository.logger.info("%s Registration completed", repository.string)
  470. except (HacsRepositoryExistException, HacsRepositoryArchivedException):
  471. return
  472. except AIOGitHubAPIException as exception:
  473. self.common.skip.append(repository.data.full_name)
  474. raise HacsException(
  475. f"Validation for {repository_full_name} failed with {exception}."
  476. ) from exception
  477. if repository_id is not None:
  478. repository.data.id = repository_id
  479. else:
  480. if self.hass is not None and ((check and repository.data.new) or self.status.new):
  481. self.async_dispatch(
  482. HacsDispatchEvent.REPOSITORY,
  483. {
  484. "action": "registration",
  485. "repository": repository.data.full_name,
  486. "repository_id": repository.data.id,
  487. },
  488. )
  489. self.repositories.register(repository, default)
  490. async def startup_tasks(self, _=None) -> None:
  491. """Tasks that are started after setup."""
  492. self.set_stage(HacsStage.STARTUP)
  493. try:
  494. repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
  495. if repository is None:
  496. await self.async_register_repository(
  497. repository_full_name=HacsGitHubRepo.INTEGRATION,
  498. category=HacsCategory.INTEGRATION,
  499. default=True,
  500. )
  501. repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
  502. if repository is None:
  503. raise HacsException("Unknown error")
  504. repository.data.installed = True
  505. repository.data.installed_version = self.integration.version.string
  506. repository.data.new = False
  507. repository.data.releases = True
  508. self.repository = repository.repository_object
  509. self.repositories.mark_default(repository)
  510. except HacsException as exception:
  511. if "403" in str(exception):
  512. self.log.critical(
  513. "GitHub API is ratelimited, or the token is wrong.",
  514. )
  515. else:
  516. self.log.critical("Could not load HACS! - %s", exception)
  517. self.disable_hacs(HacsDisabledReason.LOAD_HACS)
  518. if critical := await async_load_from_store(self.hass, "critical"):
  519. for repo in critical:
  520. if not repo["acknowledged"]:
  521. self.log.critical("URGENT!: Check the HACS panel!")
  522. self.hass.components.persistent_notification.create(
  523. title="URGENT!", message="**Check the HACS panel!**"
  524. )
  525. break
  526. self.recuring_tasks.append(
  527. self.hass.helpers.event.async_track_time_interval(
  528. self.async_get_all_category_repositories, timedelta(hours=3)
  529. )
  530. )
  531. self.recuring_tasks.append(
  532. self.hass.helpers.event.async_track_time_interval(
  533. self.async_update_all_repositories, timedelta(hours=25)
  534. )
  535. )
  536. self.recuring_tasks.append(
  537. self.hass.helpers.event.async_track_time_interval(
  538. self.async_check_rate_limit, timedelta(minutes=5)
  539. )
  540. )
  541. self.recuring_tasks.append(
  542. self.hass.helpers.event.async_track_time_interval(
  543. self.async_prosess_queue, timedelta(minutes=10)
  544. )
  545. )
  546. self.recuring_tasks.append(
  547. self.hass.helpers.event.async_track_time_interval(
  548. self.async_update_downloaded_repositories, timedelta(hours=2)
  549. )
  550. )
  551. self.recuring_tasks.append(
  552. self.hass.helpers.event.async_track_time_interval(
  553. self.async_handle_critical_repositories, timedelta(hours=2)
  554. )
  555. )
  556. self.hass.bus.async_listen_once(
  557. EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write
  558. )
  559. self.status.startup = False
  560. self.async_dispatch(HacsDispatchEvent.STATUS, {})
  561. await self.async_handle_removed_repositories()
  562. await self.async_get_all_category_repositories()
  563. await self.async_update_downloaded_repositories()
  564. self.set_stage(HacsStage.RUNNING)
  565. self.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True})
  566. await self.async_handle_critical_repositories()
  567. await self.async_prosess_queue()
  568. self.async_dispatch(HacsDispatchEvent.STATUS, {})
  569. async def async_download_file(self, url: str, *, headers: dict | None = None) -> bytes | None:
  570. """Download files, and return the content."""
  571. if url is None:
  572. return None
  573. if "tags/" in url:
  574. url = url.replace("tags/", "")
  575. self.log.debug("Downloading %s", url)
  576. timeouts = 0
  577. while timeouts < 5:
  578. try:
  579. request = await self.session.get(
  580. url=url,
  581. timeout=ClientTimeout(total=60),
  582. headers=headers,
  583. )
  584. # Make sure that we got a valid result
  585. if request.status == 200:
  586. return await request.read()
  587. raise HacsException(
  588. f"Got status code {request.status} when trying to download {url}"
  589. )
  590. except asyncio.TimeoutError:
  591. self.log.warning(
  592. "A timeout of 60! seconds was encountered while downloading %s, "
  593. "using over 60 seconds to download a single file is not normal. "
  594. "This is not a problem with HACS but how your host communicates with GitHub. "
  595. "Retrying up to 5 times to mask/hide your host/network problems to "
  596. "stop the flow of issues opened about it. "
  597. "Tries left %s",
  598. url,
  599. (4 - timeouts),
  600. )
  601. timeouts += 1
  602. await asyncio.sleep(1)
  603. continue
  604. except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  605. self.log.exception("Download failed - %s", exception)
  606. return None
  607. async def async_recreate_entities(self) -> None:
  608. """Recreate entities."""
  609. if self.configuration == ConfigurationType.YAML or not self.configuration.experimental:
  610. return
  611. platforms = [Platform.SENSOR, Platform.UPDATE]
  612. await self.hass.config_entries.async_unload_platforms(
  613. entry=self.configuration.config_entry,
  614. platforms=platforms,
  615. )
  616. self.hass.config_entries.async_setup_platforms(self.configuration.config_entry, platforms)
  617. @callback
  618. def async_dispatch(self, signal: HacsDispatchEvent, data: dict | None = None) -> None:
  619. """Dispatch a signal with data."""
  620. async_dispatcher_send(self.hass, signal, data)
  621. def set_active_categories(self) -> None:
  622. """Set the active categories."""
  623. self.common.categories = set()
  624. for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN):
  625. self.enable_hacs_category(HacsCategory(category))
  626. if HacsCategory.PYTHON_SCRIPT in self.hass.config.components:
  627. self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT)
  628. if self.hass.services.has_service("frontend", "reload_themes"):
  629. self.enable_hacs_category(HacsCategory.THEME)
  630. if self.configuration.appdaemon:
  631. self.enable_hacs_category(HacsCategory.APPDAEMON)
  632. if self.configuration.netdaemon:
  633. self.enable_hacs_category(HacsCategory.NETDAEMON)
  634. async def async_get_all_category_repositories(self, _=None) -> None:
  635. """Get all category repositories."""
  636. if self.system.disabled:
  637. return
  638. self.log.info("Loading known repositories")
  639. await asyncio.gather(
  640. *[
  641. self.async_get_category_repositories(HacsCategory(category))
  642. for category in self.common.categories or []
  643. ]
  644. )
  645. async def async_get_category_repositories(self, category: HacsCategory) -> None:
  646. """Get repositories from category."""
  647. if self.system.disabled:
  648. return
  649. try:
  650. repositories = await self.async_github_get_hacs_default_file(category)
  651. except HacsException:
  652. return
  653. for repo in repositories:
  654. if self.common.renamed_repositories.get(repo):
  655. repo = self.common.renamed_repositories[repo]
  656. if self.repositories.is_removed(repo):
  657. continue
  658. if repo in self.common.archived_repositories:
  659. continue
  660. repository = self.repositories.get_by_full_name(repo)
  661. if repository is not None:
  662. self.repositories.mark_default(repository)
  663. if self.status.new and self.configuration.dev:
  664. # Force update for new installations
  665. self.queue.add(repository.common_update())
  666. continue
  667. self.queue.add(
  668. self.async_register_repository(
  669. repository_full_name=repo,
  670. category=category,
  671. default=True,
  672. )
  673. )
  674. async def async_update_all_repositories(self, _=None) -> None:
  675. """Update all repositories."""
  676. if self.system.disabled:
  677. return
  678. self.log.debug("Starting recurring background task for all repositories")
  679. for repository in self.repositories.list_all:
  680. if repository.data.category in self.common.categories:
  681. self.queue.add(repository.common_update())
  682. self.async_dispatch(HacsDispatchEvent.REPOSITORY, {"action": "reload"})
  683. self.log.debug("Recurring background task for all repositories done")
  684. async def async_check_rate_limit(self, _=None) -> None:
  685. """Check rate limit."""
  686. if not self.system.disabled or self.system.disabled_reason != HacsDisabledReason.RATE_LIMIT:
  687. return
  688. self.log.debug("Checking if ratelimit has lifted")
  689. can_update = await self.async_can_update()
  690. self.log.debug("Ratelimit indicate we can update %s", can_update)
  691. if can_update > 0:
  692. self.enable_hacs()
  693. await self.async_prosess_queue()
  694. async def async_prosess_queue(self, _=None) -> None:
  695. """Process the queue."""
  696. if self.system.disabled:
  697. self.log.debug("HACS is disabled")
  698. return
  699. if not self.queue.has_pending_tasks:
  700. self.log.debug("Nothing in the queue")
  701. return
  702. if self.queue.running:
  703. self.log.debug("Queue is already running")
  704. return
  705. async def _handle_queue():
  706. if not self.queue.has_pending_tasks:
  707. await self.data.async_write()
  708. return
  709. can_update = await self.async_can_update()
  710. self.log.debug(
  711. "Can update %s repositories, " "items in queue %s",
  712. can_update,
  713. self.queue.pending_tasks,
  714. )
  715. if can_update != 0:
  716. try:
  717. await self.queue.execute(can_update)
  718. except HacsExecutionStillInProgress:
  719. return
  720. await _handle_queue()
  721. await _handle_queue()
  722. async def async_handle_removed_repositories(self, _=None) -> None:
  723. """Handle removed repositories."""
  724. if self.system.disabled:
  725. return
  726. need_to_save = False
  727. self.log.info("Loading removed repositories")
  728. try:
  729. removed_repositories = await self.async_github_get_hacs_default_file(
  730. HacsCategory.REMOVED
  731. )
  732. except HacsException:
  733. return
  734. for item in removed_repositories:
  735. removed = self.repositories.removed_repository(item["repository"])
  736. removed.update_data(item)
  737. for removed in self.repositories.list_removed:
  738. if (repository := self.repositories.get_by_full_name(removed.repository)) is None:
  739. continue
  740. if repository.data.full_name in self.common.ignored_repositories:
  741. continue
  742. if repository.data.installed:
  743. if removed.removal_type != "critical":
  744. if self.configuration.experimental:
  745. async_create_issue(
  746. hass=self.hass,
  747. domain=DOMAIN,
  748. issue_id=f"removed_{repository.data.id}",
  749. is_fixable=False,
  750. issue_domain=DOMAIN,
  751. severity=IssueSeverity.WARNING,
  752. translation_key="removed",
  753. translation_placeholders={
  754. "name": repository.data.full_name,
  755. "reason": removed.reason,
  756. "repositry_id": repository.data.id,
  757. },
  758. )
  759. self.log.warning(
  760. "You have '%s' installed with HACS "
  761. "this repository has been removed from HACS, please consider removing it. "
  762. "Removal reason (%s)",
  763. repository.data.full_name,
  764. removed.reason,
  765. )
  766. else:
  767. need_to_save = True
  768. repository.remove()
  769. if need_to_save:
  770. await self.data.async_write()
  771. async def async_update_downloaded_repositories(self, _=None) -> None:
  772. """Execute the task."""
  773. if self.system.disabled:
  774. return
  775. self.log.info("Starting recurring background task for downloaded repositories")
  776. for repository in self.repositories.list_downloaded:
  777. if repository.data.category in self.common.categories:
  778. self.queue.add(repository.update_repository(ignore_issues=True))
  779. self.log.debug("Recurring background task for downloaded repositories done")
  780. async def async_handle_critical_repositories(self, _=None) -> None:
  781. """Handle critical repositories."""
  782. critical_queue = QueueManager(hass=self.hass)
  783. instored = []
  784. critical = []
  785. was_installed = False
  786. try:
  787. critical = await self.async_github_get_hacs_default_file("critical")
  788. except GitHubNotModifiedException:
  789. return
  790. except HacsException:
  791. pass
  792. if not critical:
  793. self.log.debug("No critical repositories")
  794. return
  795. stored_critical = await async_load_from_store(self.hass, "critical")
  796. for stored in stored_critical or []:
  797. instored.append(stored["repository"])
  798. stored_critical = []
  799. for repository in critical:
  800. removed_repo = self.repositories.removed_repository(repository["repository"])
  801. removed_repo.removal_type = "critical"
  802. repo = self.repositories.get_by_full_name(repository["repository"])
  803. stored = {
  804. "repository": repository["repository"],
  805. "reason": repository["reason"],
  806. "link": repository["link"],
  807. "acknowledged": True,
  808. }
  809. if repository["repository"] not in instored:
  810. if repo is not None and repo.data.installed:
  811. self.log.critical(
  812. "Removing repository %s, it is marked as critical",
  813. repository["repository"],
  814. )
  815. was_installed = True
  816. stored["acknowledged"] = False
  817. # Remove from HACS
  818. critical_queue.add(repo.uninstall())
  819. repo.remove()
  820. stored_critical.append(stored)
  821. removed_repo.update_data(stored)
  822. # Uninstall
  823. await critical_queue.execute()
  824. # Save to FS
  825. await async_save_to_store(self.hass, "critical", stored_critical)
  826. # Restart HASS
  827. if was_installed:
  828. self.log.critical("Restarting Home Assistant")
  829. self.hass.async_create_task(self.hass.async_stop(100))