Initialize docker stack repo

This commit is contained in:
g4st3r
2026-03-07 17:28:59 +00:00
commit 319d4eeb3e
458 changed files with 67215 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
"""HACS gives you a powerful UI to handle downloads of all your custom needs.
For more details about this integration, please refer to the documentation at
https://hacs.xyz/
"""
from __future__ import annotations
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
from aiogithubapi.const import ACCEPT_HEADERS
from awesomeversion import AwesomeVersion
from homeassistant.components.frontend import async_remove_panel
from homeassistant.components.lovelace.system_health import system_health_info
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform, __version__ as HAVERSION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_start
from homeassistant.loader import async_get_integration
from .base import HacsBase
from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION, STARTUP
from .data_client import HacsDataClient
from .enums import HacsDisabledReason, HacsStage, LovelaceMode
from .frontend import async_register_frontend
from .utils.data import HacsData
from .utils.queue_manager import QueueManager
from .utils.version import version_left_higher_or_equal_then_right
from .websocket import async_register_websocket_commands
PLATFORMS = [Platform.SWITCH, Platform.UPDATE]
async def _async_initialize_integration(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> bool:
"""Initialize the integration"""
hass.data[DOMAIN] = hacs = HacsBase()
hacs.enable_hacs()
if config_entry.source == SOURCE_IMPORT:
# Import is not supported
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
return False
hacs.configuration.update_from_dict(
{
"config_entry": config_entry,
**config_entry.data,
**config_entry.options,
},
)
integration = await async_get_integration(hass, DOMAIN)
hacs.set_stage(None)
hacs.log.info(STARTUP, integration.version)
clientsession = async_get_clientsession(hass)
hacs.integration = integration
hacs.version = integration.version
hacs.configuration.dev = integration.version == "0.0.0"
hacs.hass = hass
hacs.queue = QueueManager(hass=hass)
hacs.data = HacsData(hacs=hacs)
hacs.data_client = HacsDataClient(
session=clientsession,
client_name=f"HACS/{integration.version}",
)
hacs.system.running = True
hacs.session = clientsession
hacs.core.lovelace_mode = LovelaceMode.YAML
try:
lovelace_info = await system_health_info(hacs.hass)
hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml"))
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
# If this happens, the users YAML is not valid, we assume YAML mode
pass
hacs.core.config_path = hacs.hass.config.path()
if hacs.core.ha_version is None:
hacs.core.ha_version = AwesomeVersion(HAVERSION)
## Legacy GitHub client
hacs.github = GitHub(
hacs.configuration.token,
clientsession,
headers={
"User-Agent": f"HACS/{hacs.version}",
"Accept": ACCEPT_HEADERS["preview"],
},
)
## New GitHub client
hacs.githubapi = GitHubAPI(
token=hacs.configuration.token,
session=clientsession,
**{"client_name": f"HACS/{hacs.version}"},
)
async def async_startup():
"""HACS startup tasks."""
hacs.enable_hacs()
try:
import custom_components.custom_updater
except ImportError:
pass
else:
hacs.log.critical(
"HACS cannot be used with custom_updater. "
"To use HACS you need to remove custom_updater from `custom_components`",
)
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False
if not version_left_higher_or_equal_then_right(
hacs.core.ha_version.string,
MINIMUM_HA_VERSION,
):
hacs.log.critical(
"You need HA version %s or newer to use this integration.",
MINIMUM_HA_VERSION,
)
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False
if not await hacs.data.restore():
hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False
hacs.set_active_categories()
async_register_websocket_commands(hass)
await async_register_frontend(hass, hacs)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
hacs.set_stage(HacsStage.SETUP)
if hacs.system.disabled:
return False
hacs.set_stage(HacsStage.WAITING)
hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
# Schedule startup tasks
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
return not hacs.system.disabled
async def async_try_startup(_=None):
"""Startup wrapper for yaml config."""
try:
startup_result = await async_startup()
except AIOGitHubAPIException:
startup_result = False
if not startup_result:
if hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN:
hacs.log.info("Could not setup HACS, trying again in 15 min")
async_call_later(hass, 900, async_try_startup)
return
hacs.enable_hacs()
await async_try_startup()
# Remove old (v0-v1) sensor if it exists, can be removed in v3
er = async_get_entity_registry(hass)
if old_sensor := er.async_get_entity_id("sensor", DOMAIN, HACS_SYSTEM_ID):
er.async_remove(old_sensor)
# Mischief managed!
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry))
setup_result = await _async_initialize_integration(hass=hass, config_entry=config_entry)
hacs: HacsBase = hass.data[DOMAIN]
return setup_result and not hacs.system.disabled
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
hacs: HacsBase = hass.data[DOMAIN]
if hacs.queue.has_pending_tasks:
hacs.log.warning("Pending tasks, can not unload, try again later.")
return False
# Clear out pending queue
hacs.queue.clear()
for task in hacs.recurring_tasks:
# Cancel all pending tasks
task()
# Store data
await hacs.data.async_write(force=True)
try:
if hass.data.get("frontend_panels", {}).get("hacs"):
hacs.log.info("Removing sidepanel")
async_remove_panel(hass, "hacs")
except AttributeError:
pass
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
hacs.set_stage(None)
hacs.disable_hacs(HacsDisabledReason.REMOVED)
hass.data.pop(DOMAIN, None)
return unload_ok
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Reload the HACS config entry."""
if not await async_unload_entry(hass, config_entry):
return
await async_setup_entry(hass, config_entry)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
"""Adds config flow for HACS."""
from __future__ import annotations
import asyncio
from contextlib import suppress
from typing import TYPE_CHECKING
from aiogithubapi import (
GitHubDeviceAPI,
GitHubException,
GitHubLoginDeviceModel,
GitHubLoginOauthModel,
)
from aiogithubapi.common.const import OAUTH_USER_LOGIN
from awesomeversion import AwesomeVersion
from homeassistant.config_entries import ConfigFlow, OptionsFlow
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import callback
from homeassistant.data_entry_flow import UnknownFlow
from homeassistant.helpers import aiohttp_client
from homeassistant.loader import async_get_integration
import voluptuous as vol
from .base import HacsBase
from .const import CLIENT_ID, DOMAIN, LOCALE, MINIMUM_HA_VERSION
from .utils.configuration_schema import (
APPDAEMON,
COUNTRY,
SIDEPANEL_ICON,
SIDEPANEL_TITLE,
)
from .utils.logger import LOGGER
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
class HacsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for HACS."""
VERSION = 1
hass: HomeAssistant
activation_task: asyncio.Task | None = None
device: GitHubDeviceAPI | None = None
_registration: GitHubLoginDeviceModel | None = None
_activation: GitHubLoginOauthModel | None = None
_reauth: bool = False
def __init__(self) -> None:
"""Initialize."""
self._errors = {}
self._user_input = {}
async def async_step_user(self, user_input):
"""Handle a flow initialized by the user."""
self._errors = {}
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if self.hass.data.get(DOMAIN):
return self.async_abort(reason="single_instance_allowed")
if user_input:
if [x for x in user_input if x.startswith("acc_") and not user_input[x]]:
self._errors["base"] = "acc"
return await self._show_config_form(user_input)
self._user_input = user_input
return await self.async_step_device(user_input)
# Initial form
return await self._show_config_form(user_input)
async def async_step_device(self, _user_input):
"""Handle device steps."""
async def _wait_for_activation() -> None:
try:
response = await self.device.activation(device_code=self._registration.device_code)
self._activation = response.data
finally:
async def _progress():
with suppress(UnknownFlow):
await self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
if not self.device:
integration = await async_get_integration(self.hass, DOMAIN)
self.device = GitHubDeviceAPI(
client_id=CLIENT_ID,
session=aiohttp_client.async_get_clientsession(self.hass),
**{"client_name": f"HACS/{integration.version}"},
)
try:
response = await self.device.register()
self._registration = response.data
except GitHubException as exception:
LOGGER.exception(exception)
return self.async_abort(reason="could_not_register")
if self.activation_task is None:
self.activation_task = self.hass.async_create_task(_wait_for_activation())
if self.activation_task.done():
if (exception := self.activation_task.exception()) is not None:
LOGGER.exception(exception)
return self.async_show_progress_done(next_step_id="could_not_register")
return self.async_show_progress_done(next_step_id="device_done")
show_progress_kwargs = {
"step_id": "device",
"progress_action": "wait_for_device",
"description_placeholders": {
"url": OAUTH_USER_LOGIN,
"code": self._registration.user_code,
},
"progress_task": self.activation_task,
}
return self.async_show_progress(**show_progress_kwargs)
async def _show_config_form(self, user_input):
"""Show the configuration form to edit location data."""
if not user_input:
user_input = {}
if AwesomeVersion(HAVERSION) < MINIMUM_HA_VERSION:
return self.async_abort(
reason="min_ha_version",
description_placeholders={"version": MINIMUM_HA_VERSION},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("acc_logs", default=user_input.get("acc_logs", False)): bool,
vol.Required("acc_addons", default=user_input.get("acc_addons", False)): bool,
vol.Required(
"acc_untested", default=user_input.get("acc_untested", False)
): bool,
vol.Required("acc_disable", default=user_input.get("acc_disable", False)): bool,
}
),
errors=self._errors,
)
async def async_step_device_done(self, user_input: dict[str, bool] | None = None):
"""Handle device steps"""
if self._reauth:
existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
self.hass.config_entries.async_update_entry(
existing_entry, data={**existing_entry.data, "token": self._activation.access_token}
)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(
title="",
data={
"token": self._activation.access_token,
},
options={
"experimental": True,
},
)
async def async_step_could_not_register(self, _user_input=None):
"""Handle issues that need transition await from progress step."""
return self.async_abort(reason="could_not_register")
async def async_step_reauth(self, _user_input=None):
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(self, user_input=None):
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
self._reauth = True
return await self.async_step_device(None)
@staticmethod
@callback
def async_get_options_flow(config_entry):
return HacsOptionsFlowHandler(config_entry)
class HacsOptionsFlowHandler(OptionsFlow):
"""HACS config flow options handler."""
def __init__(self, config_entry):
"""Initialize HACS options flow."""
if AwesomeVersion(HAVERSION) < "2024.11.99":
self.config_entry = config_entry
async def async_step_init(self, _user_input=None):
"""Manage the options."""
return await self.async_step_user()
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
hacs: HacsBase = self.hass.data.get(DOMAIN)
if user_input is not None:
return self.async_create_entry(title="", data={**user_input, "experimental": True})
if hacs is None or hacs.configuration is None:
return self.async_abort(reason="not_setup")
if hacs.queue.has_pending_tasks:
return self.async_abort(reason="pending_tasks")
schema = {
vol.Optional(SIDEPANEL_TITLE, default=hacs.configuration.sidepanel_title): str,
vol.Optional(SIDEPANEL_ICON, default=hacs.configuration.sidepanel_icon): str,
vol.Optional(COUNTRY, default=hacs.configuration.country): vol.In(LOCALE),
vol.Optional(APPDAEMON, default=hacs.configuration.appdaemon): bool,
}
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))

View File

@@ -0,0 +1,294 @@
"""Constants for HACS"""
from typing import TypeVar
from aiogithubapi.common.const import ACCEPT_HEADERS
NAME_SHORT = "HACS"
DOMAIN = "hacs"
CLIENT_ID = "395a8e669c5de9f7c6e8"
MINIMUM_HA_VERSION = "2024.4.1"
URL_BASE = "/hacsfiles"
TV = TypeVar("TV")
PACKAGE_NAME = "custom_components.hacs"
DEFAULT_CONCURRENT_TASKS = 15
DEFAULT_CONCURRENT_BACKOFF_TIME = 1
HACS_REPOSITORY_ID = "172733314"
HACS_ACTION_GITHUB_API_HEADERS = {
"User-Agent": "HACS/action",
"Accept": ACCEPT_HEADERS["preview"],
}
VERSION_STORAGE = "6"
STORENAME = "hacs"
HACS_SYSTEM_ID = "0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd"
STARTUP = """
-------------------------------------------------------------------
HACS (Home Assistant Community Store)
Version: %s
This is a custom integration
If you have any issues with this you need to open an issue here:
https://github.com/hacs/integration/issues
-------------------------------------------------------------------
"""
LOCALE = [
"ALL",
"AF",
"AL",
"DZ",
"AS",
"AD",
"AO",
"AI",
"AQ",
"AG",
"AR",
"AM",
"AW",
"AU",
"AT",
"AZ",
"BS",
"BH",
"BD",
"BB",
"BY",
"BE",
"BZ",
"BJ",
"BM",
"BT",
"BO",
"BQ",
"BA",
"BW",
"BV",
"BR",
"IO",
"BN",
"BG",
"BF",
"BI",
"KH",
"CM",
"CA",
"CV",
"KY",
"CF",
"TD",
"CL",
"CN",
"CX",
"CC",
"CO",
"KM",
"CG",
"CD",
"CK",
"CR",
"HR",
"CU",
"CW",
"CY",
"CZ",
"CI",
"DK",
"DJ",
"DM",
"DO",
"EC",
"EG",
"SV",
"GQ",
"ER",
"EE",
"ET",
"FK",
"FO",
"FJ",
"FI",
"FR",
"GF",
"PF",
"TF",
"GA",
"GM",
"GE",
"DE",
"GH",
"GI",
"GR",
"GL",
"GD",
"GP",
"GU",
"GT",
"GG",
"GN",
"GW",
"GY",
"HT",
"HM",
"VA",
"HN",
"HK",
"HU",
"IS",
"IN",
"ID",
"IR",
"IQ",
"IE",
"IM",
"IL",
"IT",
"JM",
"JP",
"JE",
"JO",
"KZ",
"KE",
"KI",
"KP",
"KR",
"KW",
"KG",
"LA",
"LV",
"LB",
"LS",
"LR",
"LY",
"LI",
"LT",
"LU",
"MO",
"MK",
"MG",
"MW",
"MY",
"MV",
"ML",
"MT",
"MH",
"MQ",
"MR",
"MU",
"YT",
"MX",
"FM",
"MD",
"MC",
"MN",
"ME",
"MS",
"MA",
"MZ",
"MM",
"NA",
"NR",
"NP",
"NL",
"NC",
"NZ",
"NI",
"NE",
"NG",
"NU",
"NF",
"MP",
"NO",
"OM",
"PK",
"PW",
"PS",
"PA",
"PG",
"PY",
"PE",
"PH",
"PN",
"PL",
"PT",
"PR",
"QA",
"RO",
"RU",
"RW",
"RE",
"BL",
"SH",
"KN",
"LC",
"MF",
"PM",
"VC",
"WS",
"SM",
"ST",
"SA",
"SN",
"RS",
"SC",
"SL",
"SG",
"SX",
"SK",
"SI",
"SB",
"SO",
"ZA",
"GS",
"SS",
"ES",
"LK",
"SD",
"SR",
"SJ",
"SZ",
"SE",
"CH",
"SY",
"TW",
"TJ",
"TZ",
"TH",
"TL",
"TG",
"TK",
"TO",
"TT",
"TN",
"TR",
"TM",
"TC",
"TV",
"UG",
"UA",
"AE",
"GB",
"US",
"UM",
"UY",
"UZ",
"VU",
"VE",
"VN",
"VG",
"VI",
"WF",
"EH",
"YE",
"ZM",
"ZW",
]

