New Addon
Google BK
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# flake8: noqa
|
||||
from .hasource import HaSource, HABackup, PendingBackup, SOURCE_HA
|
||||
from .haupdater import HaUpdater
|
||||
from .harequests import HaRequests, EVENT_BACKUP_END, EVENT_BACKUP_START, VERSION_BACKUP_PATH
|
||||
from .backupname import BackupName, BACKUP_NAME_KEYS
|
||||
from .password import Password
|
||||
from .addon_stopper import AddonStopper
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from backup.config import Config, Setting
|
||||
from backup.file import JsonFileSaver
|
||||
from backup.worker import Worker
|
||||
from backup.exceptions import SupervisorFileSystemError
|
||||
from .harequests import HaRequests
|
||||
from injector import inject, singleton
|
||||
from backup.time import Time
|
||||
from backup.logger import getLogger
|
||||
from datetime import timedelta
|
||||
from asyncio import Lock
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
CHECK_DURATION = timedelta(seconds=60)
|
||||
ATTR_STATE = "state"
|
||||
ATTR_WATCHDOG = "watchdog"
|
||||
ATTR_NAME = "name"
|
||||
STATE_STOPPED = "stopped"
|
||||
STATE_STARTED = "started"
|
||||
|
||||
STATES_STOPPED = ["stopped", "unknown", "error"]
|
||||
|
||||
|
||||
@singleton
|
||||
class AddonStopper(Worker):
|
||||
@inject
|
||||
def __init__(self, config: Config, requests: HaRequests, time: Time):
|
||||
super().__init__("StartandStopTimer", self.check, time, 10)
|
||||
self.requests = requests
|
||||
self.config = config
|
||||
self.time = time
|
||||
self.must_start = set()
|
||||
self.must_enable_watchdog = set()
|
||||
self.stop_start_check_time = time.now()
|
||||
self._backing_up = False
|
||||
self.allow_run = False
|
||||
self.lock = Lock()
|
||||
|
||||
async def start(self, schedule=True):
|
||||
if schedule:
|
||||
await super().start()
|
||||
path = self.config.get(Setting.STOP_ADDON_STATE_PATH)
|
||||
if JsonFileSaver.exists(path):
|
||||
data = JsonFileSaver.read(path)
|
||||
self.must_enable_watchdog = set(data.get("watchdog", []))
|
||||
self.must_start = set(data.get("start", []))
|
||||
|
||||
def allowRun(self):
|
||||
if not self.allow_run:
|
||||
for slug in self.config.get(Setting.STOP_ADDONS).split(','):
|
||||
if len(slug) == 0:
|
||||
continue
|
||||
self.must_start.add(slug)
|
||||
self.allow_run = True
|
||||
|
||||
def isBackingUp(self, backingUp):
|
||||
self._backing_up = backingUp
|
||||
|
||||
async def stopAddons(self, self_slug):
|
||||
async with self.lock:
|
||||
self._backing_up = True
|
||||
for slug in self.config.get(Setting.STOP_ADDONS).split(','):
|
||||
if slug == self_slug or len(slug) == 0:
|
||||
# Don't ask the supervisor to stop yourself. That would be BAD.
|
||||
continue
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
if info.get(ATTR_STATE, None) == STATE_STARTED:
|
||||
if info.get(ATTR_WATCHDOG, False):
|
||||
try:
|
||||
LOGGER.info("Temporarily disabling watchdog for addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.updateAddonOptions(slug, {ATTR_WATCHDOG: False})
|
||||
self.must_enable_watchdog.add(slug)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to disable watchdog for addon {0}".format(info.get(ATTR_NAME, slug)))
|
||||
LOGGER.printException(e)
|
||||
try:
|
||||
LOGGER.info("Stopping addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.stopAddon(slug)
|
||||
self.must_start.add(slug)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to stop addon '{0}'".format(info.get(ATTR_NAME, slug)))
|
||||
LOGGER.printException(e)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to lookup info for addon '{0}', please check your configuration".format(slug))
|
||||
LOGGER.printException(e)
|
||||
self._save()
|
||||
|
||||
async def startAddons(self):
|
||||
self._backing_up = False
|
||||
self.stop_start_check_time = self.time.now() + CHECK_DURATION
|
||||
await self.check()
|
||||
|
||||
async def check(self):
|
||||
async with self.lock:
|
||||
if self._backing_up:
|
||||
return
|
||||
if not self.allow_run:
|
||||
return
|
||||
changes = False
|
||||
if len(self.must_start) > 0:
|
||||
for slug in list(self.must_start):
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
state = info.get(ATTR_STATE, None)
|
||||
if info.get(ATTR_STATE, None) in STATES_STOPPED:
|
||||
LOGGER.info("Starting addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.startAddon(slug)
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
elif info.get(ATTR_STATE, None) == STATE_STARTED and self.time.now() > self.stop_start_check_time:
|
||||
# Give up on restarting it, looks like it was never stopped
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
else:
|
||||
LOGGER.error(f"Addon '{info.get(ATTR_NAME, slug)} had unrecognized state {state}'. The addon will most likely be unable to automatically restart this addon.", )
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to start addon '%s'", slug)
|
||||
LOGGER.printException(e)
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
|
||||
if len(self.must_enable_watchdog) > 0:
|
||||
for slug in list(self.must_enable_watchdog):
|
||||
if slug in self.must_start:
|
||||
# Wait until we're done trying to start the addon before re-enabling the watchdog, otherwise the supervisor complains
|
||||
continue
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
if not info.get(ATTR_WATCHDOG, True):
|
||||
LOGGER.info("Re-enabling watchdog for addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.updateAddonOptions(slug, {ATTR_WATCHDOG: True})
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to re-enable watchdog for addon '%s'", slug)
|
||||
LOGGER.printException(e)
|
||||
self.must_enable_watchdog.remove(slug)
|
||||
changes = True
|
||||
if changes:
|
||||
self._save()
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
path = self.config.get(Setting.STOP_ADDON_STATE_PATH)
|
||||
data = {"start": list(self.must_start), "watchdog": list(self.must_enable_watchdog)}
|
||||
JsonFileSaver.write(path, data)
|
||||
except OSError:
|
||||
raise SupervisorFileSystemError()
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
BACKUP_NAME_KEYS = {
|
||||
"{type}": lambda backup_type, now_local, host_info: backup_type,
|
||||
"{year}": lambda backup_type, now_local, host_info: now_local.strftime("%Y"),
|
||||
"{year_short}": lambda backup_type, now_local, host_info: now_local.strftime("%y"),
|
||||
"{weekday}": lambda backup_type, now_local, host_info: now_local.strftime("%A"),
|
||||
"{weekday_short}": lambda backup_type, now_local, host_info: now_local.strftime("%a"),
|
||||
"{month}": lambda backup_type, now_local, host_info: now_local.strftime("%m"),
|
||||
"{month_long}": lambda backup_type, now_local, host_info: now_local.strftime("%B"),
|
||||
"{month_short}": lambda backup_type, now_local, host_info: now_local.strftime("%b"),
|
||||
"{ms}": lambda backup_type, now_local, host_info: now_local.strftime("%f"),
|
||||
"{day}": lambda backup_type, now_local, host_info: now_local.strftime("%d"),
|
||||
"{hr24}": lambda backup_type, now_local, host_info: now_local.strftime("%H"),
|
||||
"{hr12}": lambda backup_type, now_local, host_info: now_local.strftime("%I"),
|
||||
"{min}": lambda backup_type, now_local, host_info: now_local.strftime("%M"),
|
||||
"{sec}": lambda backup_type, now_local, host_info: now_local.strftime("%S"),
|
||||
"{ampm}": lambda backup_type, now_local, host_info: now_local.strftime("%p"),
|
||||
"{version_ha}": lambda backup_type, now_local, host_info: str(host_info.get('homeassistant', 'Unknown')),
|
||||
"{version_hassos}": lambda backup_type, now_local, host_info: str(host_info.get('hassos', 'Unknown')),
|
||||
"{version_super}": lambda backup_type, now_local, host_info: str(host_info.get('supervisor', 'Unknown')),
|
||||
"{date}": lambda backup_type, now_local, host_info: now_local.strftime("%x"),
|
||||
"{time}": lambda backup_type, now_local, host_info: now_local.strftime("%X"),
|
||||
"{datetime}": lambda backup_type, now_local, host_info: now_local.strftime("%c"),
|
||||
"{isotime}": lambda backup_type, now_local, host_info: now_local.isoformat(),
|
||||
"{hostname}": lambda backup_type, now_local, host_info: str(host_info.get('hostname', 'Unknown')),
|
||||
}
|
||||
|
||||
|
||||
class BackupName():
|
||||
def resolve(self, backup_type: str, template: str, now_local: datetime, host_info) -> str:
|
||||
for key in BACKUP_NAME_KEYS:
|
||||
template = template.replace(key, BACKUP_NAME_KEYS[key](
|
||||
backup_type, now_local, host_info))
|
||||
return template
|
||||
@@ -0,0 +1,329 @@
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from aiohttp.client_exceptions import ClientResponseError, ClientConnectorError
|
||||
from injector import inject
|
||||
from asyncio.exceptions import TimeoutError
|
||||
|
||||
from ..util import AsyncHttpGetter
|
||||
from ..config import Config, Setting, Version
|
||||
from ..exceptions import HomeAssistantDeleteError, SupervisorConnectionError, SupervisorPermissionError, SupervisorTimeoutError, SupervisorUnexpectedError
|
||||
from ..model import HABackup
|
||||
from ..logger import getLogger
|
||||
from ..util import DataCache
|
||||
from backup.time import Time
|
||||
from backup.const import NECESSARY_OLD_BACKUP_PLURAL_NAME, NECESSARY_OLD_SUPERVISOR_URL
|
||||
from yarl import URL
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
HEADER_TOKEN = "X-Supervisor-Token"
|
||||
|
||||
NOTIFICATION_ID = "backup_broken"
|
||||
EVENT_BACKUP_START = "backup_started"
|
||||
EVENT_BACKUP_END = "backup_ended"
|
||||
|
||||
VERSION_BACKUP_PATH = Version.parse("2021.8")
|
||||
|
||||
|
||||
def supervisor_call(func):
|
||||
async def wrap_and_call(*args, **kwargs):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except ClientConnectorError:
|
||||
raise SupervisorConnectionError()
|
||||
except TimeoutError:
|
||||
raise SupervisorConnectionError()
|
||||
except ClientResponseError as e:
|
||||
if e.status == 403:
|
||||
raise SupervisorPermissionError()
|
||||
raise
|
||||
return wrap_and_call
|
||||
|
||||
|
||||
class HaRequests():
|
||||
"""
|
||||
Stores logic for interacting with the supervisor add-on API
|
||||
"""
|
||||
@inject
|
||||
def __init__(self, config: Config, session: ClientSession, time: Time, data_cache: DataCache):
|
||||
self.config: Config = config
|
||||
self.cache = {}
|
||||
self.session = session
|
||||
self._time = time
|
||||
self._data_cache = data_cache
|
||||
|
||||
# default the supervisor versio to using the "most featured" when it can't be parsed.
|
||||
self._super_version = VERSION_BACKUP_PATH
|
||||
|
||||
def getSupervisorURL(self) -> URL:
|
||||
if len(self.config.get(Setting.SUPERVISOR_URL)) > 0:
|
||||
return URL(self.config.get(Setting.SUPERVISOR_URL))
|
||||
if 'SUPERVISOR_TOKEN' in os.environ:
|
||||
return URL("http://supervisor")
|
||||
else:
|
||||
return URL(NECESSARY_OLD_SUPERVISOR_URL)
|
||||
|
||||
def _getBackupPath(self):
|
||||
if self.supportsBackupPaths():
|
||||
return "backups"
|
||||
return NECESSARY_OLD_BACKUP_PLURAL_NAME
|
||||
|
||||
def supportsBackupPaths(self):
|
||||
return not self._super_version or self._super_version >= VERSION_BACKUP_PATH
|
||||
|
||||
@supervisor_call
|
||||
async def createBackup(self, info):
|
||||
if 'folders' in info or 'addons' in info:
|
||||
url = self.getSupervisorURL().with_path("{0}/new/partial".format(self._getBackupPath()))
|
||||
else:
|
||||
url = self.getSupervisorURL().with_path("{0}/new/full".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url, info, timeout=ClientTimeout(total=self.config.get(Setting.PENDING_BACKUP_TIMEOUT_SECONDS)))
|
||||
|
||||
@supervisor_call
|
||||
async def auth(self, user: str, password: str) -> None:
|
||||
await self._postHassioData(self.getSupervisorURL().with_path("auth"), {"username": user, "password": password}, headers=self._altAuthHeaders())
|
||||
|
||||
@supervisor_call
|
||||
async def upload(self, stream):
|
||||
url = self.getSupervisorURL().with_path("{0}/new/upload".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url, data=stream)
|
||||
|
||||
@supervisor_call
|
||||
async def delete(self, slug) -> None:
|
||||
if slug in self.cache:
|
||||
del self.cache[slug]
|
||||
try:
|
||||
if self.supportsBackupPaths():
|
||||
delete_url = self.getSupervisorURL().with_path("{1}/{0}".format(slug, self._getBackupPath()))
|
||||
await self._sendHassioData("delete", delete_url, {})
|
||||
else:
|
||||
delete_url = self.getSupervisorURL().with_path("{1}/{0}/remove".format(slug, self._getBackupPath()))
|
||||
await self._sendHassioData("post", delete_url, {})
|
||||
except ClientResponseError as e:
|
||||
if e.status == 400:
|
||||
raise HomeAssistantDeleteError()
|
||||
raise e
|
||||
|
||||
@supervisor_call
|
||||
async def startAddon(self, slug) -> None:
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/start".format(slug))
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def stopAddon(self, slug) -> None:
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/stop".format(slug))
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def backup(self, slug):
|
||||
if slug in self.cache:
|
||||
info = self.cache[slug]
|
||||
else:
|
||||
info = await self._getHassioData(self.getSupervisorURL().with_path("{1}/{0}/info".format(slug, self._getBackupPath())))
|
||||
self.cache[slug] = info
|
||||
return HABackup(info, self._data_cache, self.config, self.config.isRetained(slug))
|
||||
|
||||
@supervisor_call
|
||||
async def backups(self):
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path(self._getBackupPath()))
|
||||
|
||||
@supervisor_call
|
||||
async def haInfo(self):
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("core/info"))
|
||||
|
||||
@supervisor_call
|
||||
async def selfInfo(self) -> Dict[str, Any]:
|
||||
return await self.getAddonInfo("self")
|
||||
|
||||
@supervisor_call
|
||||
async def getAddonInfo(self, addon_slug) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("addons/{0}/info".format(addon_slug)))
|
||||
|
||||
@supervisor_call
|
||||
async def getAddons(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("addons"))
|
||||
|
||||
@supervisor_call
|
||||
async def hassosInfo(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("hassos/info"))
|
||||
|
||||
@supervisor_call
|
||||
async def info(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("info"))
|
||||
|
||||
@supervisor_call
|
||||
async def refreshBackups(self):
|
||||
url = self.getSupervisorURL().with_path("{0}/reload".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url)
|
||||
|
||||
@supervisor_call
|
||||
async def supervisorInfo(self):
|
||||
url = self.getSupervisorURL().with_path("supervisor/info")
|
||||
info = await self._getHassioData(url)
|
||||
|
||||
# parse the supervisor version
|
||||
if 'version' in info:
|
||||
self._super_version = Version.parse(info['version'])
|
||||
return info
|
||||
|
||||
@supervisor_call
|
||||
async def restore(self, slug: str, password: str = None) -> None:
|
||||
url = self.getSupervisorURL().with_path("{1}/{0}/restore/full".format(slug, self._getBackupPath()))
|
||||
if password:
|
||||
await self._postHassioData(url, {'password': password})
|
||||
else:
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def download(self, slug) -> AsyncHttpGetter:
|
||||
url = self.getSupervisorURL().with_path("{1}/{0}/download".format(slug, self._getBackupPath()))
|
||||
ret = AsyncHttpGetter(url,
|
||||
self._getAuthHeaders(),
|
||||
self.session,
|
||||
timeoutFactory=SupervisorTimeoutError.factory,
|
||||
otherErrorFactory=SupervisorUnexpectedError.factory,
|
||||
timeout=ClientTimeout(sock_connect=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS),
|
||||
sock_read=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS)),
|
||||
time=self._time)
|
||||
return ret
|
||||
|
||||
@supervisor_call
|
||||
async def getSuperLogs(self):
|
||||
url = self.getSupervisorURL().with_path("supervisor/logs")
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
|
||||
@supervisor_call
|
||||
async def getCoreLogs(self):
|
||||
url = self.getSupervisorURL().with_path("core/logs")
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
|
||||
async def _validateHassioReply(self, resp) -> Dict[str, Any]:
|
||||
async with resp:
|
||||
resp.raise_for_status()
|
||||
details: Dict[str, Any] = await resp.json()
|
||||
if "result" not in details or details["result"] != "ok":
|
||||
if "result" in details:
|
||||
raise Exception("Hassio said: " + details["result"])
|
||||
else:
|
||||
raise Exception(
|
||||
"Malformed response from Hassio: " + str(details))
|
||||
|
||||
if "data" not in details:
|
||||
return {}
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Hassio replied: %s", details)
|
||||
return details["data"]
|
||||
|
||||
async def getAddonLogo(self, slug: str):
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/icon".format(slug))
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return (resp.headers['Content-Type'], await resp.read())
|
||||
|
||||
def _getToken(self):
|
||||
configured = self.config.get(Setting.SUPERVISOR_TOKEN)
|
||||
if configured and len(configured) > 0:
|
||||
return configured
|
||||
if "SUPERVISOR_TOKEN" in os.environ:
|
||||
return os.environ.get("SUPERVISOR_TOKEN")
|
||||
# Older versions of the supervisor use a different name for the token.
|
||||
return os.environ.get("HASSIO_TOKEN")
|
||||
|
||||
def _getAuthHeaders(self):
|
||||
return {
|
||||
'Authorization': 'Bearer ' + self._getToken()
|
||||
}
|
||||
|
||||
def _altAuthHeaders(self):
|
||||
return {
|
||||
HEADER_TOKEN: self._getToken()
|
||||
}
|
||||
|
||||
@supervisor_call
|
||||
async def _getHassioData(self, url: URL) -> Dict[str, Any]:
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Making Hassio request: " + str(url))
|
||||
return await self._validateHassioReply(await self.session.get(url, headers=self._getAuthHeaders()))
|
||||
|
||||
async def _postHassioData(self, url: URL, json=None, file=None, data=None, timeout=None, headers=None) -> Dict[str, Any]:
|
||||
return await self._sendHassioData("post", url, json, file, data, timeout, headers)
|
||||
|
||||
@supervisor_call
|
||||
async def _sendHassioData(self, method: str, url: URL, json=None, file=None, data=None, timeout=None, headers=None) -> Dict[str, Any]:
|
||||
if headers is None:
|
||||
headers = self._getAuthHeaders()
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Making Hassio request: " + str(url))
|
||||
return await self._validateHassioReply(await self.session.request(method, url, headers=headers, json=json, data=data, timeout=timeout))
|
||||
|
||||
async def _postHaData(self, path: str, data: Dict[str, Any]) -> None:
|
||||
url = self.getSupervisorURL().with_path("/core/api/" + path)
|
||||
async with self.session.post(url, headers=self._getAuthHeaders(), json=data) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
async def sendNotification(self, title: str, message: str) -> None:
|
||||
data: Dict[str, str] = {
|
||||
"title": title,
|
||||
"message": message,
|
||||
"notification_id": NOTIFICATION_ID
|
||||
}
|
||||
await self._postHaData("services/persistent_notification/create", data)
|
||||
|
||||
async def eventBackupStart(self, name, time):
|
||||
await self._sendEvent(EVENT_BACKUP_START, {
|
||||
'backup_name': name,
|
||||
'backup_time': str(time)
|
||||
})
|
||||
|
||||
async def eventBackupEnd(self, name, time, completed):
|
||||
await self._sendEvent(EVENT_BACKUP_END, {
|
||||
'completed': completed,
|
||||
'backup_name': name,
|
||||
'backup_time': str(time)
|
||||
})
|
||||
|
||||
async def _sendEvent(self, event_name: str, data: Dict[str, str]) -> None:
|
||||
await self._postHaData("events/" + event_name, data)
|
||||
|
||||
async def dismissNotification(self) -> None:
|
||||
data: Dict[str, str] = {
|
||||
"notification_id": NOTIFICATION_ID
|
||||
}
|
||||
await self._postHaData("services/persistent_notification/dismiss", data)
|
||||
|
||||
async def updateBackupStaleSensor(self, state: bool) -> None:
|
||||
if self.config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
data: Dict[str, Any] = {
|
||||
"state": state,
|
||||
"attributes": {
|
||||
"friendly_name": "Snapshots Stale",
|
||||
"device_class": "problem"
|
||||
}
|
||||
}
|
||||
await self._postHaData("states/binary_sensor." + NECESSARY_OLD_BACKUP_PLURAL_NAME + "_stale", data)
|
||||
else:
|
||||
data: Dict[str, Any] = {
|
||||
"state": state,
|
||||
"attributes": {
|
||||
"friendly_name": "Backups Stale",
|
||||
"device_class": "problem"
|
||||
}
|
||||
}
|
||||
await self._postHaData("states/binary_sensor.backups_stale", data)
|
||||
|
||||
@supervisor_call
|
||||
async def updateConfig(self, config) -> None:
|
||||
return await self._postHassioData(self.getSupervisorURL().with_path("addons/self/options"), {'options': config})
|
||||
|
||||
@supervisor_call
|
||||
async def updateAddonOptions(self, slug, options):
|
||||
return await self._postHassioData(self.getSupervisorURL().with_path("addons/{0}/options".format(slug)), options)
|
||||
|
||||
async def updateEntity(self, entity, data):
|
||||
await self._postHaData("states/" + entity, data)
|
||||
@@ -0,0 +1,537 @@
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from datetime import timedelta
|
||||
from io import IOBase
|
||||
from threading import Lock, Thread
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from backup.util import AsyncHttpGetter, GlobalInfo, Estimator, DataCache, KEY_NOTE, KEY_LAST_SEEN, KEY_PENDING, KEY_NAME, KEY_CREATED, KEY_I_MADE_THIS, KEY_IGNORE
|
||||
from ..config import Config, Setting, CreateOptions, Startable
|
||||
from ..const import SOURCE_HA
|
||||
from ..model import BackupSource, AbstractBackup, HABackup, Backup
|
||||
from ..exceptions import (LogicError, BackupInProgress,
|
||||
UploadFailed, ensureKey)
|
||||
from .harequests import HaRequests
|
||||
from .password import Password
|
||||
from .backupname import BackupName
|
||||
from ..time import Time
|
||||
from ..logger import getLogger, StandardLogger
|
||||
from backup.const import FOLDERS, NECESSARY_OLD_BACKUP_PLURAL_NAME
|
||||
from .addon_stopper import LOGGER, AddonStopper
|
||||
|
||||
logger: StandardLogger = getLogger(__name__)
|
||||
|
||||
|
||||
class PendingBackup(AbstractBackup):
|
||||
def __init__(self, backupType, protected, options: CreateOptions, request_info, config, time):
|
||||
super().__init__(
|
||||
name=request_info['name'],
|
||||
slug="pending",
|
||||
date=options.when,
|
||||
size="pending",
|
||||
source=SOURCE_HA,
|
||||
backupType=backupType,
|
||||
version="",
|
||||
protected=protected,
|
||||
retained=False,
|
||||
uploadable=False,
|
||||
details=None,
|
||||
note=options.note,
|
||||
pending=True)
|
||||
self._config = config
|
||||
self._failed = False
|
||||
self._complete = False
|
||||
self._exception = None
|
||||
self._failed_at = None
|
||||
self.setOptions(options)
|
||||
self._request_info = request_info
|
||||
self._completed_slug = None
|
||||
self._time = time
|
||||
self._pending_subverted = False
|
||||
self._start_time = time.now()
|
||||
|
||||
def considerForPurge(self) -> bool:
|
||||
return False
|
||||
|
||||
def startTime(self):
|
||||
return self._start_time
|
||||
|
||||
def failed(self, exception, time):
|
||||
self._failed = True
|
||||
self._exception = exception
|
||||
self._failed_at = time
|
||||
|
||||
def getFailureTime(self):
|
||||
return self._failed_at
|
||||
|
||||
def complete(self, slug):
|
||||
self._complete = True
|
||||
self._completed_slug = slug
|
||||
|
||||
def setPendingUnknown(self):
|
||||
self._name = "Pending Backup"
|
||||
self._backupType = "unknown"
|
||||
self._protected = False
|
||||
self._pending_subverted = True
|
||||
self._note = None
|
||||
|
||||
def createdSlug(self):
|
||||
return self._completed_slug
|
||||
|
||||
def isComplete(self):
|
||||
return self._complete
|
||||
|
||||
def isFailed(self):
|
||||
return self._failed
|
||||
|
||||
def status(self):
|
||||
if self._complete:
|
||||
return "Created"
|
||||
if self._failed:
|
||||
return "Failed!"
|
||||
return "Pending"
|
||||
|
||||
def raiseIfNeeded(self):
|
||||
if self.isFailed():
|
||||
raise self._exception
|
||||
if self._pending_subverted:
|
||||
raise BackupInProgress()
|
||||
|
||||
def isStale(self):
|
||||
if self._pending_subverted:
|
||||
delta = timedelta(seconds=self._config.get(
|
||||
Setting.BACKUP_STALE_SECONDS))
|
||||
if self._time.now() > self.startTime() + delta:
|
||||
return True
|
||||
if not self.isFailed():
|
||||
return False
|
||||
delta = timedelta(seconds=self._config.get(
|
||||
Setting.FAILED_BACKUP_TIMEOUT_SECONDS))
|
||||
staleTime = self.getFailureTime() + delta
|
||||
return self._time.now() >= staleTime
|
||||
|
||||
def madeByTheAddon(self):
|
||||
return True
|
||||
|
||||
|
||||
@singleton
|
||||
class HaSource(BackupSource[HABackup], Startable):
|
||||
"""
|
||||
Stores logic for interacting with the supervisor add-on API
|
||||
"""
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, ha: HaRequests, info: GlobalInfo, stopper: AddonStopper, estimator: Estimator, data_cache: DataCache):
|
||||
super().__init__()
|
||||
self.config: Config = config
|
||||
self._data_cache = data_cache
|
||||
self.backup_thread: Thread = None
|
||||
self.pending_backup_error: Optional[Exception] = None
|
||||
self.pending_backup_slug: Optional[str] = None
|
||||
self.self_info = None
|
||||
self.host_info = None
|
||||
self.ha_info = None
|
||||
self.super_info = None
|
||||
self.lock: Lock = Lock()
|
||||
self.time = time
|
||||
self.harequests = ha
|
||||
self.last_slugs = set()
|
||||
self.retained = []
|
||||
self.cached_retention = {}
|
||||
self._info = info
|
||||
self.pending_options = {}
|
||||
self.stopper = stopper
|
||||
self.estimator = estimator
|
||||
self._addons = {}
|
||||
self._changes_from_last_query = False
|
||||
|
||||
# This lock should be used for _ANYTHING_ that interacts with self._pending_backup
|
||||
self._pending_backup_lock = asyncio.Lock()
|
||||
self.pending_backup: Optional[PendingBackup] = None
|
||||
self._pending_backup_task = None
|
||||
self._initialized = False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
async def check(self) -> bool:
|
||||
pending = self.pending_backup
|
||||
if pending and pending.isStale():
|
||||
self.trigger()
|
||||
return await super().check()
|
||||
|
||||
def icon(self) -> str:
|
||||
return "home-assistant"
|
||||
|
||||
def name(self) -> str:
|
||||
return SOURCE_HA
|
||||
|
||||
def title(self) -> str:
|
||||
return "Home Assistant"
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return self.config.get(Setting.MAX_BACKUPS_IN_HA)
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def freeSpace(self):
|
||||
return self.estimator.getBytesFree()
|
||||
|
||||
async def create(self, options: CreateOptions) -> HABackup:
|
||||
# Make sure instance info is up-to-date, for the backup name
|
||||
await self._refreshInfo()
|
||||
|
||||
# Set a default name if it was unspecified
|
||||
if options.name_template is None or len(options.name_template) == 0:
|
||||
options.name_template = self.config.get(Setting.BACKUP_NAME)
|
||||
|
||||
# Build the backup request json, get type, etc
|
||||
request, type_name, protected = self._buildBackupInfo(
|
||||
options)
|
||||
|
||||
async with self._pending_backup_lock:
|
||||
# Check if a backup is already in progress
|
||||
if self.pending_backup:
|
||||
if not self.pending_backup.isFailed() and not self.pending_backup.isComplete():
|
||||
logger.info("A backup was already in progress")
|
||||
raise BackupInProgress()
|
||||
|
||||
# try to stop addons
|
||||
await self.stopper.stopAddons(self.self_info['slug'])
|
||||
|
||||
# Create the backup palceholder object
|
||||
self.pending_backup = PendingBackup(
|
||||
type_name, protected, options, request, self.config, self.time)
|
||||
logger.info("Requesting a new backup")
|
||||
self._pending_backup_task = asyncio.create_task(self._requestAsync(
|
||||
self.pending_backup), name="Pending Backup Requester")
|
||||
await asyncio.wait({self._pending_backup_task}, timeout=self.config.get(Setting.NEW_BACKUP_TIMEOUT_SECONDS))
|
||||
self.pending_backup.raiseIfNeeded()
|
||||
|
||||
# There is not other backup in progress, so assume its been requested.
|
||||
pending = self._data_cache.backup(KEY_PENDING)
|
||||
pending[KEY_NAME] = request['name']
|
||||
pending[KEY_CREATED] = options.when.isoformat()
|
||||
pending[KEY_LAST_SEEN] = self.time.now().isoformat()
|
||||
self._data_cache.makeDirty()
|
||||
if self.pending_backup.isComplete():
|
||||
# It completed while we waited, so just query the new backup
|
||||
ret = await self.harequests.backup(self.pending_backup.createdSlug())
|
||||
if options.note is not None:
|
||||
ret.setNote(options.note)
|
||||
self.setDataCacheInfo(ret)
|
||||
self._data_cache.backup(ret.slug())[KEY_I_MADE_THIS] = True
|
||||
return ret
|
||||
else:
|
||||
return self.pending_backup
|
||||
|
||||
def _isHttp400(self, e):
|
||||
if isinstance(e, ClientResponseError):
|
||||
return e.status == 400
|
||||
return False
|
||||
|
||||
async def start(self):
|
||||
try:
|
||||
await self.init()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def stop(self):
|
||||
if self._pending_backup_task:
|
||||
self._pending_backup_task.cancel()
|
||||
await asyncio.wait([self._pending_backup_task])
|
||||
|
||||
@property
|
||||
def query_had_changes(self):
|
||||
return self._changes_from_last_query
|
||||
|
||||
async def get(self) -> Dict[str, HABackup]:
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
else:
|
||||
# Always ensure the supervisor version is fresh before makign any other requests
|
||||
self.super_info = await self.harequests.supervisorInfo()
|
||||
slugs = set()
|
||||
retained = []
|
||||
backups: Dict[str, HABackup] = {}
|
||||
query = await self.harequests.backups()
|
||||
|
||||
# Different supervisor version use different names for the list of backups
|
||||
backup_list = []
|
||||
if NECESSARY_OLD_BACKUP_PLURAL_NAME in query:
|
||||
backup_list = query[NECESSARY_OLD_BACKUP_PLURAL_NAME]
|
||||
if 'backups' in query:
|
||||
backup_list = query['backups']
|
||||
|
||||
for backup in backup_list:
|
||||
slug = backup['slug']
|
||||
slugs.add(slug)
|
||||
item = await self.harequests.backup(slug)
|
||||
if slug in self.pending_options:
|
||||
item.setOptions(self.pending_options[slug])
|
||||
backups[slug] = item
|
||||
if item.retained():
|
||||
retained.append(item.slug())
|
||||
self.setDataCacheInfo(item)
|
||||
if self.pending_backup:
|
||||
async with self._pending_backup_lock:
|
||||
if self.pending_backup:
|
||||
if self.pending_backup.isStale():
|
||||
# The backup is stale, so just let it die.
|
||||
self._killPending()
|
||||
elif self.pending_backup.isComplete() and self.pending_backup.createdSlug() in backups:
|
||||
# Copy over options if we got the requested backup.
|
||||
backups[self.pending_backup.createdSlug()].setOptions(
|
||||
self.pending_backup.getOptions())
|
||||
if self.pending_backup.note() is not None:
|
||||
# Save the note with the now known slug
|
||||
await self.note(backups[self.pending_backup.createdSlug()], self.pending_backup.note())
|
||||
self._killPending()
|
||||
elif self.last_slugs.symmetric_difference(slugs).intersection(slugs):
|
||||
# New backup added, ignore pending backup.
|
||||
sorted = list(backups.values())
|
||||
sorted.sort(key=HABackup.date)
|
||||
if self.pending_backup.note() is not None and len(sorted) > 0:
|
||||
# Save the note with the newest backup
|
||||
await self.note(sorted[-1], self.pending_backup.note())
|
||||
self._killPending()
|
||||
if self.pending_backup:
|
||||
backups[self.pending_backup.slug()] = self.pending_backup
|
||||
for slug in retained:
|
||||
if not self.config.isRetained(slug):
|
||||
self.config.setRetained(slug, False)
|
||||
self._changes_from_last_query = self.last_slugs != slugs
|
||||
self.last_slugs = slugs
|
||||
return backups
|
||||
|
||||
def setDataCacheInfo(self, backup: HABackup):
|
||||
if backup.slug() not in self._data_cache.backups:
|
||||
# its a new backup, so we need to create a record for it
|
||||
pending = self._data_cache.backups.get(KEY_PENDING, {})
|
||||
pending_created = self.time.parse(pending.get(KEY_CREATED, self.time.now().isoformat()))
|
||||
|
||||
# If the backup has the same name as the one we created and it was created within a day
|
||||
# of the requested time, then assume the addon created it.
|
||||
self_created = backup.name() == pending.get(KEY_NAME, None) and abs((pending_created - backup.date()).total_seconds()) < timedelta(days=1).total_seconds()
|
||||
|
||||
stored_backup = self._data_cache.backup(backup.slug())
|
||||
stored_backup[KEY_I_MADE_THIS] = self_created
|
||||
stored_backup[KEY_CREATED] = backup.date().isoformat()
|
||||
stored_backup[KEY_NAME] = backup.name()
|
||||
stored_backup[KEY_NOTE] = backup.note()
|
||||
if self_created:
|
||||
# Remove the pending backup info from the cache so it doesn't get reused.
|
||||
del self._data_cache.backups[KEY_PENDING]
|
||||
# bump the last seen time
|
||||
self._data_cache.backup(backup.slug())[KEY_LAST_SEEN] = self.time.now().isoformat()
|
||||
self._data_cache.makeDirty()
|
||||
|
||||
async def delete(self, backup: Backup):
|
||||
slug = self._validateBackup(backup).slug()
|
||||
logger.info("Deleting '{0}' from Home Assistant".format(backup.name()))
|
||||
await self.harequests.delete(slug)
|
||||
backup.removeSource(self.name())
|
||||
|
||||
async def ignore(self, backup: Backup, ignore: bool):
|
||||
slug = self._validateBackup(backup).slug()
|
||||
logger.info("Updating ignore settings for '{0}'".format(backup.name()))
|
||||
self._data_cache.backup(slug)[KEY_IGNORE] = ignore
|
||||
self._data_cache.makeDirty()
|
||||
|
||||
async def note(self, backup, note: str) -> None:
|
||||
if isinstance(backup, HABackup):
|
||||
validated = backup
|
||||
else:
|
||||
validated = self._validateBackup(backup)
|
||||
logger.debug(f"Adding a note to ha backup '{validated.name()}'")
|
||||
if isinstance(validated, PendingBackup):
|
||||
# The ntoe will get set once the backup is created and we know the slug
|
||||
validated.setNote(note)
|
||||
else:
|
||||
self._data_cache.backup(validated.slug())[KEY_NOTE] = note
|
||||
self._data_cache.makeDirty()
|
||||
validated.setNote(note)
|
||||
return await super().note(backup, note)
|
||||
|
||||
async def save(self, backup: Backup, source: AsyncHttpGetter) -> HABackup:
|
||||
logger.info("Downloading '{0}'".format(backup.name()))
|
||||
self._info.upload(0)
|
||||
resp = None
|
||||
try:
|
||||
backup.overrideStatus("Loading {0}%", source)
|
||||
backup.setUploadSource(self.title(), source)
|
||||
async with source:
|
||||
with aiohttp.MultipartWriter('mixed') as mpwriter:
|
||||
mpwriter.append(source, {'CONTENT-TYPE': 'application/tar'})
|
||||
resp = await self.harequests.upload(mpwriter)
|
||||
backup.clearStatus()
|
||||
backup.clearUploadSource()
|
||||
except Exception as e:
|
||||
logger.printException(e)
|
||||
backup.overrideStatus("Failed!")
|
||||
backup.uploadFailure(logger.formatException(e))
|
||||
if resp and 'slug' in resp and resp['slug'] == backup.slug():
|
||||
self.config.setRetained(backup.slug(), True)
|
||||
return await self.harequests.backup(backup.slug())
|
||||
else:
|
||||
raise UploadFailed()
|
||||
|
||||
async def read(self, backup: Backup) -> IOBase:
|
||||
item = self._validateBackup(backup)
|
||||
return await self.harequests.download(item.slug())
|
||||
|
||||
async def retain(self, backup: Backup, retain: bool) -> None:
|
||||
item: HABackup = self._validateBackup(backup)
|
||||
item._retained = retain
|
||||
self.config.setRetained(backup.slug(), retain)
|
||||
|
||||
async def init(self):
|
||||
await self._refreshInfo()
|
||||
self._initialized = True
|
||||
|
||||
async def refresh(self):
|
||||
await self._refreshInfo()
|
||||
|
||||
async def _refreshInfo(self) -> None:
|
||||
try:
|
||||
self.self_info = await self.harequests.selfInfo()
|
||||
self.host_info = await self.harequests.info()
|
||||
self.ha_info = await self.harequests.haInfo()
|
||||
self.super_info = await self.harequests.supervisorInfo()
|
||||
addon_info = ensureKey("addons", await self.harequests.getAddons(), "Supervisor Metadata")
|
||||
self.config.update(
|
||||
ensureKey("options", self.self_info, "addon metdata"))
|
||||
if self.config.mustSaveUpgradeChanges():
|
||||
LOGGER.info("The configuration format has changed in this version of the addon and your configuration will be automatically updated")
|
||||
options = {}
|
||||
for option in self.config.getAllConfig().keys():
|
||||
options[option.value] = self.config.get(option)
|
||||
await self.harequests.updateConfig(options)
|
||||
self.config.persistedChanges()
|
||||
|
||||
self._info.ha_port = ensureKey(
|
||||
"port", self.ha_info, "Home Assistant metadata")
|
||||
self._info.ha_ssl = ensureKey(
|
||||
"ssl", self.ha_info, "Home Assistant metadata")
|
||||
self._info.addons = addon_info
|
||||
self._info.slug = ensureKey(
|
||||
"slug", self.self_info, "addon metdata")
|
||||
self._info.url = self.getAddonUrl()
|
||||
|
||||
self._addons = {}
|
||||
for addon in addon_info:
|
||||
self._addons[addon.get('slug', "default")] = addon
|
||||
|
||||
self._info.addDebugInfo("self_info", self.self_info)
|
||||
self._info.addDebugInfo("host_info", self.host_info)
|
||||
self._info.addDebugInfo("ha_info", self.ha_info)
|
||||
self._info.addDebugInfo("super_info", self.super_info)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to connect to supervisor")
|
||||
logger.debug(logger.formatException(e))
|
||||
raise e
|
||||
|
||||
def addonHasLogo(self, slug):
|
||||
return self._addons.get(slug, {}).get('logo', False)
|
||||
|
||||
def getAddonUrl(self):
|
||||
"""
|
||||
Returns the relative path to the add-on, for the purpose of linking to the add-on page from within Home Assistant.
|
||||
"""
|
||||
if self._info.slug is None:
|
||||
return ""
|
||||
return "/hassio/ingress/" + str(self._info.slug)
|
||||
|
||||
def getHostInfo(self):
|
||||
if not self.isInitialized():
|
||||
return {}
|
||||
return self.host_info
|
||||
|
||||
def getFullAddonUrl(self):
|
||||
if not self.isInitialized():
|
||||
return ""
|
||||
return self._haUrl() + "hassio/ingress/" + str(self._info.slug)
|
||||
|
||||
def getHomeAssistantUrl(self):
|
||||
if not self.isInitialized():
|
||||
return ""
|
||||
return self._haUrl()
|
||||
|
||||
def _haUrl(self):
|
||||
if self._info.ha_ssl:
|
||||
protocol = "https://"
|
||||
else:
|
||||
protocol = "http://"
|
||||
return "".join([protocol, "{host}:", str(self._info.ha_port), "/"])
|
||||
|
||||
def _validateBackup(self, backup) -> HABackup:
|
||||
item: HABackup = backup.getSource(self.name())
|
||||
if not item:
|
||||
raise LogicError(
|
||||
"Requested to do something with a backup from Home Assistant, but the backup has no Home Assistant source")
|
||||
return item
|
||||
|
||||
def _killPending(self):
|
||||
self.pending_backup = None
|
||||
if self._pending_backup_task and not self._pending_backup_task.done():
|
||||
self._pending_backup_task.cancel()
|
||||
|
||||
def postSync(self):
|
||||
self.stopper.allowRun()
|
||||
self.stopper.isBackingUp(self.pending_backup is not None)
|
||||
|
||||
async def _requestAsync(self, pending: PendingBackup, start=[]) -> None:
|
||||
try:
|
||||
result = await asyncio.wait_for(self.harequests.createBackup(pending._request_info), timeout=self.config.get(Setting.PENDING_BACKUP_TIMEOUT_SECONDS))
|
||||
slug = ensureKey(
|
||||
"slug", result, "supervisor's create backup response")
|
||||
pending.complete(slug)
|
||||
self.config.setRetained(
|
||||
slug, pending.getOptions().retain_sources.get(self.name(), False))
|
||||
logger.info("Backup finished")
|
||||
except Exception as e:
|
||||
if self._isHttp400(e):
|
||||
logger.warning("A backup was already in progress")
|
||||
pending.setPendingUnknown()
|
||||
else:
|
||||
logger.error("Backup failed:")
|
||||
logger.printException(e)
|
||||
pending.failed(e, self.time.now())
|
||||
finally:
|
||||
await self.stopper.startAddons()
|
||||
self.trigger()
|
||||
|
||||
def _buildBackupInfo(self, options: CreateOptions):
|
||||
addons: List[str] = []
|
||||
for addon in self.super_info.get('addons', {}):
|
||||
addons.append(addon['slug'])
|
||||
request_info = {
|
||||
'addons': [],
|
||||
'folders': []
|
||||
}
|
||||
folders = list(map(lambda f: f['slug'], FOLDERS))
|
||||
type_name = "Full"
|
||||
for folder in folders:
|
||||
if folder not in self.config.get(Setting.EXCLUDE_FOLDERS):
|
||||
request_info['folders'].append(folder)
|
||||
else:
|
||||
type_name = "Partial"
|
||||
for addon in addons:
|
||||
if addon not in self.config.get(Setting.EXCLUDE_ADDONS):
|
||||
request_info['addons'].append(addon)
|
||||
else:
|
||||
type_name = "Partial"
|
||||
if type_name == "Full":
|
||||
del request_info['addons']
|
||||
del request_info['folders']
|
||||
protected = False
|
||||
password = Password(self.config).resolve()
|
||||
if password:
|
||||
request_info['password'] = password
|
||||
name = BackupName().resolve(type_name, options.name_template,
|
||||
self.time.toLocal(options.when), self.host_info)
|
||||
request_info['name'] = name
|
||||
return request_info, type_name, protected
|
||||
@@ -0,0 +1,189 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..model import Coordinator, Backup
|
||||
from ..config import Config, Setting
|
||||
from ..util import GlobalInfo, Backoff, Estimator
|
||||
from .harequests import HaRequests
|
||||
from ..time import Time
|
||||
from ..worker import Worker
|
||||
from ..const import SOURCE_HA, SOURCE_GOOGLE_DRIVE
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
NOTIFICATION_TITLE = "Home Assistant Google Drive Backup is Having Trouble"
|
||||
NOTIFICATION_DESC_LINK = "The add-on is having trouble making backups and needs attention. Please visit the add-on [status page]({0}) for details."
|
||||
NOTIFICATION_DESC_STATIC = "The add-on is having trouble making backups and needs attention. Please visit the add-on status page for details."
|
||||
|
||||
MAX_BACKOFF = 60 * 5 # 5 minutes
|
||||
FIRST_BACKOFF = 60 # 1 minute
|
||||
|
||||
# Wait 5 minutes before logging
|
||||
NOTIFY_DELAY = 60 * 5 # 5 minute
|
||||
|
||||
OLD_BACKUP_ENTITY_NAME = "sensor.snapshot_backup"
|
||||
BACKUP_ENTITY_NAME = "sensor.backup_state"
|
||||
|
||||
REASSURING_MESSAGE = "Unable to reach Home Assistant (HTTP {0}). This is normal if Home Assistant is restarting. You will probably see some errors in the supervisor logs until it comes back online."
|
||||
|
||||
|
||||
@singleton
|
||||
class HaUpdater(Worker):
|
||||
@inject
|
||||
def __init__(self, requests: HaRequests, coordinator: Coordinator, config: Config, time: Time, global_info: GlobalInfo):
|
||||
self._config = config
|
||||
super().__init__("Sensor Updater", self.update, time, self.getInterval)
|
||||
self._time = time
|
||||
self._coordinator = coordinator
|
||||
self._requests: HaRequests = requests
|
||||
self._info = global_info
|
||||
self._notified = False
|
||||
self._backoff = Backoff(max=MAX_BACKOFF, base=FIRST_BACKOFF)
|
||||
self._first_error = None
|
||||
self._trigger_once = False
|
||||
|
||||
self._last_backup_update = None
|
||||
self.last_backup_update_time = time.now() - timedelta(days=1)
|
||||
self._config.subscribe(self.config_updated)
|
||||
self._last_interval = self.getInterval()
|
||||
|
||||
def config_updated(self):
|
||||
if self._last_interval != self.getInterval():
|
||||
self._wait_event.set()
|
||||
self._last_interval = self.getInterval()
|
||||
|
||||
def getInterval(self):
|
||||
return self._config.get(Setting.HA_REPORTING_INTERVAL_SECONDS)
|
||||
|
||||
async def update(self):
|
||||
try:
|
||||
if self._config.get(Setting.ENABLE_BACKUP_STALE_SENSOR):
|
||||
await self._requests.updateBackupStaleSensor('on' if self._stale() else 'off')
|
||||
if self._config.get(Setting.ENABLE_BACKUP_STATE_SENSOR):
|
||||
await self._maybeSendBackupUpdate()
|
||||
if self._config.get(Setting.NOTIFY_FOR_STALE_BACKUPS):
|
||||
if self._stale() and not self._notified:
|
||||
if self._info.url is None or len(self._info.url) == 0:
|
||||
message = NOTIFICATION_DESC_STATIC
|
||||
else:
|
||||
message = NOTIFICATION_DESC_LINK.format(self._info.url)
|
||||
await self._requests.sendNotification(NOTIFICATION_TITLE, message)
|
||||
self._notified = True
|
||||
elif not self._stale() and self._notified:
|
||||
await self._requests.dismissNotification()
|
||||
self._notified = False
|
||||
self._backoff.reset()
|
||||
self._first_error = None
|
||||
self._trigger_once = False
|
||||
except ClientResponseError as e:
|
||||
if self._first_error is None:
|
||||
self._first_error = self._time.now()
|
||||
if int(e.status / 100) == 5:
|
||||
if self._time.now() > self._first_error + timedelta(seconds=NOTIFY_DELAY):
|
||||
logger.error(
|
||||
"Unable to reach Home Assistant (HTTP {0}). This is normal if Home Assistant is restarting. You will probably see some errors in the supervisor logs until it comes back online.".format(e.status))
|
||||
else:
|
||||
logger.error("Trouble updating Home Assistant sensors.")
|
||||
self._last_backup_update = None
|
||||
await self._time.sleepAsync(self._backoff.backoff(e))
|
||||
except Exception as e:
|
||||
self._last_backup_update = None
|
||||
logger.error("Trouble updating Home Assistant sensors.")
|
||||
logger.printException(e)
|
||||
await self._time.sleepAsync(self._backoff.backoff(e))
|
||||
|
||||
async def _maybeSendBackupUpdate(self):
|
||||
update = self._buildBackupUpdate()
|
||||
if self._trigger_once or update != self._last_backup_update or self._time.now() > self.last_backup_update_time + timedelta(hours=1):
|
||||
if self._config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
await self._requests.updateEntity(OLD_BACKUP_ENTITY_NAME, update)
|
||||
else:
|
||||
await self._requests.updateEntity(BACKUP_ENTITY_NAME, update)
|
||||
self._last_backup_update = update
|
||||
self.last_backup_update_time = self._time.now()
|
||||
|
||||
def _stale(self):
|
||||
if self._info._first_sync:
|
||||
return False
|
||||
if self._info._last_error:
|
||||
return self._time.now() > self._info._last_success + timedelta(seconds=self._config.get(Setting.BACKUP_STALE_SECONDS))
|
||||
else:
|
||||
next_backup = self._coordinator.nextBackupTime(include_pending=False)
|
||||
if not next_backup:
|
||||
# no backups are configured
|
||||
return False
|
||||
|
||||
# Determine if a lot of time has passed since the last backup "should" have been made.
|
||||
warn_after = next_backup + timedelta(seconds=self._config.get(Setting.LONG_TERM_STALE_BACKUP_SECONDS))
|
||||
return self._time.now() >= warn_after
|
||||
|
||||
def _state(self):
|
||||
if self._stale():
|
||||
return "error"
|
||||
else:
|
||||
return "waiting" if self._info._first_sync else "backed_up"
|
||||
|
||||
def triggerRefresh(self):
|
||||
self._trigger_once = True
|
||||
|
||||
def _buildBackupUpdate(self):
|
||||
backups = list(filter(lambda s: not s.ignore(), self._coordinator.backups()))
|
||||
last = "Never"
|
||||
if len(backups) > 0:
|
||||
last = max(backups, key=lambda s: s.date()).date().isoformat()
|
||||
|
||||
def makeBackupData(backup: Backup):
|
||||
return {
|
||||
"name": backup.name(),
|
||||
"date": str(backup.date().isoformat()),
|
||||
"state": backup.status(),
|
||||
"size": backup.sizeString(),
|
||||
"slug": backup.slug()
|
||||
}
|
||||
ha_backups = list(filter(lambda s: s.getSource(SOURCE_HA) is not None, backups))
|
||||
drive_backups = list(filter(lambda s: s.getSource(SOURCE_GOOGLE_DRIVE) is not None, backups))
|
||||
|
||||
last_uploaded = "Never"
|
||||
if len(drive_backups) > 0:
|
||||
last_uploaded = max(drive_backups, key=lambda s: s.date()).date().isoformat()
|
||||
if self._config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
return {
|
||||
"state": self._state(),
|
||||
"attributes": {
|
||||
"friendly_name": "Snapshot State",
|
||||
"last_snapshot": last, # type: ignore
|
||||
"snapshots_in_google_drive": len(drive_backups),
|
||||
"snapshots_in_hassio": len(ha_backups),
|
||||
"snapshots_in_home_assistant": len(ha_backups),
|
||||
"size_in_google_drive": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), drive_backups))),
|
||||
"size_in_home_assistant": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), ha_backups))),
|
||||
"snapshots": list(map(makeBackupData, backups))
|
||||
}
|
||||
}
|
||||
else:
|
||||
source_metrics = self._coordinator.buildBackupMetrics()
|
||||
next = self._coordinator.nextBackupTime()
|
||||
if next is not None:
|
||||
next = next.isoformat()
|
||||
attr = {
|
||||
"friendly_name": "Backup State",
|
||||
"last_backup": last, # type: ignore
|
||||
"next_backup": next,
|
||||
"last_uploaded": last_uploaded,
|
||||
"backups_in_google_drive": len(drive_backups),
|
||||
"backups_in_home_assistant": len(ha_backups),
|
||||
"size_in_google_drive": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), drive_backups))),
|
||||
"size_in_home_assistant": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), ha_backups))),
|
||||
"backups": list(map(makeBackupData, backups))
|
||||
}
|
||||
if SOURCE_GOOGLE_DRIVE in source_metrics and 'free_space' in source_metrics[SOURCE_GOOGLE_DRIVE]:
|
||||
attr["free_space_in_google_drive"] = source_metrics[SOURCE_GOOGLE_DRIVE]['free_space']
|
||||
else:
|
||||
attr["free_space_in_google_drive"] = ""
|
||||
return {
|
||||
"state": self._state(),
|
||||
"attributes": attr
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from ..config import Config, Setting
|
||||
from ..exceptions import BackupPasswordKeyInvalid
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Password():
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
|
||||
def resolve(self, password=None):
|
||||
if password is None:
|
||||
password = self.config.get(Setting.BACKUP_PASSWORD)
|
||||
if len(password) == 0:
|
||||
return None
|
||||
if password.startswith("!secret "):
|
||||
if not os.path.isfile(self.config.get(Setting.SECRETS_FILE_PATH)):
|
||||
raise BackupPasswordKeyInvalid()
|
||||
with open(self.config.get(Setting.SECRETS_FILE_PATH)) as f:
|
||||
secrets_yaml = yaml.load(f, Loader=yaml.SafeLoader)
|
||||
key = password[len("!secret "):]
|
||||
if key not in secrets_yaml:
|
||||
raise BackupPasswordKeyInvalid()
|
||||
return str(secrets_yaml[key])
|
||||
else:
|
||||
return password
|
||||
Reference in New Issue
Block a user