From 2411b4287da191f5e11a979df3952322f05c35df Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 20 Aug 2020 11:22:04 +0200 Subject: [PATCH] Group all homeassistant/core into a module (#1950) * Group all homeassistant/core into a module * fix references * fix lint * streamline object property protection * Fix api --- supervisor/addons/addon.py | 2 +- supervisor/api/addons.py | 2 +- supervisor/api/homeassistant.py | 16 +- supervisor/api/proxy.py | 15 +- supervisor/api/supervisor.py | 4 +- supervisor/auth.py | 6 +- supervisor/bootstrap.py | 2 - supervisor/core.py | 13 +- supervisor/coresys.py | 21 - supervisor/discovery/__init__.py | 4 +- supervisor/homeassistant/__init__.py | 241 +++++++++++ supervisor/homeassistant/api.py | 122 ++++++ .../core.py} | 397 +++--------------- supervisor/{misc => homeassistant}/secrets.py | 2 +- supervisor/ingress.py | 4 +- supervisor/misc/tasks.py | 16 +- supervisor/snapshots/__init__.py | 14 +- 17 files changed, 467 insertions(+), 414 deletions(-) create mode 100644 supervisor/homeassistant/__init__.py create mode 100644 supervisor/homeassistant/api.py rename supervisor/{homeassistant.py => homeassistant/core.py} (50%) rename supervisor/{misc => homeassistant}/secrets.py (97%) diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 00b2d9690..f97748661 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -359,7 +359,7 @@ class Addon(AddonModel): options = self.options # Update secrets for validation - await self.sys_secrets.reload() + await self.sys_homeassistant.secrets.reload() try: options = schema(options) diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 6fcf07920..a95adcc9a 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -282,7 +282,7 @@ class APIAddons(CoreSysAttributes): addon = self._extract_addon_installed(request) # Update secrets for validation - await self.sys_secrets.reload() + await self.sys_homeassistant.secrets.reload() # Extend schema with add-on specific validation addon_schema = SCHEMA_OPTIONS.extend( diff --git a/supervisor/api/homeassistant.py b/supervisor/api/homeassistant.py index a300f4c9f..d688b2f4c 100644 --- a/supervisor/api/homeassistant.py +++ b/supervisor/api/homeassistant.py @@ -117,7 +117,7 @@ class APIHomeAssistant(CoreSysAttributes): @api_process async def stats(self, request: web.Request) -> Dict[Any, str]: """Return resource information.""" - stats = await self.sys_homeassistant.stats() + stats = await self.sys_homeassistant.core.stats() if not stats: raise APIError("No stats available") @@ -138,36 +138,36 @@ class APIHomeAssistant(CoreSysAttributes): body = await api_validate(SCHEMA_VERSION, request) version = body.get(ATTR_VERSION, self.sys_homeassistant.latest_version) - await asyncio.shield(self.sys_homeassistant.update(version)) + await asyncio.shield(self.sys_homeassistant.core.update(version)) @api_process def stop(self, request: web.Request) -> Awaitable[None]: """Stop Home Assistant.""" - return asyncio.shield(self.sys_homeassistant.stop()) + return asyncio.shield(self.sys_homeassistant.core.stop()) @api_process def start(self, request: web.Request) -> Awaitable[None]: """Start Home Assistant.""" - return asyncio.shield(self.sys_homeassistant.start()) + return asyncio.shield(self.sys_homeassistant.core.start()) @api_process def restart(self, request: web.Request) -> Awaitable[None]: """Restart Home Assistant.""" - return asyncio.shield(self.sys_homeassistant.restart()) + return asyncio.shield(self.sys_homeassistant.core.restart()) @api_process def rebuild(self, request: web.Request) -> Awaitable[None]: """Rebuild Home Assistant.""" - return asyncio.shield(self.sys_homeassistant.rebuild()) + return asyncio.shield(self.sys_homeassistant.core.rebuild()) @api_process_raw(CONTENT_TYPE_BINARY) def logs(self, request: web.Request) -> Awaitable[bytes]: """Return Home Assistant Docker logs.""" - return self.sys_homeassistant.logs() + return self.sys_homeassistant.core.logs() @api_process async def check(self, request: web.Request) -> None: """Check configuration of Home Assistant.""" - result = await self.sys_homeassistant.check_config() + result = await self.sys_homeassistant.core.check_config() if not result.valid: raise APIError(result.log) diff --git a/supervisor/api/proxy.py b/supervisor/api/proxy.py index b61e65bb9..80ee712ed 100644 --- a/supervisor/api/proxy.py +++ b/supervisor/api/proxy.py @@ -45,7 +45,7 @@ class APIProxy(CoreSysAttributes): async def _api_client(self, request: web.Request, path: str, timeout: int = 300): """Return a client request with proxy origin for Home Assistant.""" try: - async with self.sys_homeassistant.make_request( + async with self.sys_homeassistant.api.make_request( request.method.lower(), f"api/{path}", headers={ @@ -75,7 +75,7 @@ class APIProxy(CoreSysAttributes): async def stream(self, request: web.Request): """Proxy HomeAssistant EventStream Requests.""" self._check_access(request) - if not await self.sys_homeassistant.check_api_state(): + if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway() _LOGGER.info("Home Assistant EventStream start") @@ -96,7 +96,7 @@ class APIProxy(CoreSysAttributes): async def api(self, request: web.Request): """Proxy Home Assistant API Requests.""" self._check_access(request) - if not await self.sys_homeassistant.check_api_state(): + if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway() # Normal request @@ -130,7 +130,10 @@ class APIProxy(CoreSysAttributes): # Auth session await self.sys_homeassistant.ensure_access_token() await client.send_json( - {"type": "auth", "access_token": self.sys_homeassistant.access_token} + { + "type": "auth", + "access_token": self.sys_homeassistant.api.access_token, + } ) data = await client.receive_json() @@ -143,7 +146,7 @@ class APIProxy(CoreSysAttributes): data.get("type") == "invalid_auth" and self.sys_homeassistant.refresh_token ): - self.sys_homeassistant.access_token = None + self.sys_homeassistant.api.access_token = None return await self._websocket_client() raise HomeAssistantAuthError() @@ -157,7 +160,7 @@ class APIProxy(CoreSysAttributes): async def websocket(self, request: web.Request): """Initialize a WebSocket API connection.""" - if not await self.sys_homeassistant.check_api_state(): + if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway() _LOGGER.info("Home Assistant WebSocket API request initialize") diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index 067ef42de..6b81b2801 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -176,7 +176,9 @@ class APISupervisor(CoreSysAttributes): def reload(self, request: web.Request) -> Awaitable[None]: """Reload add-ons, configuration, etc.""" return asyncio.shield( - asyncio.wait([self.sys_updater.reload(), self.sys_secrets.reload()]) + asyncio.wait( + [self.sys_updater.reload(), self.sys_homeassistant.secrets.reload()] + ) ) @api_process diff --git a/supervisor/auth.py b/supervisor/auth.py index 06797a735..35ee49bb2 100644 --- a/supervisor/auth.py +++ b/supervisor/auth.py @@ -62,12 +62,12 @@ class Auth(JsonConfig, CoreSysAttributes): _LOGGER.info("Auth request from %s for %s", addon.slug, username) # Check API state - if not await self.sys_homeassistant.check_api_state(): + if not await self.sys_homeassistant.api.check_api_state(): _LOGGER.info("Home Assistant not running, check cache") return self._check_cache(username, password) try: - async with self.sys_homeassistant.make_request( + async with self.sys_homeassistant.api.make_request( "post", "api/hassio_auth", json={ @@ -93,7 +93,7 @@ class Auth(JsonConfig, CoreSysAttributes): async def change_password(self, username: str, password: str) -> None: """Change user password login.""" try: - async with self.sys_homeassistant.make_request( + async with self.sys_homeassistant.api.make_request( "post", "api/hassio_auth/password_reset", json={ATTR_USERNAME: username, ATTR_PASSWORD: password}, diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 1d634531e..c23cc2085 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -37,7 +37,6 @@ from .ingress import Ingress from .misc.filter import filter_data from .misc.hwmon import HwMonitor from .misc.scheduler import Scheduler -from .misc.secrets import SecretsManager from .misc.tasks import Tasks from .plugins import PluginManager from .services import ServiceManager @@ -74,7 +73,6 @@ async def initialize_coresys() -> CoreSys: coresys.discovery = Discovery(coresys) coresys.dbus = DBusManager(coresys) coresys.hassos = HassOS(coresys) - coresys.secrets = SecretsManager(coresys) coresys.scheduler = Scheduler(coresys) # diagnostics diff --git a/supervisor/core.py b/supervisor/core.py index db51bb2bf..f8b779474 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -122,9 +122,6 @@ class Core(CoreSysAttributes): # Load ingress await self.sys_ingress.load() - # Load secrets - await self.sys_secrets.load() - # Check supported OS if not self.sys_hassos.available: if self.sys_host.info.operating_system not in SUPERVISED_SUPPORTED_OS: @@ -204,10 +201,10 @@ class Core(CoreSysAttributes): # run HomeAssistant if ( self.sys_homeassistant.boot - and not await self.sys_homeassistant.is_running() + and not await self.sys_homeassistant.core.is_running() ): with suppress(HomeAssistantError): - await self.sys_homeassistant.start() + await self.sys_homeassistant.core.start() else: _LOGGER.info("Skip start of Home Assistant") @@ -223,7 +220,7 @@ class Core(CoreSysAttributes): # If landingpage / run upgrade in background if self.sys_homeassistant.version == "landingpage": - self.sys_create_task(self.sys_homeassistant.install()) + self.sys_create_task(self.sys_homeassistant.core.install()) # Start observe the host Hardware await self.sys_hwmonitor.load() @@ -278,7 +275,7 @@ class Core(CoreSysAttributes): # Close Home Assistant with suppress(HassioError): - await self.sys_homeassistant.stop() + await self.sys_homeassistant.core.stop() # Shutdown System Add-ons await self.sys_addons.shutdown(AddonStartup.SERVICES) @@ -304,7 +301,7 @@ class Core(CoreSysAttributes): # Restore core functionality await self.sys_addons.repair() - await self.sys_homeassistant.repair() + await self.sys_homeassistant.core.repair() # Tag version for latest await self.sys_supervisor.repair() diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 1500dcd89..55e36b187 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -23,7 +23,6 @@ if TYPE_CHECKING: from .hassos import HassOS from .misc.scheduler import Scheduler from .misc.hwmon import HwMonitor - from .misc.secrets import SecretsManager from .misc.tasks import Tasks from .homeassistant import HomeAssistant from .host import HostManager @@ -76,7 +75,6 @@ class CoreSys: self._dbus: Optional[DBusManager] = None self._hassos: Optional[HassOS] = None self._services: Optional[ServiceManager] = None - self._secrets: Optional[SecretsManager] = None self._scheduler: Optional[Scheduler] = None self._store: Optional[StoreManager] = None self._discovery: Optional[Discovery] = None @@ -246,20 +244,6 @@ class CoreSys: raise RuntimeError("Updater already set!") self._updater = value - @property - def secrets(self) -> SecretsManager: - """Return SecretsManager object.""" - if self._secrets is None: - raise RuntimeError("SecretsManager not set!") - return self._secrets - - @secrets.setter - def secrets(self, value: SecretsManager) -> None: - """Set a Updater object.""" - if self._secrets: - raise RuntimeError("SecretsManager already set!") - self._secrets = value - @property def addons(self) -> AddonManager: """Return AddonManager object.""" @@ -529,11 +513,6 @@ class CoreSysAttributes: """Return Updater object.""" return self.coresys.updater - @property - def sys_secrets(self) -> SecretsManager: - """Return SecretsManager object.""" - return self.coresys.secrets - @property def sys_addons(self) -> AddonManager: """Return AddonManager object.""" diff --git a/supervisor/discovery/__init__.py b/supervisor/discovery/__init__.py index cc2dca688..c56e96cfa 100644 --- a/supervisor/discovery/__init__.py +++ b/supervisor/discovery/__init__.py @@ -117,7 +117,7 @@ class Discovery(CoreSysAttributes, JsonConfig): async def _push_discovery(self, message: Message, command: str) -> None: """Send a discovery request.""" - if not await self.sys_homeassistant.check_api_state(): + if not await self.sys_homeassistant.api.check_api_state(): _LOGGER.info("Discovery %s message ignore", message.uuid) return @@ -125,7 +125,7 @@ class Discovery(CoreSysAttributes, JsonConfig): data.pop(ATTR_CONFIG) with suppress(HomeAssistantAPIError): - async with self.sys_homeassistant.make_request( + async with self.sys_homeassistant.api.make_request( command, f"api/hassio_push/discovery/{message.uuid}", json=data, diff --git a/supervisor/homeassistant/__init__.py b/supervisor/homeassistant/__init__.py new file mode 100644 index 000000000..574ea5984 --- /dev/null +++ b/supervisor/homeassistant/__init__.py @@ -0,0 +1,241 @@ +"""Home Assistant control object.""" +import asyncio +from ipaddress import IPv4Address +import logging +from pathlib import Path +import shutil +from typing import Optional +from uuid import UUID + +from ..const import ( + ATTR_ACCESS_TOKEN, + ATTR_AUDIO_INPUT, + ATTR_AUDIO_OUTPUT, + ATTR_BOOT, + ATTR_IMAGE, + ATTR_PORT, + ATTR_REFRESH_TOKEN, + ATTR_SSL, + ATTR_UUID, + ATTR_VERSION, + ATTR_WAIT_BOOT, + ATTR_WATCHDOG, + FILE_HASSIO_HOMEASSISTANT, +) +from ..coresys import CoreSys, CoreSysAttributes +from ..utils.json import JsonConfig +from ..validate import SCHEMA_HASS_CONFIG +from .api import HomeAssistantAPI +from .core import HomeAssistantCore +from .secrets import HomeAssistantSecrets + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class HomeAssistant(JsonConfig, CoreSysAttributes): + """Home Assistant core object for handle it.""" + + def __init__(self, coresys: CoreSys): + """Initialize Home Assistant object.""" + super().__init__(FILE_HASSIO_HOMEASSISTANT, SCHEMA_HASS_CONFIG) + self.coresys: CoreSys = coresys + self._api: HomeAssistantAPI = HomeAssistantAPI(coresys) + self._core: HomeAssistantCore = HomeAssistantCore(coresys) + self._secrets: HomeAssistantSecrets = HomeAssistantSecrets(coresys) + + @property + def api(self) -> HomeAssistantAPI: + """Return API handler for core.""" + return self._api + + @property + def core(self) -> HomeAssistantCore: + """Return Core handler for docker.""" + return self._core + + @property + def secrets(self) -> HomeAssistantSecrets: + """Return Secrets Manager for core.""" + return self._secrets + + @property + def machine(self) -> str: + """Return the system machines.""" + return self.core.instance.machine + + @property + def arch(self) -> str: + """Return arch of running Home Assistant.""" + return self.core.instance.arch + + @property + def error_state(self) -> bool: + """Return True if system is in error.""" + return self.core.error_state + + @property + def ip_address(self) -> IPv4Address: + """Return IP of Home Assistant instance.""" + return self.core.instance.ip_address + + @property + def api_port(self) -> int: + """Return network port to Home Assistant instance.""" + return self._data[ATTR_PORT] + + @api_port.setter + def api_port(self, value: int) -> None: + """Set network port for Home Assistant instance.""" + self._data[ATTR_PORT] = value + + @property + def api_ssl(self) -> bool: + """Return if we need ssl to Home Assistant instance.""" + return self._data[ATTR_SSL] + + @api_ssl.setter + def api_ssl(self, value: bool): + """Set SSL for Home Assistant instance.""" + self._data[ATTR_SSL] = value + + @property + def api_url(self) -> str: + """Return API url to Home Assistant.""" + return "{}://{}:{}".format( + "https" if self.api_ssl else "http", self.ip_address, self.api_port + ) + + @property + def watchdog(self) -> bool: + """Return True if the watchdog should protect Home Assistant.""" + return self._data[ATTR_WATCHDOG] + + @watchdog.setter + def watchdog(self, value: bool): + """Return True if the watchdog should protect Home Assistant.""" + self._data[ATTR_WATCHDOG] = value + + @property + def wait_boot(self) -> int: + """Return time to wait for Home Assistant startup.""" + return self._data[ATTR_WAIT_BOOT] + + @wait_boot.setter + def wait_boot(self, value: int): + """Set time to wait for Home Assistant startup.""" + self._data[ATTR_WAIT_BOOT] = value + + @property + def latest_version(self) -> str: + """Return last available version of Home Assistant.""" + return self.sys_updater.version_homeassistant + + @property + def image(self) -> str: + """Return image name of the Home Assistant container.""" + if self._data.get(ATTR_IMAGE): + return self._data[ATTR_IMAGE] + return f"homeassistant/{self.sys_machine}-homeassistant" + + @image.setter + def image(self, value: str) -> None: + """Set image name of Home Assistant container.""" + self._data[ATTR_IMAGE] = value + + @property + def version(self) -> Optional[str]: + """Return version of local version.""" + return self._data.get(ATTR_VERSION) + + @version.setter + def version(self, value: str) -> None: + """Set installed version.""" + self._data[ATTR_VERSION] = value + + @property + def boot(self) -> bool: + """Return True if Home Assistant boot is enabled.""" + return self._data[ATTR_BOOT] + + @boot.setter + def boot(self, value: bool): + """Set Home Assistant boot options.""" + self._data[ATTR_BOOT] = value + + @property + def uuid(self) -> UUID: + """Return a UUID of this Home Assistant instance.""" + return self._data[ATTR_UUID] + + @property + def supervisor_token(self) -> Optional[str]: + """Return an access token for the Supervisor API.""" + return self._data.get(ATTR_ACCESS_TOKEN) + + @supervisor_token.setter + def supervisor_token(self, value: str) -> None: + """Set the access token for the Supervisor API.""" + self._data[ATTR_ACCESS_TOKEN] = value + + @property + def refresh_token(self) -> Optional[str]: + """Return the refresh token to authenticate with Home Assistant.""" + return self._data.get(ATTR_REFRESH_TOKEN) + + @refresh_token.setter + def refresh_token(self, value: str): + """Set Home Assistant refresh_token.""" + self._data[ATTR_REFRESH_TOKEN] = value + + @property + def path_pulse(self): + """Return path to asound config.""" + return Path(self.sys_config.path_tmp, "homeassistant_pulse") + + @property + def path_extern_pulse(self): + """Return path to asound config for Docker.""" + return Path(self.sys_config.path_extern_tmp, "homeassistant_pulse") + + @property + def audio_output(self) -> Optional[str]: + """Return a pulse profile for output or None.""" + return self._data[ATTR_AUDIO_OUTPUT] + + @audio_output.setter + def audio_output(self, value: Optional[str]): + """Set audio output profile settings.""" + self._data[ATTR_AUDIO_OUTPUT] = value + + @property + def audio_input(self) -> Optional[str]: + """Return pulse profile for input or None.""" + return self._data[ATTR_AUDIO_INPUT] + + @audio_input.setter + def audio_input(self, value: Optional[str]): + """Set audio input settings.""" + self._data[ATTR_AUDIO_INPUT] = value + + async def load(self) -> None: + """Prepare Home Assistant object.""" + await asyncio.wait([self.secrets.load(), self.core.load()]) + + def write_pulse(self): + """Write asound config to file and return True on success.""" + pulse_config = self.sys_plugins.audio.pulse_client( + input_profile=self.audio_input, output_profile=self.audio_output + ) + + # Cleanup wrong maps + if self.path_pulse.is_dir(): + shutil.rmtree(self.path_pulse, ignore_errors=True) + + # Write pulse config + try: + with self.path_pulse.open("w") as config_file: + config_file.write(pulse_config) + except OSError as err: + _LOGGER.error("Home Assistant can't write pulse/client.config: %s", err) + else: + _LOGGER.info("Update pulse/client.config: %s", self.path_pulse) diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py new file mode 100644 index 000000000..3b227e96c --- /dev/null +++ b/supervisor/homeassistant/api.py @@ -0,0 +1,122 @@ +"""Home Assistant control object.""" +import asyncio +from contextlib import asynccontextmanager, suppress +from datetime import datetime, timedelta +import logging +from typing import Any, AsyncContextManager, Dict, Optional + +import aiohttp +from aiohttp import hdrs + +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import HomeAssistantAPIError, HomeAssistantAuthError +from ..utils import check_port + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class HomeAssistantAPI(CoreSysAttributes): + """Home Assistant core object for handle it.""" + + def __init__(self, coresys: CoreSys): + """Initialize Home Assistant object.""" + self.coresys: CoreSys = coresys + + # We don't persist access tokens. Instead we fetch new ones when needed + self.access_token: Optional[str] = None + self._access_token_expires: Optional[datetime] = None + + async def _ensure_access_token(self) -> None: + """Ensure there is an access token.""" + if ( + self.access_token is not None + and self._access_token_expires > datetime.utcnow() + ): + return + + with suppress(asyncio.TimeoutError, aiohttp.ClientError): + async with self.sys_websession_ssl.post( + f"{self.sys_homeassistant.api_url}/auth/token", + timeout=30, + data={ + "grant_type": "refresh_token", + "refresh_token": self.sys_homeassistant.refresh_token, + }, + ) as resp: + if resp.status != 200: + _LOGGER.error("Can't update Home Assistant access token!") + raise HomeAssistantAuthError() + + _LOGGER.info("Updated Home Assistant API token") + tokens = await resp.json() + self.access_token = tokens["access_token"] + self._access_token_expires = datetime.utcnow() + timedelta( + seconds=tokens["expires_in"] + ) + + @asynccontextmanager + async def make_request( + self, + method: str, + path: str, + json: Optional[Dict[str, Any]] = None, + content_type: Optional[str] = None, + data: Any = None, + timeout: int = 30, + params: Optional[Dict[str, str]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> AsyncContextManager[aiohttp.ClientResponse]: + """Async context manager to make a request with right auth.""" + url = f"{self.sys_homeassistant.api_url}/{path}" + headers = headers or {} + + # Passthrough content type + if content_type is not None: + headers[hdrs.CONTENT_TYPE] = content_type + + for _ in (1, 2): + await self._ensure_access_token() + headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}" + + try: + async with getattr(self.sys_websession_ssl, method)( + url, + data=data, + timeout=timeout, + json=json, + headers=headers, + params=params, + ) as resp: + # Access token expired + if resp.status == 401: + self.access_token = None + continue + yield resp + return + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Error on call %s: %s", url, err) + break + + raise HomeAssistantAPIError() + + async def check_api_state(self) -> bool: + """Return True if Home Assistant up and running.""" + # Check if port is up + if not await self.sys_run_in_executor( + check_port, + self.sys_homeassistant.ip_address, + self.sys_homeassistant.api_port, + ): + return False + + # Check if API is up + with suppress(HomeAssistantAPIError): + async with self.make_request("get", "api/config") as resp: + if resp.status in (200, 201): + data = await resp.json() + if data.get("state", "RUNNING") == "RUNNING": + return True + else: + _LOGGER.debug("Home Assistant API return: %d", resp.status) + + return False diff --git a/supervisor/homeassistant.py b/supervisor/homeassistant/core.py similarity index 50% rename from supervisor/homeassistant.py rename to supervisor/homeassistant/core.py index 6826f72f4..defc2b52f 100644 --- a/supervisor/homeassistant.py +++ b/supervisor/homeassistant/core.py @@ -1,50 +1,22 @@ """Home Assistant control object.""" import asyncio -from contextlib import asynccontextmanager, suppress -from datetime import datetime, timedelta -from ipaddress import IPv4Address +from contextlib import suppress import logging from pathlib import Path import re import secrets import shutil import time -from typing import Any, AsyncContextManager, Awaitable, Dict, Optional -from uuid import UUID +from typing import Awaitable, Optional -import aiohttp -from aiohttp import hdrs import attr from packaging import version as pkg_version -from .const import ( - ATTR_ACCESS_TOKEN, - ATTR_AUDIO_INPUT, - ATTR_AUDIO_OUTPUT, - ATTR_BOOT, - ATTR_IMAGE, - ATTR_PORT, - ATTR_REFRESH_TOKEN, - ATTR_SSL, - ATTR_UUID, - ATTR_VERSION, - ATTR_WAIT_BOOT, - ATTR_WATCHDOG, - FILE_HASSIO_HOMEASSISTANT, -) -from .coresys import CoreSys, CoreSysAttributes -from .docker.homeassistant import DockerHomeAssistant -from .docker.stats import DockerStats -from .exceptions import ( - DockerAPIError, - HomeAssistantAPIError, - HomeAssistantAuthError, - HomeAssistantError, - HomeAssistantUpdateError, -) -from .utils import check_port, convert_to_ascii, process_lock -from .utils.json import JsonConfig -from .validate import SCHEMA_HASS_CONFIG +from ..coresys import CoreSys, CoreSysAttributes +from ..docker.homeassistant import DockerHomeAssistant +from ..docker.stats import DockerStats +from ..exceptions import DockerAPIError, HomeAssistantError, HomeAssistantUpdateError +from ..utils import convert_to_ascii, process_lock _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -61,192 +33,40 @@ class ConfigResult: log = attr.ib() -class HomeAssistant(JsonConfig, CoreSysAttributes): +class HomeAssistantCore(CoreSysAttributes): """Home Assistant core object for handle it.""" def __init__(self, coresys: CoreSys): """Initialize Home Assistant object.""" - super().__init__(FILE_HASSIO_HOMEASSISTANT, SCHEMA_HASS_CONFIG) self.coresys: CoreSys = coresys self.instance: DockerHomeAssistant = DockerHomeAssistant(coresys) self.lock: asyncio.Lock = asyncio.Lock() self._error_state: bool = False - # We don't persist access tokens. Instead we fetch new ones when needed - self.access_token: Optional[str] = None - self._access_token_expires: Optional[datetime] = None - - async def load(self) -> None: - """Prepare Home Assistant object.""" - try: - # Evaluate Version if we lost this information - if not self.version: - self.version = await self.instance.get_latest_version( - key=pkg_version.parse - ) - - await self.instance.attach(tag=self.version) - except DockerAPIError: - _LOGGER.info("No Home Assistant Docker image %s found.", self.image) - await self.install_landingpage() - else: - self.version = self.instance.version - self.image = self.instance.image - self.save_data() - - @property - def machine(self) -> str: - """Return the system machines.""" - return self.instance.machine - - @property - def arch(self) -> str: - """Return arch of running Home Assistant.""" - return self.instance.arch - @property def error_state(self) -> bool: """Return True if system is in error.""" return self._error_state - @property - def ip_address(self) -> IPv4Address: - """Return IP of Home Assistant instance.""" - return self.instance.ip_address + async def load(self) -> None: + """Prepare Home Assistant object.""" + try: + # Evaluate Version if we lost this information + if not self.sys_homeassistant.version: + self.sys_homeassistant.version = await self.instance.get_latest_version( + key=pkg_version.parse + ) - @property - def api_port(self) -> int: - """Return network port to Home Assistant instance.""" - return self._data[ATTR_PORT] - - @api_port.setter - def api_port(self, value: int) -> None: - """Set network port for Home Assistant instance.""" - self._data[ATTR_PORT] = value - - @property - def api_ssl(self) -> bool: - """Return if we need ssl to Home Assistant instance.""" - return self._data[ATTR_SSL] - - @api_ssl.setter - def api_ssl(self, value: bool): - """Set SSL for Home Assistant instance.""" - self._data[ATTR_SSL] = value - - @property - def api_url(self) -> str: - """Return API url to Home Assistant.""" - return "{}://{}:{}".format( - "https" if self.api_ssl else "http", self.ip_address, self.api_port - ) - - @property - def watchdog(self) -> bool: - """Return True if the watchdog should protect Home Assistant.""" - return self._data[ATTR_WATCHDOG] - - @watchdog.setter - def watchdog(self, value: bool): - """Return True if the watchdog should protect Home Assistant.""" - self._data[ATTR_WATCHDOG] = value - - @property - def wait_boot(self) -> int: - """Return time to wait for Home Assistant startup.""" - return self._data[ATTR_WAIT_BOOT] - - @wait_boot.setter - def wait_boot(self, value: int): - """Set time to wait for Home Assistant startup.""" - self._data[ATTR_WAIT_BOOT] = value - - @property - def latest_version(self) -> str: - """Return last available version of Home Assistant.""" - return self.sys_updater.version_homeassistant - - @property - def image(self) -> str: - """Return image name of the Home Assistant container.""" - if self._data.get(ATTR_IMAGE): - return self._data[ATTR_IMAGE] - return f"homeassistant/{self.sys_machine}-homeassistant" - - @image.setter - def image(self, value: str) -> None: - """Set image name of Home Assistant container.""" - self._data[ATTR_IMAGE] = value - - @property - def version(self) -> Optional[str]: - """Return version of local version.""" - return self._data.get(ATTR_VERSION) - - @version.setter - def version(self, value: str) -> None: - """Set installed version.""" - self._data[ATTR_VERSION] = value - - @property - def boot(self) -> bool: - """Return True if Home Assistant boot is enabled.""" - return self._data[ATTR_BOOT] - - @boot.setter - def boot(self, value: bool): - """Set Home Assistant boot options.""" - self._data[ATTR_BOOT] = value - - @property - def uuid(self) -> UUID: - """Return a UUID of this Home Assistant instance.""" - return self._data[ATTR_UUID] - - @property - def supervisor_token(self) -> Optional[str]: - """Return an access token for the Supervisor API.""" - return self._data.get(ATTR_ACCESS_TOKEN) - - @property - def refresh_token(self) -> Optional[str]: - """Return the refresh token to authenticate with Home Assistant.""" - return self._data.get(ATTR_REFRESH_TOKEN) - - @refresh_token.setter - def refresh_token(self, value: str): - """Set Home Assistant refresh_token.""" - self._data[ATTR_REFRESH_TOKEN] = value - - @property - def path_pulse(self): - """Return path to asound config.""" - return Path(self.sys_config.path_tmp, "homeassistant_pulse") - - @property - def path_extern_pulse(self): - """Return path to asound config for Docker.""" - return Path(self.sys_config.path_extern_tmp, "homeassistant_pulse") - - @property - def audio_output(self) -> Optional[str]: - """Return a pulse profile for output or None.""" - return self._data[ATTR_AUDIO_OUTPUT] - - @audio_output.setter - def audio_output(self, value: Optional[str]): - """Set audio output profile settings.""" - self._data[ATTR_AUDIO_OUTPUT] = value - - @property - def audio_input(self) -> Optional[str]: - """Return pulse profile for input or None.""" - return self._data[ATTR_AUDIO_INPUT] - - @audio_input.setter - def audio_input(self, value: Optional[str]): - """Set audio input settings.""" - self._data[ATTR_AUDIO_INPUT] = value + await self.instance.attach(tag=self.sys_homeassistant.version) + except DockerAPIError: + _LOGGER.info( + "No Home Assistant Docker image %s found.", self.sys_homeassistant.image + ) + await self.install_landingpage() + else: + self.sys_homeassistant.version = self.instance.version + self.sys_homeassistant.image = self.instance.image + self.sys_homeassistant.save_data() @process_lock async def install_landingpage(self) -> None: @@ -271,9 +91,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): except Exception as err: # pylint: disable=broad-except self.sys_capture_exception(err) else: - self.version = self.instance.version - self.image = self.sys_updater.image_homeassistant - self.save_data() + self.sys_homeassistant.version = self.instance.version + self.sys_homeassistant.image = self.sys_updater.image_homeassistant + self.sys_homeassistant.save_data() break # Start landingpage @@ -287,10 +107,10 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): _LOGGER.info("Setup Home Assistant") while True: # read homeassistant tag and install it - if not self.latest_version: + if not self.sys_homeassistant.latest_version: await self.sys_updater.reload() - tag = self.latest_version + tag = self.sys_homeassistant.latest_version if tag: try: await self.instance.update( @@ -306,9 +126,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await asyncio.sleep(30) _LOGGER.info("Home Assistant docker now installed") - self.version = self.instance.version - self.image = self.sys_updater.image_homeassistant - self.save_data() + self.sys_homeassistant.version = self.instance.version + self.sys_homeassistant.image = self.sys_updater.image_homeassistant + self.sys_homeassistant.save_data() # finishing try: @@ -324,9 +144,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): @process_lock async def update(self, version: Optional[str] = None) -> None: """Update HomeAssistant version.""" - version = version or self.latest_version - old_image = self.image - rollback = self.version if not self.error_state else None + version = version or self.sys_homeassistant.latest_version + old_image = self.sys_homeassistant.image + rollback = self.sys_homeassistant.version if not self.error_state else None running = await self.instance.is_running() exists = await self.instance.exists() @@ -346,15 +166,15 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): _LOGGER.warning("Update Home Assistant image fails") raise HomeAssistantUpdateError() else: - self.version = self.instance.version - self.image = self.sys_updater.image_homeassistant + self.sys_homeassistant.version = self.instance.version + self.sys_homeassistant.image = self.sys_updater.image_homeassistant if running: await self._start() _LOGGER.info("Successful run Home Assistant %s", to_version) # Successfull - last step - self.save_data() + self.sys_homeassistant.save_data() with suppress(DockerAPIError): await self.instance.cleanup(old_image=old_image) @@ -384,18 +204,18 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): async def _start(self) -> None: """Start Home Assistant Docker & wait.""" # Create new API token - self._data[ATTR_ACCESS_TOKEN] = secrets.token_hex(56) - self.save_data() + self.sys_homeassistant.supervisor_token = secrets.token_hex(56) + self.sys_homeassistant.save_data() # Write audio settings - self.write_pulse() + self.sys_homeassistant.write_pulse() try: await self.instance.run() except DockerAPIError: raise HomeAssistantError() - await self._block_till_run(self.version) + await self._block_till_run(self.sys_homeassistant.version) @process_lock async def start(self) -> None: @@ -411,7 +231,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): except DockerAPIError: raise HomeAssistantError() - await self._block_till_run(self.version) + await self._block_till_run(self.sys_homeassistant.version) # No Instance/Container found, extended start else: await self._start() @@ -435,7 +255,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): except DockerAPIError: raise HomeAssistantError() - await self._block_till_run(self.version) + await self._block_till_run(self.sys_homeassistant.version) @process_lock async def rebuild(self) -> None: @@ -503,101 +323,6 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): _LOGGER.info("Home Assistant config is valid") return ConfigResult(True, log) - async def ensure_access_token(self) -> None: - """Ensure there is an access token.""" - if ( - self.access_token is not None - and self._access_token_expires > datetime.utcnow() - ): - return - - with suppress(asyncio.TimeoutError, aiohttp.ClientError): - async with self.sys_websession_ssl.post( - f"{self.api_url}/auth/token", - timeout=30, - data={ - "grant_type": "refresh_token", - "refresh_token": self.refresh_token, - }, - ) as resp: - if resp.status != 200: - _LOGGER.error("Can't update Home Assistant access token!") - raise HomeAssistantAuthError() - - _LOGGER.info("Updated Home Assistant API token") - tokens = await resp.json() - self.access_token = tokens["access_token"] - self._access_token_expires = datetime.utcnow() + timedelta( - seconds=tokens["expires_in"] - ) - - @asynccontextmanager - async def make_request( - self, - method: str, - path: str, - json: Optional[Dict[str, Any]] = None, - content_type: Optional[str] = None, - data: Any = None, - timeout: int = 30, - params: Optional[Dict[str, str]] = None, - headers: Optional[Dict[str, str]] = None, - ) -> AsyncContextManager[aiohttp.ClientResponse]: - """Async context manager to make a request with right auth.""" - url = f"{self.api_url}/{path}" - headers = headers or {} - - # Passthrough content type - if content_type is not None: - headers[hdrs.CONTENT_TYPE] = content_type - - for _ in (1, 2): - # Prepare Access token - if self.refresh_token: - await self.ensure_access_token() - headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}" - - try: - async with getattr(self.sys_websession_ssl, method)( - url, - data=data, - timeout=timeout, - json=json, - headers=headers, - params=params, - ) as resp: - # Access token expired - if resp.status == 401 and self.refresh_token: - self.access_token = None - continue - yield resp - return - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - _LOGGER.error("Error on call %s: %s", url, err) - break - - raise HomeAssistantAPIError() - - async def check_api_state(self) -> bool: - """Return True if Home Assistant up and running.""" - # Check if port is up - if not await self.sys_run_in_executor( - check_port, self.ip_address, self.api_port - ): - return False - - # Check if API is up - with suppress(HomeAssistantAPIError): - async with self.make_request("get", "api/config") as resp: - if resp.status in (200, 201): - data = await resp.json() - if data.get("state", "RUNNING") == "RUNNING": - return True - else: - _LOGGER.debug("Home Assistant API return: %d", resp.status) - - return False - async def _block_till_run(self, version: str) -> None: """Block until Home-Assistant is booting up or startup timeout.""" # Skip landingpage @@ -631,7 +356,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): break # 2: Check if API response - if await self.check_api_state(): + if await self.sys_homeassistant.api.check_api_state(): _LOGGER.info("Detect a running Home Assistant instance") self._error_state = False return @@ -659,7 +384,10 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): _LOGGER.info("Home Assistant pip installation done") # 5: Timeout - if timeout and time.monotonic() - start_time > self.wait_boot: + if ( + timeout + and time.monotonic() - start_time > self.sys_homeassistant.wait_boot + ): _LOGGER.warning("Don't wait anymore on Home Assistant startup!") break @@ -671,32 +399,13 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): if await self.instance.exists(): return - _LOGGER.info("Repair Home Assistant %s", self.version) + _LOGGER.info("Repair Home Assistant %s", self.sys_homeassistant.version) await self.sys_run_in_executor( self.sys_docker.network.stale_cleanup, self.instance.name ) # Pull image try: - await self.instance.install(self.version) + await self.instance.install(self.sys_homeassistant.version) except DockerAPIError: _LOGGER.error("Repairing of Home Assistant fails") - - def write_pulse(self): - """Write asound config to file and return True on success.""" - pulse_config = self.sys_plugins.audio.pulse_client( - input_profile=self.audio_input, output_profile=self.audio_output - ) - - # Cleanup wrong maps - if self.path_pulse.is_dir(): - shutil.rmtree(self.path_pulse, ignore_errors=True) - - # Write pulse config - try: - with self.path_pulse.open("w") as config_file: - config_file.write(pulse_config) - except OSError as err: - _LOGGER.error("Home Assistant can't write pulse/client.config: %s", err) - else: - _LOGGER.info("Update pulse/client.config: %s", self.path_pulse) diff --git a/supervisor/misc/secrets.py b/supervisor/homeassistant/secrets.py similarity index 97% rename from supervisor/misc/secrets.py rename to supervisor/homeassistant/secrets.py index b37953907..6673593ad 100644 --- a/supervisor/misc/secrets.py +++ b/supervisor/homeassistant/secrets.py @@ -12,7 +12,7 @@ from ..utils import AsyncThrottle _LOGGER: logging.Logger = logging.getLogger(__name__) -class SecretsManager(CoreSysAttributes): +class HomeAssistantSecrets(CoreSysAttributes): """Manage Home Assistant secrets.""" def __init__(self, coresys: CoreSys): diff --git a/supervisor/ingress.py b/supervisor/ingress.py index 0ace576c7..02c70ec10 100644 --- a/supervisor/ingress.py +++ b/supervisor/ingress.py @@ -156,13 +156,13 @@ class Ingress(JsonConfig, CoreSysAttributes): async def update_hass_panel(self, addon: Addon): """Return True if Home Assistant up and running.""" - if not await self.sys_homeassistant.is_running(): + if not await self.sys_homeassistant.core.is_running(): _LOGGER.debug("Ignore panel update on Core") return # Update UI method = "post" if addon.ingress_panel else "delete" - async with self.sys_homeassistant.make_request( + async with self.sys_homeassistant.api.make_request( method, f"api/hassio_push/panel/{addon.slug}" ) as resp: if resp.status in (200, 201): diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index e3610517c..574d1383f 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -169,7 +169,7 @@ class Tasks(CoreSysAttributes): """Check running state of Docker and start if they is close.""" # if Home Assistant is active if ( - not await self.sys_homeassistant.is_fails() + not await self.sys_homeassistant.core.is_fails() or not self.sys_homeassistant.watchdog or self.sys_homeassistant.error_state ): @@ -177,14 +177,14 @@ class Tasks(CoreSysAttributes): # if Home Assistant is running if ( - self.sys_homeassistant.in_progress - or await self.sys_homeassistant.is_running() + self.sys_homeassistant.core.in_progress + or await self.sys_homeassistant.core.is_running() ): return _LOGGER.warning("Watchdog found a problem with Home Assistant Docker!") try: - await self.sys_homeassistant.start() + await self.sys_homeassistant.core.start() except HomeAssistantError: _LOGGER.error("Watchdog Home Assistant reanimation fails!") @@ -196,7 +196,7 @@ class Tasks(CoreSysAttributes): """ # If Home-Assistant is active if ( - not await self.sys_homeassistant.is_fails() + not await self.sys_homeassistant.core.is_fails() or not self.sys_homeassistant.watchdog or self.sys_homeassistant.error_state ): @@ -207,8 +207,8 @@ class Tasks(CoreSysAttributes): # If Home-Assistant API is up if ( - self.sys_homeassistant.in_progress - or await self.sys_homeassistant.check_api_state() + self.sys_homeassistant.core.in_progress + or await self.sys_homeassistant.api.check_api_state() ): return @@ -221,7 +221,7 @@ class Tasks(CoreSysAttributes): _LOGGER.error("Watchdog found a problem with Home Assistant API!") try: - await self.sys_homeassistant.restart() + await self.sys_homeassistant.core.restart() except HomeAssistantError: _LOGGER.error("Watchdog Home Assistant reanimation fails!") finally: diff --git a/supervisor/snapshots/__init__.py b/supervisor/snapshots/__init__.py index 8b3fae3bb..942e2e9b9 100644 --- a/supervisor/snapshots/__init__.py +++ b/supervisor/snapshots/__init__.py @@ -231,7 +231,7 @@ class SnapshotManager(CoreSysAttributes): _LOGGER.info("Restore %s run Home-Assistant", snapshot.slug) snapshot.restore_homeassistant() task_hass = self.sys_create_task( - self.sys_homeassistant.update(snapshot.homeassistant_version) + self.sys_homeassistant.core.update(snapshot.homeassistant_version) ) # Restore repositories @@ -295,7 +295,7 @@ class SnapshotManager(CoreSysAttributes): async with snapshot: # Stop Home-Assistant for config restore if FOLDER_HOMEASSISTANT in folders: - await self.sys_homeassistant.stop() + await self.sys_homeassistant.core.stop() snapshot.restore_homeassistant() # Process folders @@ -308,7 +308,9 @@ class SnapshotManager(CoreSysAttributes): if homeassistant: _LOGGER.info("Restore %s run Home-Assistant", snapshot.slug) task_hass = self.sys_create_task( - self.sys_homeassistant.update(snapshot.homeassistant_version) + self.sys_homeassistant.core.update( + snapshot.homeassistant_version + ) ) if addons: @@ -324,13 +326,13 @@ class SnapshotManager(CoreSysAttributes): await task_hass # Do we need start HomeAssistant? - if not await self.sys_homeassistant.is_running(): + if not await self.sys_homeassistant.core.is_running(): await self.sys_homeassistant.start() # Check If we can access to API / otherwise restart - if not await self.sys_homeassistant.check_api_state(): + if not await self.sys_homeassistant.api.check_api_state(): _LOGGER.warning("Need restart HomeAssistant for API") - await self.sys_homeassistant.restart() + await self.sys_homeassistant.core.restart() except Exception: # pylint: disable=broad-except _LOGGER.exception("Restore %s error", snapshot.slug)