View File

@@ -0,0 +1,38 @@
"""Coordinator to trigger entity updates."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
class HacsUpdateCoordinator(BaseDataUpdateCoordinatorProtocol):
"""Dispatch updates to update entities."""
def __init__(self) -> None:
"""Initialize."""
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
@callback
def async_add_listener(
self, update_callback: CALLBACK_TYPE, context: Any = None
) -> Callable[[], None]:
"""Listen for data updates."""
@callback
def remove_listener() -> None:
"""Remove update listener."""
self._listeners.pop(remove_listener)
self._listeners[remove_listener] = (update_callback, context)
return remove_listener
@callback
def async_update_listeners(self) -> None:
"""Update all registered listeners."""
for update_callback, _ in list(self._listeners.values()):
update_callback()

View File

@@ -0,0 +1,98 @@
"""HACS Data client."""
from __future__ import annotations
import asyncio
from typing import Any
from aiohttp import ClientSession, ClientTimeout
import voluptuous as vol
from .exceptions import HacsException, HacsNotModifiedException
from .utils.logger import LOGGER
from .utils.validate import (
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
VALIDATE_FETCHED_V2_REPO_DATA,
)
CRITICAL_REMOVED_VALIDATORS = {
"critical": VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
"removed": VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
}
class HacsDataClient:
"""HACS Data client."""
def __init__(self, session: ClientSession, client_name: str) -> None:
"""Initialize."""
self._client_name = client_name
self._etags = {}
self._session = session
async def _do_request(
self,
filename: str,
section: str | None = None,
) -> dict[str, dict[str, Any]] | list[str]:
"""Do request."""
endpoint = "/".join([v for v in [section, filename] if v is not None])
try:
response = await self._session.get(
f"https://data-v2.hacs.xyz/{endpoint}",
timeout=ClientTimeout(total=60),
headers={
"User-Agent": self._client_name,
"If-None-Match": self._etags.get(endpoint, ""),
},
)
if response.status == 304:
raise HacsNotModifiedException() from None
response.raise_for_status()
except HacsNotModifiedException:
raise
except TimeoutError:
raise HacsException("Timeout of 60s reached") from None
except Exception as exception:
raise HacsException(f"Error fetching data from HACS: {exception}") from exception
self._etags[endpoint] = response.headers.get("etag")
return await response.json()
async def get_data(self, section: str | None, *, validate: bool) -> dict[str, dict[str, Any]]:
"""Get data."""
data = await self._do_request(filename="data.json", section=section)
if not validate:
return data
if section in VALIDATE_FETCHED_V2_REPO_DATA:
validated = {}
for key, repo_data in data.items():
try:
validated[key] = VALIDATE_FETCHED_V2_REPO_DATA[section](repo_data)
except vol.Invalid as exception:
LOGGER.info(
"Got invalid data for %s (%s)", repo_data.get("full_name", key), exception
)
continue
return validated
if not (validator := CRITICAL_REMOVED_VALIDATORS.get(section)):
raise ValueError(f"Do not know how to validate {section}")
validated = []
for repo_data in data:
try:
validated.append(validator(repo_data))
except vol.Invalid as exception:
LOGGER.info("Got invalid data for %s (%s)", section, exception)
continue
return validated
async def get_repositories(self, section: str) -> list[str]:
"""Get repositories."""
return await self._do_request(filename="repositories.json", section=section)

View File

@@ -0,0 +1,80 @@
"""Diagnostics support for HACS."""
from __future__ import annotations
from typing import Any
from aiogithubapi import GitHubException
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .base import HacsBase
from .const import DOMAIN
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
hacs: HacsBase = hass.data[DOMAIN]
data = {
"entry": entry.as_dict(),
"hacs": {
"stage": hacs.stage,
"version": hacs.version,
"disabled_reason": hacs.system.disabled_reason,
"new": hacs.status.new,
"startup": hacs.status.startup,
"categories": hacs.common.categories,
"renamed_repositories": hacs.common.renamed_repositories,
"archived_repositories": hacs.common.archived_repositories,
"ignored_repositories": hacs.common.ignored_repositories,
"lovelace_mode": hacs.core.lovelace_mode,
"configuration": {},
},
"custom_repositories": [
repo.data.full_name
for repo in hacs.repositories.list_all
if not hacs.repositories.is_default(str(repo.data.id))
],
"repositories": [],
}
for key in (
"appdaemon",
"country",
"debug",
"dev",
"python_script",
"release_limit",
"theme",
):
data["hacs"]["configuration"][key] = getattr(hacs.configuration, key, None)
for repository in hacs.repositories.list_downloaded:
data["repositories"].append(
{
"data": repository.data.to_json(),
"integration_manifest": repository.integration_manifest,
"repository_manifest": repository.repository_manifest.to_dict(),
"ref": repository.ref,
"paths": {
"localpath": repository.localpath.replace(hacs.core.config_path, "/config"),
"local": repository.content.path.local.replace(
hacs.core.config_path, "/config"
),
"remote": repository.content.path.remote,
},
}
)
try:
rate_limit_response = await hacs.githubapi.rate_limit()
data["rate_limit"] = rate_limit_response.data.as_dict
except GitHubException as exception:
data["rate_limit"] = str(exception)
return async_redact_data(data, ("token",))

View File

@@ -0,0 +1,143 @@
"""HACS Base entities."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity
from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
from .coordinator import HacsUpdateCoordinator
from .enums import HacsDispatchEvent, HacsGitHubRepo
if TYPE_CHECKING:
from .base import HacsBase
from .repositories.base import HacsRepository
def system_info(hacs: HacsBase) -> dict:
"""Return system info."""
return {
"identifiers": {(DOMAIN, HACS_SYSTEM_ID)},
"name": NAME_SHORT,
"manufacturer": "hacs.xyz",
"model": "",
"sw_version": str(hacs.version),
"configuration_url": "homeassistant://hacs",
"entry_type": DeviceEntryType.SERVICE,
}
class HacsBaseEntity(Entity):
"""Base HACS entity."""
repository: HacsRepository | None = None
_attr_should_poll = False
def __init__(self, hacs: HacsBase) -> None:
"""Initialize."""
self.hacs = hacs
class HacsDispatcherEntity(HacsBaseEntity):
"""Base HACS entity listening to dispatcher signals."""
async def async_added_to_hass(self) -> None:
"""Register for status events."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
HacsDispatchEvent.REPOSITORY,
self._update_and_write_state,
)
)
@callback
def _update(self) -> None:
"""Update the sensor."""
async def async_update(self) -> None:
"""Manual updates of the sensor."""
self._update()
@callback
def _update_and_write_state(self, _: Any) -> None:
"""Update the entity and write state."""
self._update()
self.async_write_ha_state()
class HacsSystemEntity(HacsDispatcherEntity):
"""Base system entity."""
_attr_icon = "hacs:hacs"
_attr_unique_id = HACS_SYSTEM_ID
@property
def device_info(self) -> dict[str, any]:
"""Return device information about HACS."""
return system_info(self.hacs)
class HacsRepositoryEntity(BaseCoordinatorEntity[HacsUpdateCoordinator], HacsBaseEntity):
"""Base repository entity."""
def __init__(
self,
hacs: HacsBase,
repository: HacsRepository,
) -> None:
"""Initialize."""
BaseCoordinatorEntity.__init__(self, hacs.coordinators[repository.data.category])
HacsBaseEntity.__init__(self, hacs=hacs)
self.repository = repository
self._attr_unique_id = str(repository.data.id)
self._repo_last_fetched = repository.data.last_fetched
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.hacs.repositories.is_downloaded(repository_id=str(self.repository.data.id))
@property
def device_info(self) -> dict[str, any]:
"""Return device information about HACS."""
if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION:
return system_info(self.hacs)
def _manufacturer():
if authors := self.repository.data.authors:
return ", ".join(author.replace("@", "") for author in authors)
return self.repository.data.full_name.split("/")[0]
return {
"identifiers": {(DOMAIN, str(self.repository.data.id))},
"name": self.repository.display_name,
"model": self.repository.data.category,
"manufacturer": _manufacturer(),
"configuration_url": f"homeassistant://hacs/repository/{self.repository.data.id}",
"entry_type": DeviceEntryType.SERVICE,
}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if (
self._repo_last_fetched is not None
and self.repository.data.last_fetched is not None
and self._repo_last_fetched >= self.repository.data.last_fetched
):
return
self._repo_last_fetched = self.repository.data.last_fetched
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
"""

View File

@@ -0,0 +1,71 @@
"""Helper constants."""
# pylint: disable=missing-class-docstring
from enum import StrEnum
class HacsGitHubRepo(StrEnum):
"""HacsGitHubRepo."""
DEFAULT = "hacs/default"
INTEGRATION = "hacs/integration"
class HacsCategory(StrEnum):
APPDAEMON = "appdaemon"
INTEGRATION = "integration"
LOVELACE = "lovelace"
PLUGIN = "plugin" # Kept for legacy purposes
PYTHON_SCRIPT = "python_script"
TEMPLATE = "template"
THEME = "theme"
REMOVED = "removed"
def __str__(self):
return str(self.value)
class HacsDispatchEvent(StrEnum):
"""HacsDispatchEvent."""
CONFIG = "hacs_dispatch_config"
ERROR = "hacs_dispatch_error"
RELOAD = "hacs_dispatch_reload"
REPOSITORY = "hacs_dispatch_repository"
REPOSITORY_DOWNLOAD_PROGRESS = "hacs_dispatch_repository_download_progress"
STAGE = "hacs_dispatch_stage"
STARTUP = "hacs_dispatch_startup"
STATUS = "hacs_dispatch_status"
class RepositoryFile(StrEnum):
"""Repository file names."""
HACS_JSON = "hacs.json"
MAINIFEST_JSON = "manifest.json"
class LovelaceMode(StrEnum):
"""Lovelace Modes."""
STORAGE = "storage"
AUTO = "auto"
AUTO_GEN = "auto-gen"
YAML = "yaml"
class HacsStage(StrEnum):
SETUP = "setup"
STARTUP = "startup"
WAITING = "waiting"
RUNNING = "running"
BACKGROUND = "background"
class HacsDisabledReason(StrEnum):
RATE_LIMIT = "rate_limit"
REMOVED = "removed"
INVALID_TOKEN = "invalid_token"
CONSTRAINS = "constrains"
LOAD_HACS = "load_hacs"
RESTORE = "restore"

View File

@@ -0,0 +1,49 @@
"""Custom Exceptions for HACS."""
class HacsException(Exception):
"""Super basic."""
class HacsRepositoryArchivedException(HacsException):
"""For repositories that are archived."""
class HacsNotModifiedException(HacsException):
"""For responses that are not modified."""
class HacsExpectedException(HacsException):
"""For stuff that are expected."""
class HacsRepositoryExistException(HacsException):
"""For repositories that are already exist."""
class HacsExecutionStillInProgress(HacsException):
"""Exception to raise if execution is still in progress."""
class AddonRepositoryException(HacsException):
"""Exception to raise when user tries to add add-on repository."""
exception_message = (
"The repository does not seem to be a integration, "
"but an add-on repository. HACS does not manage add-ons."
)
def __init__(self) -> None:
super().__init__(self.exception_message)
class HomeAssistantCoreRepositoryException(HacsException):
"""Exception to raise when user tries to add the home-assistant/core repository."""
exception_message = (
"You can not add homeassistant/core, to use core integrations "
"check the Home Assistant documentation for how to add them."
)
def __init__(self) -> None:
super().__init__(self.exception_message)

View File

