some new automations

This commit is contained in:
Your Name
2019-11-07 12:36:14 +03:00
parent 7a32cb74da
commit a00aaba52b
116 changed files with 6720 additions and 126 deletions
@@ -0,0 +1,391 @@
"""Initialize the HACS base."""
# pylint: disable=unused-argument, bad-continuation
import json
import uuid
from datetime import timedelta
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from aiogithubapi import AIOGitHubException, AIOGitHubRatelimit
from integrationhelper import Logger
from ..const import ELEMENT_TYPES
from ..store import async_load_from_store, async_save_to_store
class HacsStatus:
"""HacsStatus."""
startup = False
background_task = False
reloading_data = False
upgrading_all = False
class HacsCommon:
"""Common for HACS."""
categories = []
blacklist = []
default = []
installed = []
skip = []
class System:
"""System info."""
status = HacsStatus()
config_path = None
ha_version = None
disabled = False
class Developer:
"""Developer settings/tools."""
template_id = "Repository ID"
template_content = ""
template_raw = ""
@property
def devcontainer(self):
"""Is it a devcontainer?"""
import os
if "DEVCONTAINER" in os.environ:
return True
return False
class Hacs:
"""The base class of HACS, nested thoughout the project."""
token = f"{str(uuid.uuid4())}-{str(uuid.uuid4())}"
hacsweb = f"/hacsweb/{token}"
hacsapi = f"/hacsapi/{token}"
repositories = []
repo = None
data_repo = None
developer = Developer()
data = None
configuration = None
logger = Logger("hacs")
github = None
hass = None
version = None
system = System()
tasks = []
common = HacsCommon()
@staticmethod
def init(hass, github_token):
"""Return a initialized HACS object."""
return Hacs()
def get_by_id(self, repository_id):
"""Get repository by ID."""
try:
for repository in self.repositories:
if repository.information.uid == repository_id:
return repository
except Exception: # pylint: disable=broad-except
pass
return None
def get_by_name(self, repository_full_name):
"""Get repository by full_name."""
try:
for repository in self.repositories:
if repository.information.full_name == repository_full_name:
return repository
except Exception: # pylint: disable=broad-except
pass
return None
def is_known(self, repository_full_name):
"""Return a bool if the repository is known."""
for repository in self.repositories:
if repository.information.full_name == repository_full_name:
return True
return False
@property
def sorted_by_name(self):
"""Return a sorted(by name) list of repository objects."""
return sorted(self.repositories, key=lambda x: x.display_name)
@property
def sorted_by_repository_name(self):
"""Return a sorted(by repository_name) list of repository objects."""
return sorted(self.repositories, key=lambda x: x.information.full_name)
async def register_repository(self, full_name, category, check=True):
"""Register a repository."""
from ..repositories.repository import RERPOSITORY_CLASSES
if full_name in self.common.skip:
if full_name != "hacs/integration":
self.logger.debug(f"Skipping {full_name}")
return
if category not in RERPOSITORY_CLASSES:
self.logger.error(f"{category} is not a valid repository category.")
return False
repository = RERPOSITORY_CLASSES[category](full_name)
if check:
try:
await repository.registration()
if repository.validate.errors:
self.common.skip.append(repository.information.full_name)
if not self.system.status.startup:
self.logger.error(f"Validation for {full_name} failed.")
return repository.validate.errors
repository.logger.info("Registration complete")
except AIOGitHubException as exception:
self.logger.debug(self.github.ratelimits.remaining)
self.logger.debug(self.github.ratelimits.reset_utc)
self.common.skip.append(repository.information.full_name)
if not self.system.status.startup:
self.logger.error(
f"Validation for {full_name} failed with {exception}."
)
return exception
self.hass.bus.async_fire(
"hacs/repository",
{
"id": 1337,
"action": "registration",
"repository": repository.information.full_name,
"repository_id": repository.information.uid,
},
)
self.repositories.append(repository)
async def startup_tasks(self):
"""Tasks tha are started after startup."""
self.system.status.background_task = True
self.hass.bus.async_fire("hacs/status", {})
self.logger.debug(self.github.ratelimits.remaining)
self.logger.debug(self.github.ratelimits.reset_utc)
await self.handle_critical_repositories_startup()
await self.handle_critical_repositories()
await self.load_known_repositories()
await self.clear_out_blacklisted_repositories()
self.tasks.append(
async_track_time_interval(
self.hass, self.recuring_tasks_installed, timedelta(minutes=30)
)
)
self.tasks.append(
async_track_time_interval(
self.hass, self.recuring_tasks_all, timedelta(minutes=800)
)
)
self.system.status.startup = False
self.system.status.background_task = False
self.hass.bus.async_fire("hacs/status", {})
await self.data.async_write()
async def handle_critical_repositories_startup(self):
"""Handled critical repositories during startup."""
alert = False
critical = await async_load_from_store(self.hass, "critical")
if not critical:
return
for repo in critical:
if not repo["acknowledged"]:
alert = True
if alert:
self.logger.critical("URGENT!: Check the HACS panel!")
self.hass.components.persistent_notification.create(
title="URGENT!", message="**Check the HACS panel!**"
)
async def handle_critical_repositories(self):
"""Handled critical repositories during runtime."""
# Get critical repositories
instored = []
critical = []
was_installed = False
try:
critical = await self.data_repo.get_contents("critical")
critical = json.loads(critical.content)
except AIOGitHubException:
pass
if not critical:
self.logger.debug("No critical repositories")
return
stored_critical = await async_load_from_store(self.hass, "critical")
for stored in stored_critical or []:
instored.append(stored["repository"])
stored_critical = []
for repository in critical:
self.common.blacklist.append(repository["repository"])
repo = self.get_by_name(repository["repository"])
stored = {
"repository": repository["repository"],
"reason": repository["reason"],
"link": repository["link"],
"acknowledged": True,
}
if repository["repository"] not in instored:
if repo is not None and repo.installed:
self.logger.critical(
f"Removing repository {repository['repository']}, it is marked as critical"
)
was_installed = True
stored["acknowledged"] = False
# Uninstall from HACS
repo.remove()
await repo.uninstall()
stored_critical.append(stored)
# Save to FS
await async_save_to_store(self.hass, "critical", stored_critical)
# Resart HASS
if was_installed:
self.logger.critical("Resarting Home Assistant")
self.hass.async_create_task(self.hass.async_stop(100))
async def recuring_tasks_installed(self, notarealarg=None):
"""Recuring tasks for installed repositories."""
self.logger.debug(
"Starting recuring background task for installed repositories"
)
self.system.status.background_task = True
self.hass.bus.async_fire("hacs/status", {})
self.logger.debug(self.github.ratelimits.remaining)
self.logger.debug(self.github.ratelimits.reset_utc)
for repository in self.repositories:
if repository.status.installed:
try:
await repository.update_repository()
repository.logger.debug("Information update done.")
except AIOGitHubException:
self.system.status.background_task = False
self.hass.bus.async_fire("hacs/status", {})
await self.data.async_write()
self.logger.debug(
"Recuring background task for installed repositories done"
)
return
await self.handle_critical_repositories()
self.system.status.background_task = False
self.hass.bus.async_fire("hacs/status", {})
await self.data.async_write()
self.logger.debug("Recuring background task for installed repositories done")
async def recuring_tasks_all(self, notarealarg=None):
"""Recuring tasks for all repositories."""
self.logger.debug("Starting recuring background task for all repositories")
self.system.status.background_task = True
self.hass.bus.async_fire("hacs/status", {})
self.logger.debug(self.github.ratelimits.remaining)
self.logger.debug(self.github.ratelimits.reset_utc)
for repository in self.repositories:
try:
await repository.update_repository()
repository.logger.debug("Information update done.")
except AIOGitHubException:
self.system.status.background_task = False
self.hass.bus.async_fire("hacs/status", {})
await self.data.async_write()
self.logger.debug("Recuring background task for all repositories done")
return
await self.load_known_repositories()
await self.clear_out_blacklisted_repositories()
self.system.status.background_task = False
await self.data.async_write()
self.hass.bus.async_fire("hacs/status", {})
self.hass.bus.async_fire("hacs/repository", {"action": "reload"})
self.logger.debug("Recuring background task for all repositories done")
async def clear_out_blacklisted_repositories(self):
"""Clear out blaclisted repositories."""
need_to_save = False
for repository in self.common.blacklist:
if self.is_known(repository):
repository = self.get_by_name(repository)
if repository.status.installed:
self.logger.error(
f"You have {repository.information.full_name} installed with HACS, this repositroy has been blacklisted, please consider removing it."
)
else:
need_to_save = True
repository.remove()
if need_to_save:
await self.data.async_write()
async def get_repositories(self):
"""Return a list of repositories."""
repositories = {}
if self.configuration.dev:
if self.developer.devcontainer:
repositories = {
"appdaemon": ["ludeeus/ad-hacs"],
"integration": ["ludeeus/integration-hacs"],
"plugin": ["maykar/compact-custom-header"],
"python_script": ["ludeeus/ps-hacs"],
"theme": ["ludeeus/theme-hacs"],
}
else:
for category in self.common.categories:
remote = await self.data_repo.get_contents(category)
repositories[category] = json.loads(remote.content)
if category == "plugin":
org = await self.github.get_org_repos("custom-cards")
for repo in org:
repositories[category].append(repo.full_name)
if category == "integration":
org = await self.github.get_org_repos("custom-components")
for repo in org:
repositories[category].append(repo.full_name)
if category == "theme":
org = await self.github.get_org_repos(
"home-assistant-community-themes"
)
for repo in org:
repositories[category].append(repo.full_name)
for category in repositories:
for repo in repositories[category]:
if repo not in self.common.default:
self.common.default.append(repo)
return repositories
async def load_known_repositories(self):
"""Load known repositories."""
self.logger.info("Loading known repositories")
blacklist = await self.data_repo.get_contents("blacklist")
repositories = await self.get_repositories()
for item in json.loads(blacklist.content):
if item not in self.common.blacklist:
self.common.blacklist.append(item)
for category in repositories:
for repo in repositories[category]:
if repo in self.common.blacklist:
continue
if self.is_known(repo):
continue
try:
await self.register_repository(repo, category)
except (AIOGitHubException, AIOGitHubRatelimit):
pass
@@ -0,0 +1,72 @@
"""Backup."""
import os
import shutil
import tempfile
from time import sleep
from integrationhelper import Logger
BACKUP_PATH = tempfile.gettempdir() + "/hacs_backup/"
class Backup:
"""Backup."""
def __init__(self, local_path, backup_path=BACKUP_PATH):
"""initialize."""
self.logger = Logger("hacs.backup")
self.local_path = local_path
self.backup_path = backup_path
self.backup_path_full = f"{self.backup_path}{self.local_path.split('/')[-1]}"
def create(self):
"""Create a backup in /tmp"""
if not os.path.exists(self.local_path):
return
if os.path.exists(self.backup_path):
shutil.rmtree(self.backup_path)
while os.path.exists(self.backup_path):
sleep(0.1)
os.makedirs(self.backup_path, exist_ok=True)
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.logger.debug(
f"Backup for {self.local_path}, created in {self.backup_path_full}"
)
except Exception: # pylint: disable=broad-except
pass
def restore(self):
"""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.logger.debug(
f"Restored {self.local_path}, from backup {self.backup_path_full}"
)
def cleanup(self):
"""Cleanup backup files."""
if os.path.exists(self.backup_path):
shutil.rmtree(self.backup_path)
while os.path.exists(self.backup_path):
sleep(0.1)
self.logger.debug(f"Backup dir {self.backup_path} cleared")
@@ -0,0 +1,55 @@
"""HACS Configuration."""
import attr
from custom_components.hacs.hacsbase.exceptions import HacsUserScrewupException
@attr.s(auto_attribs=True)
class Configuration:
"""Configuration class."""
# Main configuration:
appdaemon_path: str = "appdaemon/apps/"
appdaemon: bool = False
config: dict = {}
config_entry: dict = {}
config_type: str = None
dev: bool = False
frontend_mode: str = "Grid"
options: dict = {}
plugin_path: str = "www/community/"
python_script_path: str = "python_scripts/"
python_script: bool = False
sidepanel_icon: str = "mdi:alpha-c-box"
sidepanel_title: str = "Community"
theme_path: str = "themes/"
theme: bool = False
token: str = None
# Config options:
country: str = "ALL"
experimental: bool = False
release_limit: int = 5
@staticmethod
def from_dict(configuration: dict, options: dict):
"""Set attributes from dicts."""
if isinstance(options, bool) or isinstance(configuration.get("options"), bool):
raise HacsUserScrewupException("Configuration is not valid.")
if options is None:
options = {}
if not configuration:
raise HacsUserScrewupException("Configuration is not valid.")
config = Configuration()
config.config = configuration
config.options = options
for conf_type in [configuration, options]:
for key in conf_type:
setattr(config, key, conf_type[key])
return config
@@ -0,0 +1,10 @@
"""Constants for HACS"""
# pylint: disable=unused-import
STORAGE_VERSION = "6"
STORENAME = "hacs"
# Messages
NOT_SUPPORTED_HA_VERSION = "You have version '{}' of Home Assistant, but version '{}' of '{}' require version '{}' of Home Assistant, install and upgrades are disabled for this integration untill you upgrade Home Assistant."
NO_ELEMENTS = "No elements to show, open the store to install some awesome stuff."
@@ -0,0 +1,215 @@
"""Data handler for HACS."""
import os
import json
from integrationhelper import Logger
from . import Hacs
from .const import STORAGE_VERSION
from ..const import VERSION
from ..repositories.manifest import HacsManifest
from ..store import async_save_to_store, async_load_from_store
STORES = {
"old": "hacs",
"hacs": "hacs.hacs",
"installed": "hacs.installed",
"repositories": "hacs.repositories",
}
class HacsData(Hacs):
"""HacsData class."""
def __init__(self):
"""Initialize."""
self.logger = Logger("hacs.data")
def check_corrupted_files(self):
"""Return True if one (or more) of the files are corrupted."""
for store in STORES:
path = f"{self.system.config_path}/.storage/{STORES[store]}"
if os.path.exists(path):
if os.stat(path).st_size == 0:
# File is empty (corrupted)
return True
return False
def read(self, store):
"""Return data from a store."""
path = f"{self.system.config_path}/.storage/{STORES[store]}"
content = None
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as storefile:
content = storefile.read()
content = json.loads(content)
return content
async def async_write(self):
"""Write content to the store files."""
if self.system.status.background_task:
return
self.logger.debug("Saving data")
# Hacs
await async_save_to_store(
self.hass, "hacs", {"view": self.configuration.frontend_mode}
)
# Repositories
content = {}
for repository in self.repositories:
if repository.repository_manifest is not None:
repository_manifest = repository.repository_manifest.manifest
else:
repository_manifest = None
content[repository.information.uid] = {
"authors": repository.information.authors,
"topics": repository.information.topics,
"category": repository.information.category,
"description": repository.information.description,
"full_name": repository.information.full_name,
"hide": repository.status.hide,
"installed_commit": repository.versions.installed_commit,
"installed": repository.status.installed,
"last_commit": repository.versions.available_commit,
"last_release_tag": repository.versions.available,
"repository_manifest": repository_manifest,
"name": repository.information.name,
"new": repository.status.new,
"selected_tag": repository.status.selected_tag,
"show_beta": repository.status.show_beta,
"version_installed": repository.versions.installed,
}
await async_save_to_store(self.hass, "repositories", content)
self.hass.bus.async_fire("hacs/repository", {})
self.hass.bus.fire("hacs/config", {})
async def restore(self):
"""Restore saved data."""
hacs = {}
repositories = {}
try:
hacs = await async_load_from_store(self.hass, "hacs")
except KeyError:
await async_save_to_store(self.hass, "hacs", self.data.read("hacs")["data"])
hacs = await async_load_from_store(self.hass, "hacs")
try:
repositories = await async_load_from_store(self.hass, "repositories")
except KeyError:
await async_save_to_store(
self.hass, "repositories", self.data.read("repositories")["data"]
)
repositories = await async_load_from_store(self.hass, "repositories")
try:
if self.check_corrupted_files():
# Coruptted installation
self.logger.critical("Restore failed one or more files are corrupted!")
return False
if hacs is None and repositories is None:
# Assume new install
return True
self.logger.info("Restore started")
# Hacs
self.configuration.frontend_mode = hacs.get("view", "Grid")
# Repositories
repositories = repositories
for entry in repositories:
repo = repositories[entry]
if repo["full_name"] == "custom-components/hacs":
# Skip the old repo location
continue
if not self.is_known(repo["full_name"]):
await self.register_repository(
repo["full_name"], repo["category"], False
)
repository = self.get_by_name(repo["full_name"])
if repository is None:
self.logger.error(f"Did not find {repo['full_name']}")
continue
# Restore repository attributes
if repo.get("authors") is not None:
repository.information.authors = repo["authors"]
if repo.get("topics", []):
repository.information.topics = repo["topics"]
if repo.get("description") is not None:
repository.information.description = repo["description"]
if repo.get("name") is not None:
repository.information.name = repo["name"]
if repo.get("hide") is not None:
repository.status.hide = repo["hide"]
if repo.get("installed") is not None:
repository.status.installed = repo["installed"]
if repository.status.installed:
repository.status.first_install = False
if repo.get("selected_tag") is not None:
repository.status.selected_tag = repo["selected_tag"]
if repo.get("repository_manifest") is not None:
repository.repository_manifest = HacsManifest.from_dict(
repo["repository_manifest"]
)
if repo.get("show_beta") is not None:
repository.status.show_beta = repo["show_beta"]
if repo.get("last_commit") is not None:
repository.versions.available_commit = repo["last_commit"]
repository.information.uid = entry
if repo.get("last_release_tag") is not None:
repository.releases.last_release = repo["last_release_tag"]
repository.versions.available = repo["last_release_tag"]
if repo.get("new") is not None:
repository.status.new = repo["new"]
if repo["full_name"] == "hacs/integration":
repository.versions.installed = VERSION
repository.status.installed = True
if "b" in VERSION:
repository.status.show_beta = True
elif repo.get("version_installed") is not None:
repository.versions.installed = repo["version_installed"]
if repo.get("installed_commit") is not None:
repository.versions.installed_commit = repo["installed_commit"]
self.logger.info("Restore done")
except Exception as exception:
self.logger.critical(
f"[{exception}] Restore Failed! see https://github.com/hacs/integration/issues/639 for more details."
)
return False
return True
def save(logger, path, content):
"""Save file."""
from .backup import Backup
backup = Backup(path)
backup.create()
try:
content = {"data": content, "schema": STORAGE_VERSION}
with open(path, "w", encoding="utf-8") as storefile:
json.dump(content, storefile, indent=4)
except Exception as exception: # pylint: disable=broad-except
logger.warning(f"Saving {path} failed - {exception}")
backup.restore()
backup.cleanup()
@@ -0,0 +1,45 @@
"""Custom Exceptions."""
class HacsBaseException(Exception):
"""Super basic."""
class HacsUserScrewupException(HacsBaseException):
"""Raise this when the user does something they should not do."""
class HacsNotSoBasicException(HacsBaseException):
"""Not that basic."""
class HacsDataFileMissing(HacsBaseException):
"""Raise this storage datafile is missing."""
class HacsDataNotExpected(HacsBaseException):
"""Raise this when data returned from storage is not ok."""
class HacsRepositoryInfo(HacsBaseException):
"""Raise this when repository info is missing/wrong."""
class HacsRequirement(HacsBaseException):
"""Raise this when repository is missing a requirement."""
class HacsMissingManifest(HacsBaseException):
"""Raise this when manifest is missing."""
def __init__(self, message="The manifest file is missing in the repository."):
super().__init__(message)
self.message = message
class HacsBlacklistException(HacsBaseException):
"""Raise this when the repository is currently in the blacklist."""
def __init__(self, message="The repository is currently in the blacklist."):
super().__init__(message)
self.message = message
@@ -0,0 +1,129 @@
"""HACS Migration logic."""
# pylint: disable=broad-except,no-member
from shutil import copy2
from integrationhelper import Logger, Validate
from . import Hacs
from .data import save, STORES
from .const import STORAGE_VERSION
MIGRATIONS = {}
def register(cls):
"""Register steps."""
MIGRATIONS[cls.from_version] = cls
return cls
class ValidateData(Hacs):
"""Validate."""
def validate_local_data_file(self):
"""Validate content."""
validate = Validate()
old_data = self.data.read("old")
if old_data is None:
# new install.
return True
if old_data.get("hacs", {}).get("schema") is None:
return True
if old_data.get("hacs", {}).get("schema") == STORAGE_VERSION:
# Newest version, no need to do anything.
return
current = old_data.get("hacs", {}).get("schema")
for version in range(int(current), int(STORAGE_VERSION)):
if current in MIGRATIONS:
MIGRATIONS[current](old_data).migrate()
else:
validate.errors.append(f"Missing migration step for {version}")
if validate.errors:
for error in validate.errors:
self.logger.critical(error)
return validate.success
class Migration(Hacs):
"""Hacs migrations"""
def __init__(self, old_data=None):
"""initialize migration."""
self.old_data = old_data
self.logger = Logger("hacs.migration")
self.source = f"{self.system.config_path}/.storage/hacs"
self.cleanup()
self.backup()
def cleanup(self):
"""Remove files no longer in use."""
def backup(self):
"""Back up old file."""
destination = f"{self.source}.{self.from_version}"
self.logger.info(f"Backing up current file to '{destination}'")
copy2(self.source, destination)
@register
class FromVersion4(Migration):
"""Migrate from version 4"""
from_version = "4"
def migrate(self):
"""Start migration."""
self.logger.info(f"Starting migration from {self.from_version}")
hacs = self.old_data["hacs"]
repositories = self.old_data["repositories"]
installed = {}
for repository in repositories:
repository = repositories[repository]
repository["full_name"] = repository["repository_name"]
repository["category"] = repository["repository_type"]
if not repository["installed"]:
repository["show_beta"] = False
else:
if repository["version_installed"] is not None:
version_type = "version"
version_installed = repository["version_installed"]
version_available = repository["last_release_tag"]
else:
version_type = "commit"
version_installed = repository["installed_commit"]
version_available = repository["last_commit"]
if repository["full_name"] != "hacs/integration":
installed[repository["repository_name"]] = {
"version_type": str(version_type),
"version_installed": str(version_installed),
"version_available": str(version_available),
}
path = f"{self.system.config_path}/.storage/{STORES['hacs']}"
save(self.logger, path, hacs)
path = f"{self.system.config_path}/.storage/{STORES['repositories']}"
save(self.logger, path, repositories)
path = f"{self.system.config_path}/.storage/{STORES['installed']}"
save(self.logger, path, installed)
self.logger.info("Migration done")
@register
class FromVersion5(Migration):
"""Migrate from version 5"""
from_version = "5"
def migrate(self):
"""Start migration."""
self.logger.info("Migration done")
@@ -0,0 +1,16 @@
"""HACS Startup."""
from . import Hacs
class HacsStartup(Hacs):
"""Startup class."""
async def run_startup(self):
"""Run startup tasks for HACS."""
self.logger.critical("Startup!")
if self.configuration.dev:
self.logger.critical("Running in DEV mode!")
if self.developer.devcontainer:
self.logger.critical("Running inside a devcontainer")
self.logger.critical("Some features have been disabled")
self.logger.critical("It will only fetch one repository pr category")
@@ -0,0 +1,6 @@
"""HACS Updater."""
from . import Hacs
class HacsUpdate(Hacs):
"""Update class."""