base.py 34 KB

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