@@ -0,0 +1,67 @@
"""Starting setup task: Frontend."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from homeassistant.components.frontend import (
add_extra_js_url,
async_register_built_in_panel,
)
from .const import DOMAIN, URL_BASE
from .hacs_frontend import VERSION as FE_VERSION, locate_dir
from .utils.workarounds import async_register_static_path
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .base import HacsBase
async def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
"""Register the frontend."""
# Register frontend
if hacs.configuration.dev and (frontend_path := os.getenv("HACS_FRONTEND_DIR")):
hacs.log.warning(
"<HacsFrontend> Frontend development mode enabled. Do not run in production!"
)
await async_register_static_path(
hass, f"{URL_BASE}/frontend", f"{frontend_path}/hacs_frontend", cache_headers=False
)
hacs.frontend_version = "dev"
else:
await async_register_static_path(
hass, f"{URL_BASE}/frontend", locate_dir(), cache_headers=False
)
hacs.frontend_version = FE_VERSION
# Custom iconset
await async_register_static_path(
hass, f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
)
add_extra_js_url(hass, f"{URL_BASE}/iconset.js")
# Add to sidepanel if needed
if DOMAIN not in hass.data.get("frontend_panels", {}):
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title=hacs.configuration.sidepanel_title,
sidebar_icon=hacs.configuration.sidepanel_icon,
frontend_url_path=DOMAIN,
config={
"_panel_custom": {
"name": "hacs-frontend",
"embed_iframe": True,
"trust_external": False,
"js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={hacs.frontend_version}",
}
},
require_admin=True,
)
# Setup plugin endpoint if needed
await hacs.async_setup_frontend_endpoint_plugin()

View File

@@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"pre-release": {
"state": {
"on": "mdi:test-tube",
"off": "mdi:test-tube-off"
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
const hacsIcons = {
hacs: {
path: "m 20.064849,22.306912 c -0.0319,0.369835 -0.280561,0.707789 -0.656773,0.918212 -0.280572,0.153036 -0.605773,0.229553 -0.950094,0.229553 -0.0765,0 -0.146661,-0.0064 -0.216801,-0.01275 -0.605774,-0.05739 -1.135016,-0.344329 -1.402827,-0.7588 l 0.784304,-0.516495 c 0.0893,0.146659 0.344331,0.312448 0.707793,0.34433 0.235931,0.02551 0.471852,-0.01913 0.637643,-0.108401 0.101998,-0.05101 0.172171,-0.127529 0.17854,-0.191295 0.0065,-0.08289 -0.0255,-0.369835 -0.733293,-0.439975 -1.013854,-0.09565 -1.645127,-0.688661 -1.568606,-1.460214 0.0319,-0.382589 0.280561,-0.714165 0.663153,-0.930965 0.331571,-0.172165 0.752423,-0.25506 1.166895,-0.210424 0.599382,0.05739 1.128635,0.344329 1.402816,0.7588 l -0.784304,0.510118 c -0.0893,-0.140282 -0.344331,-0.299694 -0.707782,-0.331576 -0.235932,-0.02551 -0.471863,0.01913 -0.637654,0.10202 -0.0956,0.05739 -0.165791,0.133906 -0.17216,0.191295 -0.0255,0.293317 0.465482,0.420847 0.726913,0.439976 v 0.0064 c 1.020234,0.09565 1.638757,0.66953 1.562237,1.460213 z m -7.466854,-0.988354 c 0,-1.192401 0.962855,-2.155249 2.15525,-2.155249 0.599393,0 1.179645,0.25506 1.594117,0.707789 l -0.695033,0.624895 c -0.235931,-0.25506 -0.561133,-0.401718 -0.899084,-0.401718 -0.675903,0 -1.217906,0.542 -1.217906,1.217906 0,0.66953 0.542003,1.217908 1.217906,1.217908 0.337951,0 0.663153,-0.140283 0.899084,-0.401718 l 0.695033,0.631271 c -0.414472,0.452729 -0.988355,0.707788 -1.594117,0.707788 -1.192395,0 -2.15525,-0.969224 -2.15525,-2.148872 z M 8.6573365,23.461054 10.353474,19.14418 h 0.624893 l 1.568618,4.316874 H 11.52037 L 11.265308,22.734136 H 9.964513 l -0.274192,0.726918 z m 1.6833885,-1.68339 h 0.580263 L 10.646796,21.012487 Z M 8.1089536,19.156932 v 4.297745 H 7.1461095 v -1.645131 h -1.606867 v 1.645131 H 4.5763876 v -4.297745 h 0.9628549 v 1.696143 h 1.606867 V 19.156932 Z M 20.115859,4.2997436 C 20.090359,4.159461 19.969198,4.0574375 19.822548,4.0574375 H 14.141102 10.506516 4.8250686 c -0.14665,0 -0.2678112,0.1020202 -0.2933108,0.2423061 L 3.690064,8.8461703 c -0.00651,0.01913 -0.00651,0.03826 -0.00651,0.057391 v 1.5239797 c 0,0.165789 0.133911,0.299694 0.2996911,0.299694 H 4.5762579 20.0711 20.664112 c 0.165781,0 0.299691,-0.133905 0.299691,-0.299694 V 8.8971848 c 0,-0.01913 0,-0.03826 -0.0065,-0.05739 z M 4.5763876,17.358767 c 0,0.184917 0.1466608,0.331577 0.3315819,0.331577 h 5.5985465 3.634586 0.924594 c 0.184911,0 0.331571,-0.14666 0.331571,-0.331577 v -4.744098 c 0,-0.184918 0.146661,-0.331577 0.331582,-0.331577 h 2.894913 c 0.184921,0 0.331582,0.146659 0.331582,0.331577 v 4.744098 c 0,0.184917 0.146661,0.331577 0.331571,0.331577 h 0.446363 c 0.18491,0 0.331571,-0.14666 0.331571,-0.331577 v -5.636804 c 0,-0.184918 -0.146661,-0.331577 -0.331571,-0.331577 H 4.9079695 c -0.1849211,0 -0.3315819,0.146659 -0.3315819,0.331577 z m 1.6578879,-4.852498 h 5.6495565 c 0.15303,0 0.280561,0.12753 0.280561,0.280564 v 3.513438 c 0,0.153036 -0.127531,0.280566 -0.280561,0.280566 H 6.2342755 c -0.1530412,0 -0.2805719,-0.12753 -0.2805719,-0.280566 v -3.513438 c 0,-0.159411 0.1275307,-0.280564 0.2805719,-0.280564 z M 19.790657,3.3879075 H 4.8569594 c -0.1530412,0 -0.2805718,-0.1275296 -0.2805718,-0.2805642 V 1.3665653 C 4.5763876,1.2135296 4.7039182,1.086 4.8569594,1.086 H 19.790657 c 0.153041,0 0.280572,0.1275296 0.280572,0.2805653 v 1.740778 c 0,0.1530346 -0.127531,0.2805642 -0.280572,0.2805642 z",
keywords: ["hacs", "home assistant community store"],
},
};
window.customIcons = window.customIcons || {};
window.customIconsets = window.customIconsets || {};
window.customIcons["hacs"] = {
getIcon: async (iconName) => (
{ path: hacsIcons[iconName]?.path }
),
getIconList: async () =>
Object.entries(hacsIcons).map(([icon, content]) => ({
name: icon,
keywords: content.keywords,
})
)
};

View File

@@ -0,0 +1,26 @@
{
"domain": "hacs",
"name": "HACS",
"after_dependencies": [
"python_script"
],
"codeowners": [
"@ludeeus"
],
"config_flow": true,
"dependencies": [
"http",
"websocket_api",
"frontend",
"persistent_notification",
"lovelace",
"repairs"
],
"documentation": "https://hacs.xyz/docs/use/",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/hacs/integration/issues",
"requirements": [
"aiogithubapi>=22.10.1"
],
"version": "2.0.5"
}

View File

@@ -0,0 +1,58 @@
"""Repairs platform for HACS."""
from __future__ import annotations
from typing import Any
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
import voluptuous as vol
from custom_components.hacs.base import HacsBase
from .const import DOMAIN
class RestartRequiredFixFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
def __init__(self, issue_id: str) -> None:
self.issue_id = issue_id
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm_restart()
async def async_step_confirm_restart(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
await self.hass.services.async_call("homeassistant", "restart")
return self.async_create_entry(title="", data={})
hacs: HacsBase = self.hass.data[DOMAIN]
integration = hacs.repositories.get_by_id(self.issue_id.split("_")[2])
return self.async_show_form(
step_id="confirm_restart",
data_schema=vol.Schema({}),
description_placeholders={"name": integration.display_name},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None = None,
*args: Any,
**kwargs: Any,
) -> RepairsFlow | None:
"""Create flow."""
if issue_id.startswith("restart_required"):
return RestartRequiredFixFlow(issue_id)
return None

View File

@@ -0,0 +1,21 @@
"""Initialize repositories."""
from __future__ import annotations
from ..enums import HacsCategory
from .appdaemon import HacsAppdaemonRepository
from .base import HacsRepository
from .integration import HacsIntegrationRepository
from .plugin import HacsPluginRepository
from .python_script import HacsPythonScriptRepository
from .template import HacsTemplateRepository
from .theme import HacsThemeRepository
REPOSITORY_CLASSES: dict[HacsCategory, HacsRepository] = {
HacsCategory.THEME: HacsThemeRepository,
HacsCategory.INTEGRATION: HacsIntegrationRepository,
HacsCategory.PYTHON_SCRIPT: HacsPythonScriptRepository,
HacsCategory.APPDAEMON: HacsAppdaemonRepository,
HacsCategory.PLUGIN: HacsPluginRepository,
HacsCategory.TEMPLATE: HacsTemplateRepository,
}

View File

@@ -0,0 +1,93 @@
"""Class for appdaemon apps in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from aiogithubapi import AIOGitHubAPIException
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsAppdaemonRepository(HacsRepository):
"""Appdaemon apps in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.APPDAEMON
self.content.path.local = self.localpath
self.content.path.remote = "apps"
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/appdaemon/apps/{self.data.name}"
async def validate_repository(self):
"""Validate."""
await self.common_validate()
# Custom step 1: Validate content.
try:
addir = await self.repository_object.get_contents("apps", self.ref)
except AIOGitHubAPIException:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
) from None
if not isinstance(addir, list):
self.validate.errors.append(f"{self.string} Repository structure not compliant")
self.content.path.remote = addir[0].path
self.content.objects = await self.repository_object.get_contents(
self.content.path.remote, self.ref
)
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get appdaemon objects.
if self.repository_manifest:
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
if self.content.path.remote == "apps":
addir = await self.repository_object.get_contents(self.content.path.remote, self.ref)
self.content.path.remote = addir[0].path
self.content.objects = await self.repository_object.get_contents(
self.content.path.remote, self.ref
)
# Set local path
self.content.path.local = self.localpath
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
"""Class for integrations in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.loader import async_get_custom_components
from ..const import DOMAIN
from ..enums import HacsCategory, HacsDispatchEvent, HacsGitHubRepo, RepositoryFile
from ..exceptions import AddonRepositoryException, HacsException
from ..utils.decode import decode_content
from ..utils.decorator import concurrent
from ..utils.filters import get_first_directory_in_directory
from ..utils.json import json_loads
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsIntegrationRepository(HacsRepository):
"""Integrations in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.INTEGRATION
self.content.path.remote = "custom_components"
self.content.path.local = self.localpath
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/custom_components/{self.data.domain}"
async def async_post_installation(self):
"""Run post installation steps."""
self.pending_restart = True
if self.data.config_flow:
if self.data.full_name != HacsGitHubRepo.INTEGRATION:
await self.reload_custom_components()
if self.data.first_install:
self.pending_restart = False
if self.pending_restart:
self.logger.debug("%s Creating restart_required issue", self.string)
async_create_issue(
hass=self.hacs.hass,
domain=DOMAIN,
issue_id=f"restart_required_{self.data.id}_{self.ref}",
is_fixable=True,
issue_domain=self.data.domain or DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="restart_required",
translation_placeholders={
"name": self.display_name,
},
)
async def async_post_uninstall(self) -> None:
"""Run post uninstall steps."""
if self.data.config_flow:
await self.reload_custom_components()
else:
self.pending_restart = True
async def validate_repository(self):
"""Validate."""
await self.common_validate()
# Custom step 1: Validate content.
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
if self.content.path.remote == "custom_components":
name = get_first_directory_in_directory(self.tree, "custom_components")
if name is None:
if (
"repository.json" in self.treefiles
or "repository.yaml" in self.treefiles
or "repository.yml" in self.treefiles
):
raise AddonRepositoryException()
raise HacsException(
f"{self.string} Repository structure for {
self.ref.replace('tags/', '')} is not compliant"
)
self.content.path.remote = f"custom_components/{name}"
# Get the content of manifest.json
if manifest := await self.async_get_integration_manifest():
try:
self.integration_manifest = manifest
self.data.authors = manifest.get("codeowners", [])
self.data.domain = manifest["domain"]
self.data.manifest_name = manifest.get("name")
self.data.config_flow = manifest.get("config_flow", False)
except KeyError as exception:
self.validate.errors.append(
f"Missing expected key '{exception}' in {
RepositoryFile.MAINIFEST_JSON}"
)
self.hacs.log.error(
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
)
# Set local path
self.content.path.local = self.localpath
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
if self.content.path.remote == "custom_components":
name = get_first_directory_in_directory(self.tree, "custom_components")
self.content.path.remote = f"custom_components/{name}"
# Get the content of manifest.json
if manifest := await self.async_get_integration_manifest():
try:
self.integration_manifest = manifest
self.data.authors = manifest.get("codeowners", [])
self.data.domain = manifest["domain"]
self.data.manifest_name = manifest.get("name")
self.data.config_flow = manifest.get("config_flow", False)
except KeyError as exception:
self.validate.errors.append(
f"Missing expected key '{exception}' in {
RepositoryFile.MAINIFEST_JSON}"
)
self.hacs.log.error(
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
)
# Set local path
self.content.path.local = self.localpath
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
async def reload_custom_components(self):
"""Reload custom_components (and config flows)in HA."""
self.logger.info("Reloading custom_component cache")
del self.hacs.hass.data["custom_components"]
await async_get_custom_components(self.hacs.hass)
self.logger.info("Custom_component cache reloaded")
async def async_get_integration_manifest(self, ref: str = None) -> dict[str, Any] | None:
"""Get the content of the manifest.json file."""
manifest_path = (
"manifest.json"
if self.repository_manifest.content_in_root
else f"{self.content.path.remote}/{RepositoryFile.MAINIFEST_JSON}"
)
if not manifest_path in (x.full_path for x in self.tree):
raise HacsException(f"No {RepositoryFile.MAINIFEST_JSON} file found '{manifest_path}'")
response = await self.hacs.async_github_api_method(
method=self.hacs.githubapi.repos.contents.get,
repository=self.data.full_name,
path=manifest_path,
**{"params": {"ref": ref or self.version_to_download()}},
)
if response:
return json_loads(decode_content(response.data.content))
async def get_integration_manifest(self, *, version: str, **kwargs) -> dict[str, Any] | None:
"""Get the content of the manifest.json file."""
manifest_path = (
"manifest.json"
if self.repository_manifest.content_in_root
else f"{self.content.path.remote}/{RepositoryFile.MAINIFEST_JSON}"
)
if manifest_path not in (x.full_path for x in self.tree):
raise HacsException(f"No {RepositoryFile.MAINIFEST_JSON} file found '{manifest_path}'")
self.logger.debug("%s Getting manifest.json for version=%s", self.string, version)
try:
result = await self.hacs.async_download_file(
f"https://raw.githubusercontent.com/{
self.data.full_name}/{version}/{manifest_path}",
nolog=True,
)
if result is None:
return None
return json_loads(result)
except Exception: # pylint: disable=broad-except
return None

View File

@@ -0,0 +1,246 @@
"""Class for plugins in HACS."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from ..utils.json import json_loads
from .base import HacsRepository
HACSTAG_REPLACER = re.compile(r"\D+")
if TYPE_CHECKING:
from homeassistant.components.lovelace.resources import ResourceStorageCollection
from ..base import HacsBase
class HacsPluginRepository(HacsRepository):
"""Plugins in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.file_name = None
self.data.category = HacsCategory.PLUGIN
self.content.path.local = self.localpath
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/www/community/{self.data.full_name.split('/')[-1]}"
async def validate_repository(self):
"""Validate."""
# Run common validation steps.
await self.common_validate()
# Custom step 1: Validate content.
self.update_filenames()
if self.content.path.remote is None:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
if self.content.path.remote == "release":
self.content.single = True
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
async def async_post_installation(self):
"""Run post installation steps."""
await self.hacs.async_setup_frontend_endpoint_plugin()
await self.update_dashboard_resources()
async def async_post_uninstall(self):
"""Run post uninstall steps."""
await self.remove_dashboard_resources()
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get plugin objects.
self.update_filenames()
if self.content.path.remote is None:
self.validate.errors.append(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
if self.content.path.remote == "release":
self.content.single = True
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
async def get_package_content(self):
"""Get package content."""
try:
package = await self.repository_object.get_contents("package.json", self.ref)
package = json_loads(package.content)
if package:
self.data.authors = package["author"]
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
pass
def update_filenames(self) -> None:
"""Get the filename to target."""
content_in_root = self.repository_manifest.content_in_root
if specific_filename := self.repository_manifest.filename:
valid_filenames = (specific_filename,)
else:
valid_filenames = (
f"{self.data.name.replace('lovelace-', '')}.js",
f"{self.data.name}.js",
f"{self.data.name}.umd.js",
f"{self.data.name}-bundle.js",
)
if not content_in_root:
if self.releases.objects:
release = self.releases.objects[0]
if release.assets:
if assetnames := [
filename
for filename in valid_filenames
for asset in release.assets
if filename == asset.name
]:
self.data.file_name = assetnames[0]
self.content.path.remote = "release"
return
all_paths = {x.full_path for x in self.tree}
for filename in valid_filenames:
if filename in all_paths:
self.data.file_name = filename
self.content.path.remote = ""
return
if not content_in_root and f"dist/{filename}" in all_paths:
self.data.file_name = filename.split("/")[-1]
self.content.path.remote = "dist"
return
def generate_dashboard_resource_hacstag(self) -> str:
"""Get the HACS tag used by dashboard resources."""
version = (
self.display_installed_version
or self.data.selected_tag
or self.display_available_version
)
return f"{self.data.id}{HACSTAG_REPLACER.sub('', version)}"
def generate_dashboard_resource_namespace(self) -> str:
"""Get the dashboard resource namespace."""
return f"/hacsfiles/{self.data.full_name.split("/")[1]}"
def generate_dashboard_resource_url(self) -> str:
"""Get the dashboard resource namespace."""
filename = self.data.file_name
if "/" in filename:
self.logger.warning("%s have defined an invalid file name %s", self.string, filename)
filename = filename.split("/")[-1]
return (
f"{self.generate_dashboard_resource_namespace()}/{filename}"
f"?hacstag={self.generate_dashboard_resource_hacstag()}"
)
def _get_resource_handler(self) -> ResourceStorageCollection | None:
"""Get the resource handler."""
resources: ResourceStorageCollection | None
if not (hass_data := self.hacs.hass.data):
self.logger.error("%s Can not access the hass data", self.string)
return
if (lovelace_data := hass_data.get("lovelace")) is None:
self.logger.warning("%s Can not access the lovelace integration data", self.string)
return
if self.hacs.core.ha_version > "2025.1.99":
# Changed to 2025.2.0
# Changed in https://github.com/home-assistant/core/pull/136313
resources = lovelace_data.resources
else:
resources = lovelace_data.get("resources")
if resources is None:
self.logger.warning("%s Can not access the dashboard resources", self.string)
return
if not hasattr(resources, "store") or resources.store is None:
self.logger.info("%s YAML mode detected, can not update resources", self.string)
return
if resources.store.key != "lovelace_resources" or resources.store.version != 1:
self.logger.warning("%s Can not use the dashboard resources", self.string)
return
return resources
async def update_dashboard_resources(self) -> None:
"""Update dashboard resources."""
if not (resources := self._get_resource_handler()):
return
if not resources.loaded:
await resources.async_load()
namespace = self.generate_dashboard_resource_namespace()
url = self.generate_dashboard_resource_url()
for entry in resources.async_items():
if (entry_url := entry["url"]).startswith(namespace):
if entry_url != url:
self.logger.info(
"%s Updating existing dashboard resource from %s to %s",
self.string,
entry_url,
url,
)
await resources.async_update_item(entry["id"], {"url": url})
return
# Nothing was updated, add the resource
self.logger.info("%s Adding dashboard resource %s", self.string, url)
await resources.async_create_item({"res_type": "module", "url": url})
async def remove_dashboard_resources(self) -> None:
"""Remove dashboard resources."""
if not (resources := self._get_resource_handler()):
return
if not resources.loaded:
await resources.async_load()
namespace = self.generate_dashboard_resource_namespace()
for entry in resources.async_items():
if entry["url"].startswith(namespace):
self.logger.info("%s Removing dashboard resource %s", self.string, entry["url"])
await resources.async_delete_item(entry["id"])
return

View File

@@ -0,0 +1,111 @@
"""Class for python_scripts in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsPythonScriptRepository(HacsRepository):
"""python_scripts in HACS."""
category = "python_script"
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.PYTHON_SCRIPT
self.content.path.remote = "python_scripts"
self.content.path.local = self.localpath
self.content.single = True
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/python_scripts"
async def validate_repository(self):
"""Validate."""
# Run common validation steps.
await self.common_validate()
# Custom step 1: Validate content.
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
compliant = False
for treefile in self.treefiles:
if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".py"):
compliant = True
break
if not compliant:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
async def async_post_registration(self):
"""Registration."""
# Set name
self.update_filenames()
if self.hacs.system.action:
await self.hacs.validation.async_run_repository_checks(self)
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get python_script objects.
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
compliant = False
for treefile in self.treefiles:
if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".py"):
compliant = True
break
if not compliant:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
# Update name
self.update_filenames()
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
def update_filenames(self) -> None:
"""Get the filename to target."""
for treefile in self.tree:
if treefile.full_path.startswith(
self.content.path.remote
) and treefile.full_path.endswith(".py"):
self.data.file_name = treefile.filename

View File

@@ -0,0 +1,106 @@
"""Class for themes in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.exceptions import HomeAssistantError
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsTemplateRepository(HacsRepository):
"""Custom templates in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.TEMPLATE
self.content.path.remote = ""
self.content.path.local = self.localpath
self.content.single = True
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/custom_templates"
async def async_post_installation(self):
"""Run post installation steps."""
await self._reload_custom_templates()
async def validate_repository(self):
"""Validate."""
# Run common validation steps.
await self.common_validate()
# Custom step 1: Validate content.
self.data.file_name = self.repository_manifest.filename
if (
not self.data.file_name
or "/" in self.data.file_name
or not self.data.file_name.endswith(".jinja")
or self.data.file_name not in self.treefiles
):
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
async def async_post_registration(self):
"""Registration."""
# Set filenames
self.data.file_name = self.repository_manifest.filename
self.content.path.local = self.localpath
if self.hacs.system.action:
await self.hacs.validation.async_run_repository_checks(self)
async def async_post_uninstall(self) -> None:
"""Run post uninstall steps."""
await self._reload_custom_templates()
async def _reload_custom_templates(self) -> None:
"""Reload custom templates."""
self.logger.debug("%s Reloading custom templates", self.string)
try:
await self.hacs.hass.services.async_call("homeassistant", "reload_custom_templates", {})
except HomeAssistantError as exception:
self.logger.exception("%s %s", self.string, exception)
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Update filenames
self.data.file_name = self.repository_manifest.filename
self.content.path.local = self.localpath
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)

View File

@@ -0,0 +1,119 @@
"""Class for themes in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.exceptions import HomeAssistantError
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsThemeRepository(HacsRepository):
"""Themes in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.THEME
self.content.path.remote = "themes"
self.content.path.local = self.localpath
self.content.single = False
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/themes/{self.data.file_name.replace('.yaml', '')}"
async def async_post_installation(self):
"""Run post installation steps."""
await self._reload_frontend_themes()
async def validate_repository(self):
"""Validate."""
# Run common validation steps.
await self.common_validate()
# Custom step 1: Validate content.
compliant = False
for treefile in self.treefiles:
if treefile.startswith("themes/") and treefile.endswith(".yaml"):
compliant = True
break
if not compliant:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
async def async_post_registration(self):
"""Registration."""
# Set name
self.update_filenames()
self.content.path.local = self.localpath
if self.hacs.system.action:
await self.hacs.validation.async_run_repository_checks(self)
async def _reload_frontend_themes(self) -> None:
"""Reload frontend themes."""
self.logger.debug("%s Reloading frontend themes", self.string)
try:
await self.hacs.hass.services.async_call("frontend", "reload_themes", {})
except HomeAssistantError as exception:
self.logger.exception("%s %s", self.string, exception)
async def async_post_uninstall(self) -> None:
"""Run post uninstall steps."""
await self._reload_frontend_themes()
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get theme objects.
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
# Update name
self.update_filenames()
self.content.path.local = self.localpath
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
def update_filenames(self) -> None:
"""Get the filename to target."""
for treefile in self.tree:
if treefile.full_path.startswith(
self.content.path.remote
) and treefile.full_path.endswith(".yaml"):
self.data.file_name = treefile.filename

View File

@@ -0,0 +1,73 @@
"""Switch entities for HACS."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .base import HacsBase
from .const import DOMAIN
from .entity import HacsRepositoryEntity
from .repositories.base import HacsRepository
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Setup switch platform."""
hacs: HacsBase = hass.data[DOMAIN]
async_add_entities(
HacsRepositoryPreReleaseSwitchEntity(hacs=hacs, repository=repository)
for repository in hacs.repositories.list_downloaded
)
class HacsRepositoryPreReleaseSwitchEntity(HacsRepositoryEntity, SwitchEntity):
"""Pre-release switch entities for repositories downloaded with HACS."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_has_entity_name = True
_attr_translation_key = "pre-release"
def __init__(self, hacs: HacsBase, repository: HacsRepository) -> None:
"""Initialize the repository pre-release switch."""
super().__init__(hacs, repository)
self._attr_entity_registry_enabled_default = self.repository.data.show_beta
@property
def is_on(self) -> bool:
"""Return if the pre-release option is enabled for the repository."""
return self.repository.data.show_beta
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self._handle_change(value=True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self._handle_change(value=False)
async def _handle_change(self, value: bool) -> None:
"""Handle attribute value changes."""
self.repository.data.show_beta = value
# As this value is directly affecting what data points is in use by other entities
# we need to update all entities to reflect the change
# Do force an update of the entities we need to clear the last fetched data
# since that is used to limit state updates
# Once we have signaled the update we can restore the last fetched data
_last_fetch = self.repository.data.last_fetched
self.repository.data.last_fetched = None
self.coordinator.async_update_listeners()
self.repository.data.last_fetched = _last_fetch # Restore last fetched
# Write the HACS data and update the entity state
await self.hacs.data.async_write()
self.async_write_ha_state()

View File

@@ -0,0 +1,52 @@
"""Provide info to system health."""
from typing import Any
from aiogithubapi.common.const import BASE_API_URL
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .base import HacsBase
from .const import DOMAIN
GITHUB_STATUS = "https://www.githubstatus.com/"
CLOUDFLARE_STATUS = "https://www.cloudflarestatus.com/"
@callback
def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None:
"""Register system health callbacks."""
register.domain = "Home Assistant Community Store"
register.async_register_info(system_health_info, "/hacs")
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
if DOMAIN not in hass.data:
return {"Disabled": "HACS is not loaded, but HA still requests this information..."}
hacs: HacsBase = hass.data[DOMAIN]
response = await hacs.githubapi.rate_limit()
data = {
"GitHub API": system_health.async_check_can_reach_url(hass, BASE_API_URL, GITHUB_STATUS),
"GitHub Content": system_health.async_check_can_reach_url(
hass, "https://raw.githubusercontent.com/hacs/integration/main/hacs.json"
),
"GitHub Web": system_health.async_check_can_reach_url(
hass, "https://github.com/", GITHUB_STATUS
),
"HACS Data": system_health.async_check_can_reach_url(
hass, "https://data-v2.hacs.xyz/data.json", CLOUDFLARE_STATUS
),
"GitHub API Calls Remaining": response.data.resources.core.remaining,
"Installed Version": hacs.version,
"Stage": hacs.stage,
"Available Repositories": len(hacs.repositories.list_all),
"Downloaded Repositories": len(hacs.repositories.list_downloaded),
}
if hacs.system.disabled:
data["Disabled"] = hacs.system.disabled_reason
return data

View File

@@ -0,0 +1,84 @@
{
"config": {
"abort": {
"single_instance_allowed": "Only a single configuration of HACS is allowed.",
"min_ha_version": "You need at least version {version} of Home Assistant to setup HACS.",
"github": "Could not authenticate with GitHub, try again later.",
"not_setup": "HACS is not setup.",
"reauth_successful": "Reauthentication was successful."
},
"error": {
"auth": "Personal Access Token is not correct",
"acc": "You need to acknowledge all the statements before continuing"
},
"step": {
"user": {
"data": {
"acc_logs": "I know how to access Home Assistant logs",
"acc_addons": "I know that there are no add-ons in HACS",
"acc_untested": "I know that everything inside HACS including HACS itself is custom and untested by Home Assistant",
"acc_disable": "I know that if I get issues with Home Assistant I should disable all my custom_components"
},
"description": "Before you can setup HACS you need to acknowledge the following"
},
"device": {
"title": "Waiting for device activation"
},
"reauth_confirm": {
"title": "Reauthentication needed",
"description": "You need to reauthenticate with GitHub."
}
},
"progress": {
"wait_for_device": "1. Open {url} \n2. Paste the following key to authorize HACS: \n```\n{code}\n```"
}
},
"options": {
"abort": {
"not_setup": "HACS is not setup.",
"pending_tasks": "There are pending tasks. Try again later.",
"release_limit_value": "The release limit needs to be between 1 and 100."
},
"step": {
"user": {
"data": {
"not_in_use": "Not in use with YAML",
"country": "Filter with country code",
"release_limit": "Number of releases to show",
"debug": "Enable debug",
"appdaemon": "Enable AppDaemon apps discovery & tracking",
"sidepanel_icon": "Side panel icon",
"sidepanel_title": "Side panel title"
}
}
}
},
"issues": {
"restart_required": {
"title": "Restart required",
"fix_flow": {
"step": {
"confirm_restart": {
"title": "Restart required",
"description": "Restart of Home Assistant is required to finish download/update of {name}, click submit to restart now."
}
}
}
},
"removed": {
"title": "Repository removed from HACS",
"description": "Because {reason}, `{name}` has been removed from HACS. Please visit the [HACS Panel](/hacs/repository/{repositry_id}) to remove it."
}
},
"entity": {
"switch": {
"pre-release": {
"name": "Pre-release",
"state": {
"off": "No pre-releases",
"on": "Pre-releases preferred"
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
"""Custom HACS types."""
from typing import TypedDict
class DownloadableContent(TypedDict):
"""Downloadable content."""
url: str
name: str

View File

@@ -0,0 +1,158 @@
"""Update entities for HACS."""
from __future__ import annotations
from typing import Any
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, HomeAssistantError, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .base import HacsBase
from .const import DOMAIN
from .entity import HacsRepositoryEntity
from .enums import HacsCategory, HacsDispatchEvent
from .exceptions import HacsException
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Setup update platform."""
hacs: HacsBase = hass.data[DOMAIN]
async_add_entities(
HacsRepositoryUpdateEntity(hacs=hacs, repository=repository)
for repository in hacs.repositories.list_downloaded
)
class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
"""Update entities for repositories downloaded with HACS."""
_attr_supported_features = (
UpdateEntityFeature.INSTALL
| UpdateEntityFeature.SPECIFIC_VERSION
| UpdateEntityFeature.PROGRESS
| UpdateEntityFeature.RELEASE_NOTES
)
@property
def name(self) -> str | None:
"""Return the name."""
return f"{self.repository.display_name} update"
@property
def latest_version(self) -> str:
"""Return latest version of the entity."""
return self.repository.display_available_version
@property
def release_url(self) -> str:
"""Return the URL of the release page."""
if self.repository.display_version_or_commit == "commit":
return f"https://github.com/{self.repository.data.full_name}"
return f"https://github.com/{self.repository.data.full_name}/releases/{self.latest_version}"
@property
def installed_version(self) -> str:
"""Return downloaded version of the entity."""
return self.repository.display_installed_version
@property
def release_summary(self) -> str | None:
"""Return the release summary."""
if self.repository.pending_restart:
return "<ha-alert alert-type='error'>Restart of Home Assistant required</ha-alert>"
return None
@property
def entity_picture(self) -> str | None:
"""Return the entity picture to use in the frontend."""
if (
self.repository.data.category != HacsCategory.INTEGRATION
or self.repository.data.domain is None
):
return None
return f"https://brands.home-assistant.io/_/{self.repository.data.domain}/icon.png"
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
"""Install an update."""
to_download = version or self.latest_version
if to_download == self.installed_version:
raise HomeAssistantError(f"Version {self.installed_version} of {
self.repository.data.full_name} is already downloaded")
try:
await self.repository.async_download_repository(ref=version or self.latest_version)
except HacsException as exception:
raise HomeAssistantError(exception) from exception
async def async_release_notes(self) -> str | None:
"""Return the release notes."""
if self.repository.pending_restart:
return None
if self.latest_version not in self.repository.data.published_tags:
releases = await self.repository.get_releases(
prerelease=self.repository.data.show_beta,
returnlimit=self.hacs.configuration.release_limit,
)
if releases:
self.repository.data.releases = True
self.repository.releases.objects = releases
self.repository.data.published_tags = [x.tag_name for x in releases]
self.repository.data.last_version = next(iter(self.repository.data.published_tags))
release_notes = ""
# Compile release notes from installed version up to the latest
if self.installed_version in self.repository.data.published_tags:
for release in self.repository.releases.objects:
if release.tag_name == self.installed_version:
break
release_notes += f"# {release.tag_name}"
if release.tag_name != release.name:
release_notes += f" - {release.name}"
release_notes += f"\n\n{release.body}"
release_notes += "\n\n---\n\n"
elif any(self.repository.releases.objects):
release_notes += self.repository.releases.objects[0].body
if self.repository.pending_update:
if self.repository.data.category == HacsCategory.INTEGRATION:
release_notes += (
"\n\n<ha-alert alert-type='warning'>You need to restart"
" Home Assistant manually after updating.</ha-alert>\n\n"
)
if self.repository.data.category == HacsCategory.PLUGIN:
release_notes += (
"\n\n<ha-alert alert-type='warning'>You need to manually"
" clear the frontend cache after updating.</ha-alert>\n\n"
)
return release_notes.replace("\n#", "\n\n#")
async def async_added_to_hass(self) -> None:
"""Register for status events."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
self._update_download_progress,
)
)
@callback
def _update_download_progress(self, data: dict) -> None:
"""Update the download progress."""
if data["repository"] != self.repository.data.full_name:
return
self._update_in_progress(progress=data["progress"])
@callback
def _update_in_progress(self, progress: int | bool) -> None:
"""Update the download progress."""
self._attr_in_progress = progress
self.async_write_ha_state()

View File

@@ -0,0 +1 @@
"""Initialize HACS utils."""

View File

@@ -0,0 +1,110 @@
"""Backup."""
from __future__ import annotations
import os
import shutil
import tempfile
from time import sleep
from typing import TYPE_CHECKING
from .path import is_safe
if TYPE_CHECKING:
from ..base import HacsBase
from ..repositories.base import HacsRepository
DEFAULT_BACKUP_PATH = f"{tempfile.gettempdir()}/hacs_backup/"
class Backup:
"""Backup."""
def __init__(
self,
hacs: HacsBase,
local_path: str | None = None,
backup_path: str = DEFAULT_BACKUP_PATH,
repository: HacsRepository | None = None,
) -> None:
"""Initialize."""
self.hacs = hacs
self.repository = repository
self.local_path = local_path or repository.content.path.local
self.backup_path = backup_path
if repository:
self.backup_path = (
tempfile.gettempdir()
+ f"/hacs_persistent_{repository.data.category}/"
+ repository.data.name
)
self.backup_path_full = f"{self.backup_path}{self.local_path.split('/')[-1]}"
def _init_backup_dir(self) -> bool:
"""Init backup dir."""
if not os.path.exists(self.local_path):
return False
if not is_safe(self.hacs, self.local_path):
return False
if os.path.exists(self.backup_path):
shutil.rmtree(self.backup_path)
# Wait for the folder to be removed
while os.path.exists(self.backup_path):
sleep(0.1)
os.makedirs(self.backup_path, exist_ok=True)
return True
def create(self) -> None:
"""Create a backup in /tmp"""
if not self._init_backup_dir():
return
try:
if os.path.isfile(self.local_path):
shutil.copyfile(self.local_path, self.backup_path_full)
os.remove(self.local_path)
else:
shutil.copytree(self.local_path, self.backup_path_full)
shutil.rmtree(self.local_path)
while os.path.exists(self.local_path):
sleep(0.1)
self.hacs.log.debug(
"Backup for %s, created in %s",
self.local_path,
self.backup_path_full,
)
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception:
self.hacs.log.warning("Could not create backup: %s", exception)
def restore(self) -> None:
"""Restore from backup."""
if not os.path.exists(self.backup_path_full):
return
if os.path.isfile(self.backup_path_full):
if os.path.exists(self.local_path):
os.remove(self.local_path)
shutil.copyfile(self.backup_path_full, self.local_path)
else:
if os.path.exists(self.local_path):
shutil.rmtree(self.local_path)
while os.path.exists(self.local_path):
sleep(0.1)
shutil.copytree(self.backup_path_full, self.local_path)
self.hacs.log.debug("Restored %s, from backup %s", self.local_path, self.backup_path_full)
def cleanup(self) -> None:
"""Cleanup backup files."""
if not os.path.exists(self.backup_path):
return
shutil.rmtree(self.backup_path)
# Wait for the folder to be removed
while os.path.exists(self.backup_path):
sleep(0.1)
self.hacs.log.debug("Backup dir %s cleared", self.backup_path)

View File

@@ -0,0 +1,9 @@
"""HACS Configuration Schemas."""
# Configuration:
SIDEPANEL_TITLE = "sidepanel_title"
SIDEPANEL_ICON = "sidepanel_icon"
APPDAEMON = "appdaemon"
# Options:
COUNTRY = "country"

View File

@@ -0,0 +1,323 @@
"""Data handler for HACS."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Any
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from ..base import HacsBase
from ..const import HACS_REPOSITORY_ID
from ..enums import HacsDisabledReason, HacsDispatchEvent
from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository
from .logger import LOGGER
from .path import is_safe
from .store import async_load_from_store, async_save_to_store
EXPORTED_BASE_DATA = (
("new", False),
("full_name", ""),
)
EXPORTED_REPOSITORY_DATA = EXPORTED_BASE_DATA + (
("authors", []),
("category", ""),
("description", ""),
("domain", None),
("downloads", 0),
("etag_repository", None),
("hide", False),
("last_updated", 0),
("new", False),
("stargazers_count", 0),
("topics", []),
)
EXPORTED_DOWNLOADED_REPOSITORY_DATA = EXPORTED_REPOSITORY_DATA + (
("archived", False),
("config_flow", False),
("default_branch", None),
("first_install", False),
("installed_commit", None),
("installed", False),
("last_commit", None),
("last_version", None),
("manifest_name", None),
("open_issues", 0),
("prerelease", None),
("published_tags", []),
("releases", False),
("selected_tag", None),
("show_beta", False),
)
class HacsData:
"""HacsData class."""
def __init__(self, hacs: HacsBase):
"""Initialize."""
self.logger = LOGGER
self.hacs = hacs
self.content = {}
async def async_force_write(self, _=None):
"""Force write."""
await self.async_write(force=True)
async def async_write(self, force: bool = False) -> None:
"""Write content to the store files."""
if not force and self.hacs.system.disabled:
return
self.logger.debug("<HacsData async_write> Saving data")
# Hacs
await async_save_to_store(
self.hacs.hass,
"hacs",
{
"archived_repositories": self.hacs.common.archived_repositories,
"renamed_repositories": self.hacs.common.renamed_repositories,
"ignored_repositories": self.hacs.common.ignored_repositories,
},
)
await self._async_store_experimental_content_and_repos()
await self._async_store_content_and_repos()
async def _async_store_content_and_repos(self, _=None): # bb: ignore
"""Store the main repos file and each repo that is out of date."""
# Repositories
self.content = {}
for repository in self.hacs.repositories.list_all:
if repository.data.category in self.hacs.common.categories:
self.async_store_repository_data(repository)
await async_save_to_store(self.hacs.hass, "repositories", self.content)
for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG):
self.hacs.async_dispatch(event, {})
async def _async_store_experimental_content_and_repos(self, _=None):
"""Store the main repos file and each repo that is out of date."""
# Repositories
self.content = {}
for repository in self.hacs.repositories.list_all:
if repository.data.category in self.hacs.common.categories:
self.async_store_experimental_repository_data(repository)
await async_save_to_store(self.hacs.hass, "data", {"repositories": self.content})
@callback
def async_store_repository_data(self, repository: HacsRepository) -> dict:
"""Store the repository data."""
data = {"repository_manifest": repository.repository_manifest.manifest}
for key, default in (
EXPORTED_DOWNLOADED_REPOSITORY_DATA
if repository.data.installed
else EXPORTED_REPOSITORY_DATA
):
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
if repository.data.installed_version:
data["version_installed"] = repository.data.installed_version
if repository.data.last_fetched:
data["last_fetched"] = repository.data.last_fetched.timestamp()
self.content[str(repository.data.id)] = data
@callback
def async_store_experimental_repository_data(self, repository: HacsRepository) -> None:
"""Store the experimental repository data for non downloaded repositories."""
data = {}
self.content.setdefault(repository.data.category, [])
if repository.data.installed:
data["repository_manifest"] = repository.repository_manifest.manifest
for key, default in EXPORTED_DOWNLOADED_REPOSITORY_DATA:
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
if repository.data.installed_version:
data["version_installed"] = repository.data.installed_version
if repository.data.last_fetched:
data["last_fetched"] = repository.data.last_fetched.timestamp()
else:
for key, default in EXPORTED_BASE_DATA:
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
self.content[repository.data.category].append({"id": str(repository.data.id), **data})
async def restore(self):
"""Restore saved data."""
self.hacs.status.new = False
repositories = {}
hacs = {}
try:
hacs = await async_load_from_store(self.hacs.hass, "hacs") or {}
except HomeAssistantError:
pass
try:
repositories = await async_load_from_store(self.hacs.hass, "repositories")
if not repositories and (data := await async_load_from_store(self.hacs.hass, "data")):
for category, entries in data.get("repositories", {}).items():
for repository in entries:
repositories[repository["id"]] = {"category": category, **repository}
except HomeAssistantError as exception:
self.hacs.log.error(
"Could not read %s, restore the file from a backup - %s",
self.hacs.hass.config.path(".storage/hacs.data"),
exception,
)
self.hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False
if not hacs and not repositories:
# Assume new install
self.hacs.status.new = True
return True
self.logger.info("<HacsData restore> Restore started")
# Hacs
self.hacs.common.archived_repositories = set()
self.hacs.common.ignored_repositories = set()
self.hacs.common.renamed_repositories = {}
# Clear out doubble renamed values
renamed = hacs.get("renamed_repositories", {})
for entry in renamed:
value = renamed.get(entry)
if value not in renamed:
self.hacs.common.renamed_repositories[entry] = value
# Clear out doubble archived values
for entry in hacs.get("archived_repositories", set()):
if entry not in self.hacs.common.archived_repositories:
self.hacs.common.archived_repositories.add(entry)
# Clear out doubble ignored values
for entry in hacs.get("ignored_repositories", set()):
if entry not in self.hacs.common.ignored_repositories:
self.hacs.common.ignored_repositories.add(entry)
try:
await self.register_unknown_repositories(repositories)
for entry, repo_data in repositories.items():
if entry == "0":
# Ignore repositories with ID 0
self.logger.debug(
"<HacsData restore> Found repository with ID %s - %s", entry, repo_data
)
continue
self.async_restore_repository(entry, repo_data)
self.logger.info("<HacsData restore> Restore done")
except (
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
self.logger.critical(
"<HacsData restore> [%s] Restore Failed!", exception, exc_info=exception
)
return False
return True
async def register_unknown_repositories(
self, repositories: dict[str, dict[str, Any]], category: str | None = None
):
"""Registry any unknown repositories."""
for repo_idx, (entry, repo_data) in enumerate(repositories.items()):
# async_register_repository is awaited in a loop
# since its unlikely to ever suspend at startup
if (
entry == "0"
or repo_data.get("category", category) is None
or self.hacs.repositories.is_registered(repository_id=entry)
):
continue
await self.hacs.async_register_repository(
repository_full_name=repo_data["full_name"],
category=repo_data.get("category", category),
check=False,
repository_id=entry,
)
if repo_idx % 100 == 0:
# yield to avoid blocking the event loop
await asyncio.sleep(0)
@callback
def async_restore_repository(self, entry: str, repository_data: dict[str, Any]):
"""Restore repository."""
repository: HacsRepository | None = None
if full_name := repository_data.get("full_name"):
repository = self.hacs.repositories.get_by_full_name(full_name)
if not repository:
repository = self.hacs.repositories.get_by_id(entry)
if not repository:
return
try:
self.hacs.repositories.set_repository_id(repository, entry)
except ValueError as exception:
self.logger.warning("<HacsData async_restore_repository> duplicate IDs %s", exception)
return
# Restore repository attributes
repository.data.authors = repository_data.get("authors", [])
repository.data.description = repository_data.get("description", "")
repository.data.downloads = repository_data.get("downloads", 0)
repository.data.last_updated = repository_data.get("last_updated", 0)
if self.hacs.system.generator:
repository.data.etag_releases = repository_data.get("etag_releases")
repository.data.open_issues = repository_data.get("open_issues", 0)
repository.data.etag_repository = repository_data.get("etag_repository")
repository.data.topics = [
topic for topic in repository_data.get("topics", []) if topic not in TOPIC_FILTER
]
repository.data.domain = repository_data.get("domain")
repository.data.stargazers_count = repository_data.get(
"stargazers_count"
) or repository_data.get("stars", 0)
repository.releases.last_release = repository_data.get("last_release_tag")
repository.data.releases = repository_data.get("releases", False)
repository.data.installed = repository_data.get("installed", False)
repository.data.new = repository_data.get("new", False)
repository.data.selected_tag = repository_data.get("selected_tag")
repository.data.show_beta = repository_data.get("show_beta", False)
repository.data.last_version = repository_data.get("last_version")
repository.data.prerelease = repository_data.get("prerelease")
repository.data.last_commit = repository_data.get("last_commit")
repository.data.installed_version = repository_data.get("version_installed")
repository.data.installed_commit = repository_data.get("installed_commit")
repository.data.manifest_name = repository_data.get("manifest_name")
if last_fetched := repository_data.get("last_fetched"):
repository.data.last_fetched = datetime.fromtimestamp(last_fetched, UTC)
repository.repository_manifest = HacsManifest.from_dict(
repository_data.get("manifest") or repository_data.get("repository_manifest") or {}
)
if repository.data.prerelease == repository.data.last_version:
repository.data.prerelease = None
if repository.localpath is not None and is_safe(self.hacs, repository.localpath):
# Set local path
repository.content.path.local = repository.localpath
if repository.data.installed:
repository.data.first_install = False
if entry == HACS_REPOSITORY_ID:
repository.data.installed_version = self.hacs.version
repository.data.installed = True

View File

@@ -0,0 +1,8 @@
"""Util to decode content from the github API."""
from base64 import b64decode
def decode_content(content: str) -> str:
"""Decode content."""
return b64decode(bytearray(content, "utf-8")).decode()

View File

@@ -0,0 +1,43 @@
"""HACS Decorators."""
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from functools import wraps
from typing import TYPE_CHECKING, Any
from ..const import DEFAULT_CONCURRENT_BACKOFF_TIME, DEFAULT_CONCURRENT_TASKS
if TYPE_CHECKING:
from ..base import HacsBase
def concurrent(
concurrenttasks: int = DEFAULT_CONCURRENT_TASKS,
backoff_time: int = DEFAULT_CONCURRENT_BACKOFF_TIME,
) -> Coroutine[Any, Any, None]:
"""Return a modified function."""
max_concurrent = asyncio.Semaphore(concurrenttasks)
def inner_function(function) -> Coroutine[Any, Any, None]:
@wraps(function)
async def wrapper(*args, **kwargs) -> None:
hacs: HacsBase = getattr(args[0], "hacs", None)
async with max_concurrent:
result = await function(*args, **kwargs)
if (
hacs is None
or hacs.queue is None
or hacs.queue.has_pending_tasks
or "update" not in function.__name__
):
await asyncio.sleep(backoff_time)
return result
return wrapper
return inner_function

View File

@@ -0,0 +1,42 @@
"""File system functions."""
from __future__ import annotations
import os
import shutil
from typing import TypeAlias
from homeassistant.core import HomeAssistant
# From typeshed
StrOrBytesPath: TypeAlias = str | bytes | os.PathLike[str] | os.PathLike[bytes]
FileDescriptorOrPath: TypeAlias = int | StrOrBytesPath
async def async_exists(hass: HomeAssistant, path: FileDescriptorOrPath) -> bool:
"""Test whether a path exists."""
return await hass.async_add_executor_job(os.path.exists, path)
async def async_remove(
hass: HomeAssistant, path: StrOrBytesPath, *, missing_ok: bool = False
) -> None:
"""Remove a path."""
try:
return await hass.async_add_executor_job(os.remove, path)
except FileNotFoundError:
if missing_ok:
return
raise
async def async_remove_directory(
hass: HomeAssistant, path: StrOrBytesPath, *, missing_ok: bool = False
) -> None:
"""Remove a directory."""
try:
return await hass.async_add_executor_job(shutil.rmtree, path)
except FileNotFoundError:
if missing_ok:
return
raise

View File

@@ -0,0 +1,47 @@
"""Filter functions."""
from __future__ import annotations
from typing import Any
def filter_content_return_one_of_type(
content: list[str | Any],
namestartswith: str,
filterfiltype: str,
attr: str = "name",
) -> list[str]:
"""Only match 1 of the filter."""
contents = []
filetypefound = False
for filename in content:
if isinstance(filename, str):
if filename.startswith(namestartswith):
if filename.endswith(f".{filterfiltype}"):
if not filetypefound:
contents.append(filename)
filetypefound = True
continue
else:
contents.append(filename)
else:
if getattr(filename, attr).startswith(namestartswith):
if getattr(filename, attr).endswith(f".{filterfiltype}"):
if not filetypefound:
contents.append(filename)
filetypefound = True
continue
else:
contents.append(filename)
return contents
def get_first_directory_in_directory(content: list[str | Any], dirname: str) -> str | None:
"""Return the first directory in dirname or None."""
directory = None
for path in content:
if path.full_path.startswith(dirname) and path.full_path != dirname:
if path.is_directory:
directory = path.filename
break
return directory

View File

@@ -0,0 +1,19 @@
"""GitHub GraphQL Queries."""
GET_REPOSITORY_RELEASES = """
query ($owner: String!, $name: String!, $first: Int!) {
rateLimit {
cost
}
repository(owner: $owner, name: $name) {
releases(first: $first, orderBy: {field: CREATED_AT, direction: DESC}) {
nodes {
tagName
name
isPrerelease
publishedAt
}
}
}
}
"""

View File

@@ -0,0 +1,5 @@
"""JSON utils."""
from homeassistant.util.json import json_loads
__all__ = ["json_loads"]

View File

@@ -0,0 +1,7 @@
"""Custom logger for HACS."""
import logging
from ..const import PACKAGE_NAME
LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)

View File

@@ -0,0 +1,41 @@
"""Path utils"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..base import HacsBase
@lru_cache(maxsize=1)
def _get_safe_paths(
config_path: str,
appdaemon_path: str,
plugin_path: str,
python_script_path: str,
theme_path: str,
) -> set[str]:
"""Get safe paths."""
return {
Path(f"{config_path}/{appdaemon_path}").as_posix(),
Path(f"{config_path}/{plugin_path}").as_posix(),
Path(f"{config_path}/{python_script_path}").as_posix(),
Path(f"{config_path}/{theme_path}").as_posix(),
Path(f"{config_path}/custom_components/").as_posix(),
Path(f"{config_path}/custom_templates/").as_posix(),
}
def is_safe(hacs: HacsBase, path: str | Path) -> bool:
"""Helper to check if path is safe to remove."""
configuration = hacs.configuration
return Path(path).as_posix() not in _get_safe_paths(
hacs.core.config_path,
configuration.appdaemon_path,
configuration.plugin_path,
configuration.python_script_path,
configuration.theme_path,
)

View File

@@ -0,0 +1,82 @@
"""The QueueManager class."""
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
import time
from homeassistant.core import HomeAssistant
from ..exceptions import HacsExecutionStillInProgress
from .logger import LOGGER
_LOGGER = LOGGER
class QueueManager:
"""The QueueManager class."""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self.queue: list[Coroutine] = []
self.running = False
@property
def pending_tasks(self) -> int:
"""Return a count of pending tasks in the queue."""
return len(self.queue)
@property
def has_pending_tasks(self) -> bool:
"""Return a count of pending tasks in the queue."""
return self.pending_tasks != 0
def clear(self) -> None:
"""Clear the queue."""
self.queue = []
def add(self, task: Coroutine) -> None:
"""Add a task to the queue."""
self.queue.append(task)
async def execute(self, number_of_tasks: int | None = None) -> None:
"""Execute the tasks in the queue."""
if self.running:
_LOGGER.debug("<QueueManager> Execution is already running")
raise HacsExecutionStillInProgress
if len(self.queue) == 0:
_LOGGER.debug("<QueueManager> The queue is empty")
return
self.running = True
_LOGGER.debug("<QueueManager> Checking out tasks to execute")
local_queue = []
if number_of_tasks:
for task in self.queue[:number_of_tasks]:
local_queue.append(task)
else:
for task in self.queue:
local_queue.append(task)
_LOGGER.debug("<QueueManager> Starting queue execution for %s tasks", len(local_queue))
start = time.time()
result = await asyncio.gather(*local_queue, return_exceptions=True)
for entry in result:
if isinstance(entry, Exception):
_LOGGER.error("<QueueManager> %s", entry)
end = time.time() - start
for task in local_queue:
self.queue.remove(task)
_LOGGER.debug(
"<QueueManager> Queue execution finished for %s tasks finished in %.2f seconds",
len(local_queue),
end,
)
if self.has_pending_tasks:
_LOGGER.debug("<QueueManager> %s tasks remaining in the queue", len(self.queue))
self.running = False

View File

@@ -0,0 +1,17 @@
"""Regex utils"""
from __future__ import annotations
import re
RE_REPOSITORY = re.compile(
r"(?:(?:.*github.com.)|^)([A-Za-z0-9-]+\/[\w.-]+?)(?:(?:\.git)?|(?:[^\w.-].*)?)$"
)
def extract_repository_from_url(url: str) -> str | None:
"""Extract the owner/repo part form a URL."""
match = re.match(RE_REPOSITORY, url)
if not match:
return None
return match.group(1).lower()

View File

@@ -0,0 +1,79 @@
"""Storage handers."""
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.storage import Store
from homeassistant.util import json as json_util
from ..const import VERSION_STORAGE
from ..exceptions import HacsException
from .logger import LOGGER
_LOGGER = LOGGER
class HACSStore(Store):
"""A subclass of Store that allows multiple loads in the executor."""
def load(self):
"""Load the data from disk if version matches."""
try:
data = json_util.load_json(self.path)
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception:
_LOGGER.critical(
"Could not load '%s', restore it from a backup or delete the file: %s",
self.path,
exception,
)
raise HacsException(exception) from exception
if data == {} or data["version"] != self.version:
return None
return data["data"]
def get_store_key(key):
"""Return the key to use with homeassistant.helpers.storage.Storage."""
return key if "/" in key else f"hacs.{key}"
def _get_store_for_key(hass, key, encoder):
"""Create a Store object for the key."""
return HACSStore(hass, VERSION_STORAGE, get_store_key(key), encoder=encoder, atomic_writes=True)
def get_store_for_key(hass, key):
"""Create a Store object for the key."""
return _get_store_for_key(hass, key, JSONEncoder)
async def async_load_from_store(hass, key):
"""Load the retained data from store and return de-serialized data."""
return await get_store_for_key(hass, key).async_load() or {}
async def async_save_to_store(hass, key, data):
"""Generate dynamic data to store and save it to the filesystem.
The data is only written if the content on the disk has changed
by reading the existing content and comparing it.
If the data has changed this will generate two executor jobs
If the data has not changed this will generate one executor job
"""
current = await async_load_from_store(hass, key)
if current is None or current != data:
await get_store_for_key(hass, key).async_save(data)
return
_LOGGER.debug(
"<HACSStore async_save_to_store> Did not store data for '%s'. Content did not change",
get_store_key(key),
)
async def async_remove_store(hass, key):
"""Remove a store element that should no longer be used."""
if "/" not in key:
return
await get_store_for_key(hass, key).async_remove()

View File

@@ -0,0 +1,30 @@
"""Various URL utils for HACS."""
import re
from typing import Literal
GIT_SHA = re.compile(r"^[a-fA-F0-9]{40}$")
def github_release_asset(
*,
repository: str,
version: str,
filename: str,
**_,
) -> str:
"""Generate a download URL for a release asset."""
return f"https://github.com/{repository}/releases/download/{version}/{filename}"
def github_archive(
*,
repository: str,
version: str,
variant: Literal["heads", "tags"] = "heads",
**_,
) -> str:
"""Generate a download URL for a repository zip."""
if GIT_SHA.match(version):
return f"https://github.com/{repository}/archive/{version}.zip"
return f"https://github.com/{repository}/archive/refs/{variant}/{version}.zip"

View File

@@ -0,0 +1,215 @@
"""Validation utilities."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from awesomeversion import AwesomeVersion
from homeassistant.helpers.config_validation import url as url_validator
import voluptuous as vol
from ..const import LOCALE
@dataclass
class Validate:
"""Validate."""
errors: list[str] = field(default_factory=list)
@property
def success(self) -> bool:
"""Return bool if the validation was a success."""
return len(self.errors) == 0
def _country_validator(values) -> list[str]:
"""Custom country validator."""
countries = []
if isinstance(values, str):
countries.append(values.upper())
elif isinstance(values, list):
for value in values:
countries.append(value.upper())
else:
raise vol.Invalid(f"Value '{values}' is not a string or list.", path=["country"])
for country in countries:
if country not in LOCALE:
raise vol.Invalid(f"Value '{country}' is not in {LOCALE}.", path=["country"])
return countries
HACS_MANIFEST_JSON_SCHEMA = vol.Schema(
{
vol.Optional("content_in_root"): bool,
vol.Optional("country"): _country_validator,
vol.Optional("filename"): str,
vol.Optional("hacs"): str,
vol.Optional("hide_default_branch"): bool,
vol.Optional("homeassistant"): str,
vol.Optional("persistent_directory"): str,
vol.Optional("render_readme"): bool,
vol.Optional("zip_release"): bool,
vol.Required("name"): str,
},
extra=vol.PREVENT_EXTRA,
)
INTEGRATION_MANIFEST_JSON_SCHEMA = vol.Schema(
{
vol.Required("codeowners"): list,
vol.Required("documentation"): url_validator,
vol.Required("domain"): str,
vol.Required("issue_tracker"): url_validator,
vol.Required("name"): str,
vol.Required("version"): vol.Coerce(AwesomeVersion),
},
extra=vol.ALLOW_EXTRA,
)
def validate_repo_data(schema: dict[str, Any], extra: int) -> Callable[[Any], Any]:
"""Return a validator for repo data.
This is used instead of vol.All to always try both the repo schema and
and the validate_version validator.
"""
_schema = vol.Schema(schema, extra=extra)
def validate_repo_data(data: Any) -> Any:
"""Validate integration repo data."""
schema_errors: vol.MultipleInvalid | None = None
try:
_schema(data)
except vol.MultipleInvalid as err:
schema_errors = err
try:
validate_version(data)
except vol.Invalid as err:
if schema_errors:
schema_errors.add(err)
else:
raise
if schema_errors:
raise schema_errors
return data
return validate_repo_data
def validate_version(data: Any) -> Any:
"""Ensure at least one of last_commit or last_version is present."""
if "last_commit" not in data and "last_version" not in data:
raise vol.Invalid("Expected at least one of [`last_commit`, `last_version`], got none")
return data
V2_COMMON_DATA_JSON_SCHEMA = {
vol.Required("description"): vol.Any(str, None),
vol.Optional("downloads"): int,
vol.Optional("etag_releases"): str,
vol.Required("etag_repository"): str,
vol.Required("full_name"): str,
vol.Optional("last_commit"): str,
vol.Required("last_fetched"): vol.Any(int, float),
vol.Required("last_updated"): str,
vol.Optional("last_version"): str,
vol.Optional("prerelease"): str,
vol.Required("manifest"): {
vol.Optional("country"): vol.Any([str], False),
vol.Optional("name"): str,
},
vol.Optional("open_issues"): int,
vol.Optional("stargazers_count"): int,
vol.Optional("topics"): [str],
}
V2_INTEGRATION_DATA_JSON_SCHEMA = {
**V2_COMMON_DATA_JSON_SCHEMA,
vol.Required("domain"): str,
vol.Required("manifest_name"): str,
}
_V2_REPO_SCHEMAS = {
"appdaemon": V2_COMMON_DATA_JSON_SCHEMA,
"integration": V2_INTEGRATION_DATA_JSON_SCHEMA,
"plugin": V2_COMMON_DATA_JSON_SCHEMA,
"python_script": V2_COMMON_DATA_JSON_SCHEMA,
"template": V2_COMMON_DATA_JSON_SCHEMA,
"theme": V2_COMMON_DATA_JSON_SCHEMA,
}
# Used when validating repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_REPO_DATA = {
category: validate_repo_data(schema, vol.REMOVE_EXTRA)
for category, schema in _V2_REPO_SCHEMAS.items()
}
# Used when validating repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_REPO_DATA = {
category: vol.Schema({str: validate_repo_data(schema, vol.PREVENT_EXTRA)})
for category, schema in _V2_REPO_SCHEMAS.items()
}
V2_CRITICAL_REPO_DATA_SCHEMA = {
vol.Required("link"): str,
vol.Required("reason"): str,
vol.Required("repository"): str,
}
# Used when validating critical repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
V2_CRITICAL_REPO_DATA_SCHEMA,
extra=vol.REMOVE_EXTRA,
)
# Used when validating critical repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
[
vol.Schema(
V2_CRITICAL_REPO_DATA_SCHEMA,
extra=vol.PREVENT_EXTRA,
)
]
)
V2_REMOVED_REPO_DATA_SCHEMA = {
vol.Optional("link"): str,
vol.Optional("reason"): str,
vol.Required("removal_type"): vol.In(
[
"Integration is missing a version, and is abandoned.",
"Remove",
"archived",
"blacklist",
"critical",
"deprecated",
"removal",
"remove",
"removed",
"replaced",
"repository",
]
),
vol.Required("repository"): str,
}
# Used when validating removed repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
V2_REMOVED_REPO_DATA_SCHEMA,
extra=vol.REMOVE_EXTRA,
)
# Used when validating removed repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
[
vol.Schema(
V2_REMOVED_REPO_DATA_SCHEMA,
extra=vol.PREVENT_EXTRA,
)
]
)

View File

@@ -0,0 +1,36 @@
"""Version utils."""
from __future__ import annotations
from functools import lru_cache
from awesomeversion import (
AwesomeVersion,
AwesomeVersionException,
AwesomeVersionStrategy,
)
@lru_cache(maxsize=1024)
def version_left_higher_then_right(left: str, right: str) -> bool | None:
"""Return a bool if source is newer than target, will also be true if identical."""
try:
left_version = AwesomeVersion(left)
right_version = AwesomeVersion(right)
if (
left_version.strategy != AwesomeVersionStrategy.UNKNOWN
and right_version.strategy != AwesomeVersionStrategy.UNKNOWN
):
return left_version > right_version
except (AwesomeVersionException, AttributeError, KeyError):
pass
return None
def version_left_higher_or_equal_then_right(left: str, right: str) -> bool:
"""Return a bool if source is newer than target, will also be true if identical."""
if left == right:
return True
return version_left_higher_then_right(left, right)

View File

@@ -0,0 +1,37 @@
"""Workarounds."""
from homeassistant.core import HomeAssistant
DOMAIN_OVERRIDES = {
# https://github.com/hacs/integration/issues/2465
"custom-components/sensor.custom_aftership": "custom_aftership"
}
try:
from homeassistant.components.http import StaticPathConfig
async def async_register_static_path(
hass: HomeAssistant,
url_path: str,
path: str,
cache_headers: bool = True,
) -> None:
"""Register a static path with the HTTP component."""
await hass.http.async_register_static_paths(
[StaticPathConfig(url_path, path, cache_headers)]
)
except ImportError:
async def async_register_static_path(
hass: HomeAssistant,
url_path: str,
path: str,
cache_headers: bool = True,
) -> None:
"""Register a static path with the HTTP component.
Legacy: Can be removed when min version is 2024.7
https://developers.home-assistant.io/blog/2024/06/18/async_register_static_paths/
"""
hass.http.register_static_path(url_path, path, cache_headers)

View File

@@ -0,0 +1,30 @@
# Repository validation
This is where the validation rules that run against the various repository categories live.
## Structure
- There is one file pr. rule.
- All rule needs tests to verify every possible outcome for the rule.
- It's better with multiple files than a big rule.
- All rules uses `ActionValidationBase` as the base class.
- Only use `validate` or `async_validate` methods to define validation rules.
- If a rule should fail, raise `ValidationException` with the failure message.
## Example
```python
from .base import (
ActionValidationBase,
ValidationBase,
ValidationException,
)
class SuperAwesomeRepository(ActionValidationBase):
category = "integration"
async def async_validate(self):
if self.repository != "super-awesome":
raise ValidationException("The repository is not super-awesome")
```

View File

@@ -0,0 +1 @@
"""Initialize validation."""

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-archived"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if self.repository.data.archived:
raise ValidationException("The repository is archived")

View File

@@ -0,0 +1,54 @@
"""Base class for validation."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..exceptions import HacsException
if TYPE_CHECKING:
from ..enums import HacsCategory
from ..repositories.base import HacsRepository
class ValidationException(HacsException):
"""Raise when there is a validation issue."""
class ActionValidationBase:
"""Base class for action validation."""
categories: tuple[HacsCategory, ...] = ()
allow_fork: bool = True
more_info: str = "https://hacs.xyz/docs/publish/action"
def __init__(self, repository: HacsRepository) -> None:
self.hacs = repository.hacs
self.repository = repository
self.failed = False
@property
def slug(self) -> str:
"""Return the check slug."""
return self.__class__.__module__.rsplit(".", maxsplit=1)[-1]
async def async_validate(self) -> None:
"""Validate the repository."""
async def execute_validation(self, *_: Any, **__: Any) -> None:
"""Execute the task defined in subclass."""
self.failed = False
try:
await self.async_validate()
except ValidationException as exception:
self.failed = True
self.hacs.log.error(
"<Validation %s> failed: %s (More info: %s )",
self.slug,
exception,
self.more_info,
)
else:
self.hacs.log.info("<Validation %s> completed", self.slug)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.hacs.enums import HacsCategory
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
URL = "https://brands.home-assistant.io/domains.json"
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-brands"
categories = (HacsCategory.INTEGRATION,)
async def async_validate(self) -> None:
"""Validate the repository."""
response = await self.hacs.session.get(URL)
content = await response.json()
if self.repository.data.domain not in content["custom"]:
raise ValidationException(
"The repository has not been added as a custom domain to the brands repo"
)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if not self.repository.data.description:
raise ValidationException("The repository has no description")

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from voluptuous.error import Invalid
from voluptuous.humanize import humanize_error
from ..enums import HacsCategory, RepositoryFile
from ..repositories.base import HacsManifest, HacsRepository
from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA
from .base import ActionValidationBase, ValidationException
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest"
async def async_validate(self) -> None:
"""Validate the repository."""
if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]:
raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file")
content = await self.repository.async_get_hacs_json(self.repository.ref)
try:
hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(content))
except Invalid as exception:
raise ValidationException(humanize_error(content, exception)) from exception
if self.repository.data.category == HacsCategory.INTEGRATION:
if hacsjson.zip_release and not hacsjson.filename:
raise ValidationException("zip_release is True, but filename is not set")

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ..enums import HacsCategory
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
IGNORED = ["-shield", "img.shields.io", "buymeacoffee.com"]
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
categories = (HacsCategory.PLUGIN, HacsCategory.THEME)
more_info = "https://hacs.xyz/docs/publish/include#check-images"
async def async_validate(self) -> None:
"""Validate the repository."""
info = await self.repository.async_get_info_file_contents(version=self.repository.ref)
for line in info.split("\n"):
if "<img" in line or "![" in line:
if [ignore for ignore in IGNORED if ignore in line]:
continue
return
raise ValidationException("The repository does not have images in the Readme file")

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-info"
async def async_validate(self) -> None:
"""Validate the repository."""
filenames = [x.filename.lower() for x in self.repository.tree]
if "readme" in filenames:
pass
elif "readme.md" in filenames:
pass
elif "info" in filenames:
pass
elif "info.md" in filenames:
pass
else:
raise ValidationException("The repository has no information file")

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from voluptuous.error import Invalid
from ..enums import HacsCategory, RepositoryFile
from ..utils.validate import INTEGRATION_MANIFEST_JSON_SCHEMA
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
from ..repositories.integration import HacsIntegrationRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
repository: HacsIntegrationRepository
more_info = "https://hacs.xyz/docs/publish/include#check-manifest"
categories = (HacsCategory.INTEGRATION,)
async def async_validate(self) -> None:
"""Validate the repository."""
if RepositoryFile.MAINIFEST_JSON not in [x.filename for x in self.repository.tree]:
raise ValidationException(
f"The repository has no '{RepositoryFile.MAINIFEST_JSON}' file"
)
content = await self.repository.get_integration_manifest(version=self.repository.ref)
try:
INTEGRATION_MANIFEST_JSON_SCHEMA(content)
except Invalid as exception:
raise ValidationException(exception) from exception

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if not self.repository.data.has_issues:
raise ValidationException("The repository does not have issues enabled")

View File

@@ -0,0 +1,81 @@
"""Hacs validation manager."""
from __future__ import annotations
import asyncio
from importlib import import_module
import os
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..repositories.base import HacsRepository
from .base import ActionValidationBase
class ValidationManager:
"""Hacs validation manager."""
def __init__(self, hacs: HacsBase, hass: HomeAssistant) -> None:
"""Initialize the setup manager class."""
self.hacs = hacs
self.hass = hass
self._validators: dict[str, ActionValidationBase] = {}
@property
def validators(self) -> list[ActionValidationBase]:
"""Return all list of all tasks."""
return list(self._validators.values())
async def async_load(self, repository: HacsRepository) -> None:
"""Load all tasks."""
self._validators = {}
validator_files = Path(__file__).parent
validator_modules = (
module.stem
for module in validator_files.glob("*.py")
if module.name not in ("base.py", "__init__.py", "manager.py")
)
async def _load_module(module: str) -> None:
task_module = import_module(f"{__package__}.{module}")
if task := await task_module.async_setup_validator(repository=repository):
self._validators[task.slug] = task
await asyncio.gather(*[_load_module(task) for task in validator_modules])
async def async_run_repository_checks(self, repository: HacsRepository) -> None:
"""Run all validators for a repository."""
if not self.hacs.system.action:
return
await self.async_load(repository)
is_pull_from_fork = (
not os.getenv("INPUT_REPOSITORY")
and os.getenv("GITHUB_REPOSITORY") != repository.data.full_name
)
validators = [
validator
for validator in self.validators or []
if (
(not validator.categories or repository.data.category in validator.categories)
and validator.slug not in os.getenv("INPUT_IGNORE", "").split(" ")
and (not is_pull_from_fork or validator.allow_fork)
)
]
await asyncio.gather(*[validator.execute_validation() for validator in validators])
total = len(validators)
failed = len([x for x in validators if x.failed])
if failed != 0:
repository.logger.error("%s %s/%s checks failed", repository.string, failed, total)
exit(1)
else:
repository.logger.info("%s All (%s) checks passed", repository.string, total)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if not self.repository.data.topics:
raise ValidationException("The repository has no valid topics")

View File

@@ -0,0 +1,123 @@
"""Register_commands."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
import voluptuous as vol
from ..const import DOMAIN
from .critical import hacs_critical_acknowledge, hacs_critical_list
from .repositories import (
hacs_repositories_add,
hacs_repositories_clear_new,
hacs_repositories_list,
hacs_repositories_remove,
hacs_repositories_removed,
)
from .repository import (
hacs_repository_beta,
hacs_repository_download,
hacs_repository_ignore,
hacs_repository_info,
hacs_repository_refresh,
hacs_repository_release_notes,
hacs_repository_releases,
hacs_repository_remove,
hacs_repository_state,
hacs_repository_version,
)
if TYPE_CHECKING:
from ..base import HacsBase
@callback
def async_register_websocket_commands(hass: HomeAssistant) -> None:
"""Register_commands."""
websocket_api.async_register_command(hass, hacs_info)
websocket_api.async_register_command(hass, hacs_subscribe)
websocket_api.async_register_command(hass, hacs_repository_info)
websocket_api.async_register_command(hass, hacs_repository_download)
websocket_api.async_register_command(hass, hacs_repository_ignore)
websocket_api.async_register_command(hass, hacs_repository_state)
websocket_api.async_register_command(hass, hacs_repository_version)
websocket_api.async_register_command(hass, hacs_repository_beta)
websocket_api.async_register_command(hass, hacs_repository_refresh)
websocket_api.async_register_command(hass, hacs_repository_release_notes)
websocket_api.async_register_command(hass, hacs_repository_remove)
websocket_api.async_register_command(hass, hacs_critical_acknowledge)
websocket_api.async_register_command(hass, hacs_critical_list)
websocket_api.async_register_command(hass, hacs_repositories_list)
websocket_api.async_register_command(hass, hacs_repositories_add)
websocket_api.async_register_command(hass, hacs_repositories_clear_new)
websocket_api.async_register_command(hass, hacs_repositories_removed)
websocket_api.async_register_command(hass, hacs_repositories_remove)
websocket_api.async_register_command(hass, hacs_repository_releases)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/subscribe",
vol.Required("signal"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_subscribe(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle websocket subscriptions."""
@callback
def forward_messages(data: dict | None = None) -> None:
"""Forward events to websocket."""
connection.send_message(websocket_api.event_message(msg["id"], data))
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
hass,
msg["signal"],
forward_messages,
)
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/info",
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return information about HACS."""
hacs: HacsBase = hass.data.get(DOMAIN)
connection.send_message(
websocket_api.result_message(
msg["id"],
{
"categories": hacs.common.categories,
"country": hacs.configuration.country,
"debug": hacs.configuration.debug,
"dev": hacs.configuration.dev,
"disabled_reason": hacs.system.disabled_reason,
"has_pending_tasks": hacs.queue.has_pending_tasks,
"lovelace_mode": hacs.core.lovelace_mode,
"stage": hacs.stage,
"startup": hacs.status.startup,
"version": hacs.version,
},
)
)

View File

@@ -0,0 +1,59 @@
"""Register info websocket commands."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from ..utils.store import async_load_from_store, async_save_to_store
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/critical/list",
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_critical_list(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List critical repositories."""
connection.send_message(
websocket_api.result_message(
msg["id"],
(await async_load_from_store(hass, "critical") or []),
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/critical/acknowledge",
vol.Optional("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_critical_acknowledge(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Acknowledge critical repository."""
repository = msg["repository"]
critical = await async_load_from_store(hass, "critical")
for repo in critical:
if repository == repo["repository"]:
repo["acknowledged"] = True
await async_save_to_store(hass, "critical", critical)
connection.send_message(websocket_api.result_message(msg["id"], critical))

View File

@@ -0,0 +1,216 @@
"""Register info websocket commands."""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Any
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from custom_components.hacs.utils import regex
from ..const import DOMAIN
from ..enums import HacsDispatchEvent
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..base import HacsBase
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/list",
vol.Optional("categories"): [str],
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_list(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List repositories."""
hacs: HacsBase = hass.data.get(DOMAIN)
connection.send_message(
websocket_api.result_message(
msg["id"],
[
{
"authors": repo.data.authors,
"available_version": repo.display_available_version,
"installed_version": repo.display_installed_version,
"config_flow": repo.data.config_flow,
"can_download": repo.can_download,
"category": repo.data.category,
"country": repo.repository_manifest.country,
"custom": not hacs.repositories.is_default(str(repo.data.id)),
"description": repo.data.description,
"domain": repo.data.domain,
"downloads": repo.data.downloads,
"file_name": repo.data.file_name,
"full_name": repo.data.full_name,
"hide": repo.data.hide,
"homeassistant": repo.repository_manifest.homeassistant,
"id": repo.data.id,
"installed": repo.data.installed,
"last_updated": repo.data.last_updated,
"local_path": repo.content.path.local,
"name": repo.display_name,
"new": repo.data.new,
"pending_upgrade": repo.pending_update,
"stars": repo.data.stargazers_count,
"state": repo.state,
"status": repo.display_status,
"topics": repo.data.topics,
}
for repo in hacs.repositories.list_all
if repo.data.category in msg.get("categories", hacs.common.categories)
and not repo.ignored_by_country_configuration
and repo.data.last_fetched
],
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/clear_new",
vol.Optional("categories"): cv.ensure_list,
vol.Optional("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_clear_new(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Clear new repositories for specific categories."""
hacs: HacsBase = hass.data.get(DOMAIN)
if repo := msg.get("repository"):
repository = hacs.repositories.get_by_id(repo)
repository.data.new = False
else:
for repo in hacs.repositories.list_all:
if repo.data.new and repo.data.category in msg.get("categories", []):
hacs.log.debug(
"Clearing new flag from '%s'",
repo.data.full_name,
)
repo.data.new = False
hacs.async_dispatch(HacsDispatchEvent.REPOSITORY, {})
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/removed",
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_removed(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get information about removed repositories."""
hacs: HacsBase = hass.data.get(DOMAIN)
content = []
for repo in hacs.repositories.list_removed:
if repo.repository not in hacs.common.ignored_repositories:
content.append(repo.to_json())
connection.send_message(websocket_api.result_message(msg["id"], content))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/add",
vol.Required("repository"): cv.string,
vol.Required("category"): vol.Lower,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_add(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Add custom repositoriy."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = regex.extract_repository_from_url(msg["repository"])
category = msg["category"]
if repository is None:
return
if repository in hacs.common.skip:
hacs.common.skip.remove(repository)
if renamed := hacs.common.renamed_repositories.get(repository):
repository = renamed
if category not in hacs.common.categories:
hacs.log.error("%s is not a valid category for %s", category, repository)
elif not hacs.repositories.get_by_full_name(repository):
try:
await hacs.async_register_repository(
repository_full_name=repository,
category=category,
)
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception:
hacs.async_dispatch(
HacsDispatchEvent.ERROR,
{
"action": "add_repository",
"exception": str(sys.exc_info()[0].__name__),
"message": str(exception),
},
)
else:
hacs.async_dispatch(
HacsDispatchEvent.ERROR,
{
"action": "add_repository",
"message": f"Repository '{repository}' exists in the store.",
},
)
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/remove",
vol.Required("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_remove(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove custom repositoriy."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
repository.remove()
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))

View File

@@ -0,0 +1,369 @@
"""Register info websocket commands."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from ..const import DOMAIN
from ..enums import HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.version import version_left_higher_then_right
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..base import HacsBase
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/info",
vol.Required("repository_id"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return information about a repository."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository_id = msg["repository_id"]
repository = hacs.repositories.get_by_id(repository_id)
if repository is None:
connection.send_error(
msg["id"],
"repository_not_found",
f"Repository with ID ({repository_id}) not found",
)
return
if not repository.updated_info:
try:
await repository.update_repository(ignore_issues=True, force=True)
except Exception as exception: # pylint: disable=broad-except
repository.logger.error("%s %s", repository.string, exception)
repository.updated_info = True
if repository.data.new:
repository.data.new = False
await hacs.data.async_write()
connection.send_message(
websocket_api.result_message(
msg["id"],
{
"additional_info": repository.additional_info,
"authors": repository.data.authors,
"available_version": repository.display_available_version,
"beta": repository.data.show_beta,
"can_download": repository.can_download,
"category": repository.data.category,
"config_flow": repository.data.config_flow,
"country": repository.repository_manifest.country,
"custom": not hacs.repositories.is_default(str(repository.data.id)),
"default_branch": repository.data.default_branch,
"description": repository.data.description,
"domain": repository.data.domain,
"downloads": repository.data.downloads,
"file_name": repository.data.file_name,
"full_name": repository.data.full_name,
"hide_default_branch": repository.repository_manifest.hide_default_branch,
"homeassistant": repository.repository_manifest.homeassistant,
"id": repository.data.id,
"installed_version": repository.display_installed_version,
"installed": repository.data.installed,
"issues": repository.data.open_issues,
"last_updated": repository.data.last_updated,
"local_path": repository.content.path.local,
"name": repository.display_name,
"new": False,
"pending_upgrade": repository.pending_update,
"releases": repository.data.published_tags,
"ref": repository.ref,
"selected_tag": repository.data.selected_tag,
"stars": repository.data.stargazers_count,
"state": repository.state,
"status": repository.display_status,
"topics": repository.data.topics,
"version_or_commit": repository.display_version_or_commit,
},
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/ignore",
vol.Required("repository"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_ignore(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Ignore a repository."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository_id = msg["repository"]
hacs.log.info("Ignoring %s", repository_id)
repository = hacs.repositories.get_by_id(repository_id)
if repository is None:
connection.send_error(
msg["id"],
"repository_not_found",
f"Repository with ID ({repository_id}) not found",
)
return
hacs.common.ignored_repositories.add(repository.data.full_name)
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/state",
vol.Required("repository"): cv.string,
vol.Required("state"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_state(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set the state of a repository"""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
repository.state = msg["state"]
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/version",
vol.Required("repository"): cv.string,
vol.Required("version"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_version(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set the version of a repository"""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
if msg["version"] == repository.data.default_branch:
repository.data.selected_tag = None
else:
repository.data.selected_tag = msg["version"]
await repository.update_repository(force=True)
repository.state = None
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/beta",
vol.Required("repository"): cv.string,
vol.Required("show_beta"): cv.boolean,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_beta(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Show or hide beta versions of a repository"""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
repository.data.show_beta = msg["show_beta"]
await repository.update_repository(force=True)
repository.state = None
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/download",
vol.Required("repository"): cv.string,
vol.Optional("version"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_download(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set the version of a repository"""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
try:
was_installed = repository.data.installed
await repository.async_download_repository(ref=msg.get("version"))
if not was_installed:
hacs.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True})
await hacs.async_recreate_entities()
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
except HacsException as exception:
repository.logger.error("%s %s", repository.string, exception)
connection.send_error(msg["id"], "error", str(exception))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/remove",
vol.Required("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_remove(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove a repository."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
repository.data.new = False
try:
await repository.update_repository(ignore_issues=True, force=True)
except Exception as exception: # pylint: disable=broad-except
repository.logger.error("%s %s", repository.string, exception)
await repository.uninstall()
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/refresh",
vol.Required("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_refresh(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Refresh a repository."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
await repository.update_repository(ignore_issues=True, force=True)
await hacs.data.async_write()
# Update state of update entity
hacs.coordinators[repository.data.category].async_update_listeners()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/release_notes",
vol.Required("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_release_notes(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return release notes."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
connection.send_message(
websocket_api.result_message(
msg["id"],
[
{
"name": x.name,
"body": x.body,
"tag": x.tag_name,
}
for x in repository.releases.objects
if not repository.data.installed_version
or version_left_higher_then_right(x.tag_name, repository.data.installed_version)
],
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/releases",
vol.Required("repository_id"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_releases(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return releases."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository_id"])
try:
releases = await repository.async_get_releases()
except Exception as exception:
hacs.log.exception(exception)
connection.send_error(msg["id"], "unknown", str(exception))
return
connection.send_message(
websocket_api.result_message(
msg["id"],
[
{
"name": release.name,
"tag": release.tag_name,
"published_at": release.published_at,
"prerelease": release.prerelease,
}
for release in releases
],
)
)