diff --git a/API.md b/API.md index a8905a943..e54496400 100644 --- a/API.md +++ b/API.md @@ -376,6 +376,7 @@ Output is the raw Docker log. - POST `/homeassistant/check` - POST `/homeassistant/start` - POST `/homeassistant/stop` +- POST `/homeassistant/rebuild` - POST `/homeassistant/options` diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index 8b02e7d0f..62b2567b8 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -21,7 +21,7 @@ from ..const import ( ATTR_UUID, ATTR_VERSION, ATTR_WEBUI, BOOT_AUTO, BOOT_MANUAL, PRIVILEGED_ALL, ROLE_ALL, ROLE_DEFAULT, STARTUP_ALL, STARTUP_APPLICATION, STARTUP_SERVICES, STATE_STARTED, STATE_STOPPED) -from ..services.validate import DISCOVERY_SERVICES +from ..discovery.validate import valid_discovery_service from ..validate import ( ALSA_DEVICE, DOCKER_PORTS, NETWORK_PORT, SHA256, UUID_MATCH) @@ -79,9 +79,9 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ vol.Required(ATTR_VERSION): vol.Coerce(str), vol.Required(ATTR_SLUG): vol.Coerce(str), vol.Required(ATTR_DESCRIPTON): vol.Coerce(str), - vol.Optional(ATTR_URL): vol.Url(), - vol.Optional(ATTR_ARCH, default=ARCH_ALL): [vol.In(ARCH_ALL)], + vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)], vol.Optional(ATTR_MACHINE): [vol.In(MACHINE_ALL)], + vol.Optional(ATTR_URL): vol.Url(), vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.In(STARTUP_ALL)), vol.Required(ATTR_BOOT): @@ -114,7 +114,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ vol.Optional(ATTR_DOCKER_API, default=False): vol.Boolean(), vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(), vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)], - vol.Optional(ATTR_DISCOVERY): [vol.In(DISCOVERY_SERVICES)], + vol.Optional(ATTR_DISCOVERY): [valid_discovery_service], vol.Required(ATTR_OPTIONS): dict, vol.Required(ATTR_SCHEMA): vol.Any(vol.Schema({ vol.Coerce(str): vol.Any(SCHEMA_ELEMENT, [ diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 4f60380e5..c105500e6 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -1,23 +1,24 @@ """Init file for Hass.io RESTful API.""" import logging from pathlib import Path +from typing import Optional from aiohttp import web +from ..coresys import CoreSys, CoreSysAttributes from .addons import APIAddons from .auth import APIAuth from .discovery import APIDiscovery -from .homeassistant import APIHomeAssistant from .hardware import APIHardware -from .host import APIHost from .hassos import APIHassOS +from .homeassistant import APIHomeAssistant +from .host import APIHost from .info import APIInfo from .proxy import APIProxy -from .supervisor import APISupervisor -from .snapshots import APISnapshots -from .services import APIServices from .security import SecurityMiddleware -from ..coresys import CoreSysAttributes +from .services import APIServices +from .snapshots import APISnapshots +from .supervisor import APISupervisor _LOGGER = logging.getLogger(__name__) @@ -25,18 +26,18 @@ _LOGGER = logging.getLogger(__name__) class RestAPI(CoreSysAttributes): """Handle RESTful API for Hass.io.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize Docker base wrapper.""" - self.coresys = coresys - self.security = SecurityMiddleware(coresys) - self.webapp = web.Application( + self.coresys: CoreSys = coresys + self.security: SecurityMiddleware = SecurityMiddleware(coresys) + self.webapp: web.Application = web.Application( middlewares=[self.security.token_validation]) # service stuff - self._runner = web.AppRunner(self.webapp) - self._site = None + self._runner: web.AppRunner = web.AppRunner(self.webapp) + self._site: Optional[web.TCPSite] = None - async def load(self): + async def load(self) -> None: """Register REST API Calls.""" self._register_supervisor() self._register_host() @@ -52,7 +53,7 @@ class RestAPI(CoreSysAttributes): self._register_info() self._register_auth() - def _register_host(self): + def _register_host(self) -> None: """Register hostcontrol functions.""" api_host = APIHost() api_host.coresys = self.coresys @@ -72,7 +73,7 @@ class RestAPI(CoreSysAttributes): api_host.service_reload), ]) - def _register_hassos(self): + def _register_hassos(self) -> None: """Register HassOS functions.""" api_hassos = APIHassOS() api_hassos.coresys = self.coresys @@ -84,7 +85,7 @@ class RestAPI(CoreSysAttributes): web.post('/hassos/config/sync', api_hassos.config_sync), ]) - def _register_hardware(self): + def _register_hardware(self) -> None: """Register hardware functions.""" api_hardware = APIHardware() api_hardware.coresys = self.coresys @@ -94,7 +95,7 @@ class RestAPI(CoreSysAttributes): web.get('/hardware/audio', api_hardware.audio), ]) - def _register_info(self): + def _register_info(self) -> None: """Register info functions.""" api_info = APIInfo() api_info.coresys = self.coresys @@ -103,7 +104,7 @@ class RestAPI(CoreSysAttributes): web.get('/info', api_info.info), ]) - def _register_auth(self): + def _register_auth(self) -> None: """Register auth functions.""" api_auth = APIAuth() api_auth.coresys = self.coresys @@ -112,7 +113,7 @@ class RestAPI(CoreSysAttributes): web.post('/auth', api_auth.auth), ]) - def _register_supervisor(self): + def _register_supervisor(self) -> None: """Register Supervisor functions.""" api_supervisor = APISupervisor() api_supervisor.coresys = self.coresys @@ -127,7 +128,7 @@ class RestAPI(CoreSysAttributes): web.post('/supervisor/options', api_supervisor.options), ]) - def _register_homeassistant(self): + def _register_homeassistant(self) -> None: """Register Home Assistant functions.""" api_hass = APIHomeAssistant() api_hass.coresys = self.coresys @@ -142,9 +143,10 @@ class RestAPI(CoreSysAttributes): web.post('/homeassistant/stop', api_hass.stop), web.post('/homeassistant/start', api_hass.start), web.post('/homeassistant/check', api_hass.check), + web.post('/homeassistant/rebuild', api_hass.rebuild), ]) - def _register_proxy(self): + def _register_proxy(self) -> None: """Register Home Assistant API Proxy.""" api_proxy = APIProxy() api_proxy.coresys = self.coresys @@ -158,7 +160,7 @@ class RestAPI(CoreSysAttributes): web.get('/homeassistant/api/', api_proxy.api), ]) - def _register_addons(self): + def _register_addons(self) -> None: """Register Add-on functions.""" api_addons = APIAddons() api_addons.coresys = self.coresys @@ -184,7 +186,7 @@ class RestAPI(CoreSysAttributes): web.get('/addons/{addon}/stats', api_addons.stats), ]) - def _register_snapshots(self): + def _register_snapshots(self) -> None: """Register snapshots functions.""" api_snapshots = APISnapshots() api_snapshots.coresys = self.coresys @@ -204,7 +206,7 @@ class RestAPI(CoreSysAttributes): web.get('/snapshots/{snapshot}/download', api_snapshots.download), ]) - def _register_services(self): + def _register_services(self) -> None: """Register services functions.""" api_services = APIServices() api_services.coresys = self.coresys @@ -216,7 +218,7 @@ class RestAPI(CoreSysAttributes): web.delete('/services/{service}', api_services.del_service), ]) - def _register_discovery(self): + def _register_discovery(self) -> None: """Register discovery functions.""" api_discovery = APIDiscovery() api_discovery.coresys = self.coresys @@ -228,7 +230,7 @@ class RestAPI(CoreSysAttributes): web.post('/discovery', api_discovery.set_discovery), ]) - def _register_panel(self): + def _register_panel(self) -> None: """Register panel for Home Assistant.""" panel_dir = Path(__file__).parent.joinpath("panel") @@ -256,7 +258,7 @@ class RestAPI(CoreSysAttributes): # This route is for HA > 0.70 self.webapp.add_routes([web.static('/app', panel_dir)]) - async def start(self): + async def start(self) -> None: """Run RESTful API webserver.""" await self._runner.setup() self._site = web.TCPSite( @@ -270,7 +272,7 @@ class RestAPI(CoreSysAttributes): else: _LOGGER.info("Start API on %s", self.sys_docker.network.supervisor) - async def stop(self): + async def stop(self) -> None: """Stop RESTful API webserver.""" if not self._site: return diff --git a/hassio/api/discovery.py b/hassio/api/discovery.py index b3ff6f99e..560f51fa7 100644 --- a/hassio/api/discovery.py +++ b/hassio/api/discovery.py @@ -3,17 +3,24 @@ import voluptuous as vol from .utils import api_process, api_validate from ..const import ( - ATTR_ADDON, ATTR_UUID, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_SERVICE, - REQUEST_FROM) + ATTR_ADDON, + ATTR_UUID, + ATTR_CONFIG, + ATTR_DISCOVERY, + ATTR_SERVICE, + REQUEST_FROM, +) from ..coresys import CoreSysAttributes from ..exceptions import APIError, APIForbidden -from ..validate import SERVICE_ALL +from ..discovery.validate import valid_discovery_service -SCHEMA_DISCOVERY = vol.Schema({ - vol.Required(ATTR_SERVICE): SERVICE_ALL, - vol.Optional(ATTR_CONFIG): vol.Maybe(dict), -}) +SCHEMA_DISCOVERY = vol.Schema( + { + vol.Required(ATTR_SERVICE): valid_discovery_service, + vol.Optional(ATTR_CONFIG): vol.Maybe(dict), + } +) class APIDiscovery(CoreSysAttributes): @@ -21,7 +28,7 @@ class APIDiscovery(CoreSysAttributes): def _extract_message(self, request): """Extract discovery message from URL.""" - message = self.sys_discovery.get(request.match_info.get('uuid')) + message = self.sys_discovery.get(request.match_info.get("uuid")) if not message: raise APIError("Discovery message not found") return message @@ -38,12 +45,14 @@ class APIDiscovery(CoreSysAttributes): discovery = [] for message in self.sys_discovery.list_messages: - discovery.append({ - ATTR_ADDON: message.addon, - ATTR_SERVICE: message.service, - ATTR_UUID: message.uuid, - ATTR_CONFIG: message.config, - }) + discovery.append( + { + ATTR_ADDON: message.addon, + ATTR_SERVICE: message.service, + ATTR_UUID: message.uuid, + ATTR_CONFIG: message.config, + } + ) return {ATTR_DISCOVERY: discovery} diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index d30c986a2..619858d06 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -1,15 +1,34 @@ """Init file for Hass.io Home Assistant RESTful API.""" import asyncio import logging +from typing import Coroutine, Dict, Any import voluptuous as vol +from aiohttp import web from ..const import ( - ATTR_ARCH, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_BOOT, ATTR_CPU_PERCENT, - ATTR_CUSTOM, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_MACHINE, ATTR_MEMORY_LIMIT, - ATTR_MEMORY_USAGE, ATTR_NETWORK_RX, ATTR_NETWORK_TX, ATTR_PASSWORD, - ATTR_PORT, ATTR_REFRESH_TOKEN, ATTR_SSL, ATTR_VERSION, ATTR_WAIT_BOOT, - ATTR_WATCHDOG, CONTENT_TYPE_BINARY) + ATTR_ARCH, + ATTR_BLK_READ, + ATTR_BLK_WRITE, + ATTR_BOOT, + ATTR_CPU_PERCENT, + ATTR_CUSTOM, + ATTR_IMAGE, + ATTR_LAST_VERSION, + ATTR_MACHINE, + ATTR_MEMORY_LIMIT, + ATTR_MEMORY_USAGE, + ATTR_NETWORK_RX, + ATTR_NETWORK_TX, + ATTR_PASSWORD, + ATTR_PORT, + ATTR_REFRESH_TOKEN, + ATTR_SSL, + ATTR_VERSION, + ATTR_WAIT_BOOT, + ATTR_WATCHDOG, + CONTENT_TYPE_BINARY, +) from ..coresys import CoreSysAttributes from ..exceptions import APIError from ..validate import DOCKER_IMAGE, NETWORK_PORT @@ -18,37 +37,28 @@ from .utils import api_process, api_process_raw, api_validate _LOGGER = logging.getLogger(__name__) # pylint: disable=no-value-for-parameter -SCHEMA_OPTIONS = vol.Schema({ - vol.Optional(ATTR_BOOT): - vol.Boolean(), - vol.Inclusive(ATTR_IMAGE, 'custom_hass'): - vol.Maybe(vol.Coerce(str)), - vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): - vol.Any(None, DOCKER_IMAGE), - vol.Optional(ATTR_PORT): - NETWORK_PORT, - vol.Optional(ATTR_PASSWORD): - vol.Maybe(vol.Coerce(str)), - vol.Optional(ATTR_SSL): - vol.Boolean(), - vol.Optional(ATTR_WATCHDOG): - vol.Boolean(), - vol.Optional(ATTR_WAIT_BOOT): - vol.All(vol.Coerce(int), vol.Range(min=60)), - vol.Optional(ATTR_REFRESH_TOKEN): - vol.Maybe(vol.Coerce(str)), -}) +SCHEMA_OPTIONS = vol.Schema( + { + vol.Optional(ATTR_BOOT): vol.Boolean(), + vol.Inclusive(ATTR_IMAGE, "custom_hass"): vol.Maybe(vol.Coerce(str)), + vol.Inclusive(ATTR_LAST_VERSION, "custom_hass"): vol.Any(None, DOCKER_IMAGE), + vol.Optional(ATTR_PORT): NETWORK_PORT, + vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_SSL): vol.Boolean(), + vol.Optional(ATTR_WATCHDOG): vol.Boolean(), + vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)), + vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)), + } +) -SCHEMA_VERSION = vol.Schema({ - vol.Optional(ATTR_VERSION): vol.Coerce(str), -}) +SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)}) class APIHomeAssistant(CoreSysAttributes): """Handle RESTful API for Home Assistant functions.""" @api_process - async def info(self, request): + async def info(self, request: web.Request) -> Dict[str, Any]: """Return host information.""" return { ATTR_VERSION: self.sys_homeassistant.version, @@ -65,7 +75,7 @@ class APIHomeAssistant(CoreSysAttributes): } @api_process - async def options(self, request): + async def options(self, request: web.Request) -> None: """Set Home Assistant options.""" body = await api_validate(SCHEMA_OPTIONS, request) @@ -81,6 +91,7 @@ class APIHomeAssistant(CoreSysAttributes): if ATTR_PASSWORD in body: self.sys_homeassistant.api_password = body[ATTR_PASSWORD] + self.sys_homeassistant.refresh_token = None if ATTR_SSL in body: self.sys_homeassistant.api_ssl = body[ATTR_SSL] @@ -97,7 +108,7 @@ class APIHomeAssistant(CoreSysAttributes): self.sys_homeassistant.save_data() @api_process - async def stats(self, request): + async def stats(self, request: web.Request) -> Dict[Any, str]: """Return resource information.""" stats = await self.sys_homeassistant.stats() if not stats: @@ -114,7 +125,7 @@ class APIHomeAssistant(CoreSysAttributes): } @api_process - async def update(self, request): + async def update(self, request: web.Request) -> None: """Update Home Assistant.""" body = await api_validate(SCHEMA_VERSION, request) version = body.get(ATTR_VERSION, self.sys_homeassistant.last_version) @@ -122,27 +133,32 @@ class APIHomeAssistant(CoreSysAttributes): await asyncio.shield(self.sys_homeassistant.update(version)) @api_process - def stop(self, request): + def stop(self, request: web.Request) -> Coroutine: """Stop Home Assistant.""" return asyncio.shield(self.sys_homeassistant.stop()) @api_process - def start(self, request): + def start(self, request: web.Request) -> Coroutine: """Start Home Assistant.""" return asyncio.shield(self.sys_homeassistant.start()) @api_process - def restart(self, request): + def restart(self, request: web.Request) -> Coroutine: """Restart Home Assistant.""" return asyncio.shield(self.sys_homeassistant.restart()) + @api_process + def rebuild(self, request: web.Request) -> Coroutine: + """Rebuild Home Assistant.""" + return asyncio.shield(self.sys_homeassistant.rebuild()) + @api_process_raw(CONTENT_TYPE_BINARY) - def logs(self, request): + def logs(self, request: web.Request) -> Coroutine: """Return Home Assistant Docker logs.""" return self.sys_homeassistant.logs() @api_process - async def check(self, request): + async def check(self, request: web.Request) -> None: """Check configuration of Home Assistant.""" result = await self.sys_homeassistant.check_config() if not result.valid: diff --git a/hassio/const.py b/hassio/const.py index 2b3bb6aa1..a70966bab 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -2,7 +2,7 @@ from pathlib import Path from ipaddress import ip_network -HASSIO_VERSION = "150" +HASSIO_VERSION = "151" URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_VERSION = "https://s3.amazonaws.com/hassio-version/{channel}.json" @@ -159,7 +159,6 @@ ATTR_ADDON = "addon" ATTR_AVAILABLE = "available" ATTR_HOST = "host" ATTR_USERNAME = "username" -ATTR_PROTOCOL = "protocol" ATTR_DISCOVERY = "discovery" ATTR_CONFIG = "config" ATTR_SERVICES = "services" @@ -189,7 +188,6 @@ ATTR_AUTH_API = "auth_api" ATTR_KERNEL_MODULES = "kernel_modules" ATTR_SUPPORTED_ARCH = "supported_arch" -SERVICE_MQTT = "mqtt" PROVIDE_SERVICE = "provide" NEED_SERVICE = "need" WANT_SERVICE = "want" diff --git a/hassio/core.py b/hassio/core.py index d5819d35a..b9ae489ef 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -6,8 +6,12 @@ import logging import async_timeout from .coresys import CoreSysAttributes -from .const import (STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, - STARTUP_INITIALIZE) +from .const import ( + STARTUP_SYSTEM, + STARTUP_SERVICES, + STARTUP_APPLICATION, + STARTUP_INITIALIZE, +) from .exceptions import HassioError, HomeAssistantError _LOGGER = logging.getLogger(__name__) @@ -108,7 +112,7 @@ class HassIO(CoreSysAttributes): await self.sys_tasks.load() # If landingpage / run upgrade in background - if self.sys_homeassistant.version == 'landingpage': + if self.sys_homeassistant.version == "landingpage": self.sys_create_task(self.sys_homeassistant.install()) _LOGGER.info("Hass.io is up and running") @@ -121,12 +125,14 @@ class HassIO(CoreSysAttributes): # process async stop tasks try: with async_timeout.timeout(10): - await asyncio.wait([ - self.sys_api.stop(), - self.sys_dns.stop(), - self.sys_websession.close(), - self.sys_websession_ssl.close() - ]) + await asyncio.wait( + [ + self.sys_api.stop(), + self.sys_dns.stop(), + self.sys_websession.close(), + self.sys_websession_ssl.close(), + ] + ) except asyncio.TimeoutError: _LOGGER.warning("Force Shutdown!") diff --git a/hassio/discovery.py b/hassio/discovery/__init__.py similarity index 61% rename from hassio/discovery.py rename to hassio/discovery/__init__.py index 116e4e4cd..46f3b1ff4 100644 --- a/hassio/discovery.py +++ b/hassio/discovery/__init__.py @@ -1,35 +1,50 @@ """Handle discover message for Home Assistant.""" -import logging +from __future__ import annotations + from contextlib import suppress -from uuid import uuid4 +import logging +from typing import Any, Dict, List, Optional, TYPE_CHECKING +from uuid import uuid4, UUID import attr import voluptuous as vol from voluptuous.humanize import humanize_error -from .const import FILE_HASSIO_DISCOVERY, ATTR_CONFIG, ATTR_DISCOVERY -from .coresys import CoreSysAttributes -from .exceptions import DiscoveryError, HomeAssistantAPIError -from .validate import SCHEMA_DISCOVERY_CONFIG -from .utils.json import JsonConfig -from .services.validate import DISCOVERY_SERVICES +from ..const import ATTR_CONFIG, ATTR_DISCOVERY, FILE_HASSIO_DISCOVERY +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import DiscoveryError, HomeAssistantAPIError +from ..utils.json import JsonConfig +from .validate import SCHEMA_DISCOVERY_CONFIG, valid_discovery_config + +if TYPE_CHECKING: + from ..addons.addon import Addon _LOGGER = logging.getLogger(__name__) -CMD_NEW = 'post' -CMD_DEL = 'delete' +CMD_NEW = "post" +CMD_DEL = "delete" + + +@attr.s +class Message: + """Represent a single Discovery message.""" + + addon: str = attr.ib() + service: str = attr.ib() + config: Dict[str, Any] = attr.ib(cmp=False) + uuid: UUID = attr.ib(factory=lambda: uuid4().hex, cmp=False) class Discovery(CoreSysAttributes, JsonConfig): """Home Assistant Discovery handler.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize discovery handler.""" super().__init__(FILE_HASSIO_DISCOVERY, SCHEMA_DISCOVERY_CONFIG) - self.coresys = coresys - self.message_obj = {} + self.coresys: CoreSys = coresys + self.message_obj: Dict[str, Message] = {} - async def load(self): + async def load(self) -> None: """Load exists discovery message into storage.""" messages = {} for message in self._data[ATTR_DISCOVERY]: @@ -39,9 +54,9 @@ class Discovery(CoreSysAttributes, JsonConfig): _LOGGER.info("Load %d messages", len(messages)) self.message_obj = messages - def save(self): + def save(self) -> None: """Write discovery message into data file.""" - messages = [] + messages: List[Dict[str, Any]] = [] for message in self.list_messages: messages.append(attr.asdict(message)) @@ -49,22 +64,21 @@ class Discovery(CoreSysAttributes, JsonConfig): self._data[ATTR_DISCOVERY].extend(messages) self.save_data() - def get(self, uuid): + def get(self, uuid: str) -> Optional[Message]: """Return discovery message.""" return self.message_obj.get(uuid) @property - def list_messages(self): + def list_messages(self) -> List[Message]: """Return list of available discovery messages.""" return list(self.message_obj.values()) - def send(self, addon, service, config): + def send(self, addon: Addon, service: str, config: Dict[str, Any]) -> Message: """Send a discovery message to Home Assistant.""" try: - config = DISCOVERY_SERVICES[service](config) + config = valid_discovery_config(service, config) except vol.Invalid as err: - _LOGGER.error( - "Invalid discovery %s config", humanize_error(config, err)) + _LOGGER.error("Invalid discovery %s config", humanize_error(config, err)) raise DiscoveryError() from None # Create message @@ -77,24 +91,26 @@ class Discovery(CoreSysAttributes, JsonConfig): _LOGGER.info("Duplicate discovery message from %s", addon.slug) return old_message - _LOGGER.info("Send discovery to Home Assistant %s from %s", - service, addon.slug) + _LOGGER.info("Send discovery to Home Assistant %s from %s", service, addon.slug) self.message_obj[message.uuid] = message self.save() self.sys_create_task(self._push_discovery(message, CMD_NEW)) return message - def remove(self, message): + def remove(self, message: Message) -> None: """Remove a discovery message from Home Assistant.""" self.message_obj.pop(message.uuid, None) self.save() - _LOGGER.info("Delete discovery to Home Assistant %s from %s", - message.service, message.addon) + _LOGGER.info( + "Delete discovery to Home Assistant %s from %s", + message.service, + message.addon, + ) self.sys_create_task(self._push_discovery(message, CMD_DEL)) - async def _push_discovery(self, message, command): + async def _push_discovery(self, message: Message, command: str) -> None: """Send a discovery request.""" if not await self.sys_homeassistant.check_api_state(): _LOGGER.info("Discovery %s mesage ignore", message.uuid) @@ -105,18 +121,12 @@ class Discovery(CoreSysAttributes, JsonConfig): with suppress(HomeAssistantAPIError): async with self.sys_homeassistant.make_request( - command, f"api/hassio_push/discovery/{message.uuid}", - json=data, timeout=10): + command, + f"api/hassio_push/discovery/{message.uuid}", + json=data, + timeout=10, + ): _LOGGER.info("Discovery %s message send", message.uuid) return _LOGGER.warning("Discovery %s message fail", message.uuid) - - -@attr.s -class Message: - """Represent a single Discovery message.""" - addon = attr.ib() - service = attr.ib() - config = attr.ib(cmp=False) - uuid = attr.ib(factory=lambda: uuid4().hex, cmp=False) diff --git a/hassio/discovery/const.py b/hassio/discovery/const.py new file mode 100644 index 000000000..c85f248fd --- /dev/null +++ b/hassio/discovery/const.py @@ -0,0 +1,8 @@ +"""Discovery static data.""" + +ATTR_HOST = "host" +ATTR_PASSWORD = "password" +ATTR_PORT = "port" +ATTR_PROTOCOL = "protocol" +ATTR_SSL = "ssl" +ATTR_USERNAME = "username" diff --git a/hassio/discovery/services/__init__.py b/hassio/discovery/services/__init__.py new file mode 100644 index 000000000..5a423090d --- /dev/null +++ b/hassio/discovery/services/__init__.py @@ -0,0 +1 @@ +"""Discovery service modules.""" diff --git a/hassio/discovery/services/deconz.py b/hassio/discovery/services/deconz.py new file mode 100644 index 000000000..d817aac1f --- /dev/null +++ b/hassio/discovery/services/deconz.py @@ -0,0 +1,11 @@ +"""Discovery service for MQTT.""" +import voluptuous as vol + +from hassio.validate import NETWORK_PORT + +from ..const import ATTR_HOST, ATTR_PORT + + +SCHEMA = vol.Schema( + {vol.Required(ATTR_HOST): vol.Coerce(str), vol.Required(ATTR_PORT): NETWORK_PORT} +) diff --git a/hassio/discovery/services/mqtt.py b/hassio/discovery/services/mqtt.py new file mode 100644 index 000000000..abb673bf2 --- /dev/null +++ b/hassio/discovery/services/mqtt.py @@ -0,0 +1,27 @@ +"""Discovery service for MQTT.""" +import voluptuous as vol + +from hassio.validate import NETWORK_PORT + +from ..const import ( + ATTR_HOST, + ATTR_PASSWORD, + ATTR_PORT, + ATTR_PROTOCOL, + ATTR_SSL, + ATTR_USERNAME, +) + +# pylint: disable=no-value-for-parameter +SCHEMA = vol.Schema( + { + vol.Required(ATTR_HOST): vol.Coerce(str), + vol.Required(ATTR_PORT): NETWORK_PORT, + vol.Optional(ATTR_USERNAME): vol.Coerce(str), + vol.Optional(ATTR_PASSWORD): vol.Coerce(str), + vol.Optional(ATTR_SSL, default=False): vol.Boolean(), + vol.Optional(ATTR_PROTOCOL, default="3.1.1"): vol.All( + vol.Coerce(str), vol.In(["3.1", "3.1.1"]) + ), + } +) diff --git a/hassio/discovery/validate.py b/hassio/discovery/validate.py new file mode 100644 index 000000000..52d5db619 --- /dev/null +++ b/hassio/discovery/validate.py @@ -0,0 +1,47 @@ +"""Validate services schema.""" +from pathlib import Path +from importlib import import_module + +import voluptuous as vol + +from ..const import ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_SERVICE, ATTR_UUID +from ..utils.validate import schema_or +from ..validate import UUID_MATCH + + +def valid_discovery_service(service): + """Validate service name.""" + service_file = Path(__file__).parent.joinpath(f"services/{service}.py") + if not service_file.exists(): + raise vol.Invalid(f"Service {service} not found") + return service + + +def valid_discovery_config(service, config): + """Validate service name.""" + try: + service_mod = import_module(f".services.{service}", "hassio.discovery") + except ImportError: + raise vol.Invalid(f"Service {service} not found") + + return service_mod.SCHEMA(config) + + +SCHEMA_DISCOVERY = vol.Schema( + [ + vol.Schema( + { + vol.Required(ATTR_UUID): UUID_MATCH, + vol.Required(ATTR_ADDON): vol.Coerce(str), + vol.Required(ATTR_SERVICE): valid_discovery_service, + vol.Required(ATTR_CONFIG): vol.Maybe(dict), + }, + extra=vol.REMOVE_EXTRA, + ) + ] +) + +SCHEMA_DISCOVERY_CONFIG = vol.Schema( + {vol.Optional(ATTR_DISCOVERY, default=list): schema_or(SCHEMA_DISCOVERY)}, + extra=vol.REMOVE_EXTRA, +) diff --git a/hassio/docker/hassos_cli.py b/hassio/docker/hassos_cli.py index 315448899..97a0c0a5f 100644 --- a/hassio/docker/hassos_cli.py +++ b/hassio/docker/hassos_cli.py @@ -17,7 +17,7 @@ class DockerHassOSCli(DockerInterface, CoreSysAttributes): """Return name of HassOS CLI image.""" return f"homeassistant/{self.sys_arch.supervisor}-hassio-cli" - def _stop(self): + def _stop(self, remove_container=True): """Don't need stop.""" return True @@ -33,5 +33,6 @@ class DockerHassOSCli(DockerInterface, CoreSysAttributes): else: self._meta = image.attrs - _LOGGER.info("Found HassOS CLI %s with version %s", self.image, - self.version) + _LOGGER.info( + "Found HassOS CLI %s with version %s", self.image, self.version + ) diff --git a/hassio/docker/homeassistant.py b/hassio/docker/homeassistant.py index e219fb163..873015bdf 100644 --- a/hassio/docker/homeassistant.py +++ b/hassio/docker/homeassistant.py @@ -8,7 +8,7 @@ from ..const import ENV_TOKEN, ENV_TIME, LABEL_MACHINE _LOGGER = logging.getLogger(__name__) -HASS_DOCKER_NAME = 'homeassistant' +HASS_DOCKER_NAME = "homeassistant" class DockerHomeAssistant(DockerInterface): @@ -17,8 +17,8 @@ class DockerHomeAssistant(DockerInterface): @property def machine(self): """Return machine of Home Assistant Docker image.""" - if self._meta and LABEL_MACHINE in self._meta['Config']['Labels']: - return self._meta['Config']['Labels'][LABEL_MACHINE] + if self._meta and LABEL_MACHINE in self._meta["Config"]["Labels"]: + return self._meta["Config"]["Labels"][LABEL_MACHINE] return None @property @@ -58,25 +58,29 @@ class DockerHomeAssistant(DockerInterface): privileged=True, init=True, devices=self.devices, - network_mode='host', + network_mode="host", environment={ - 'HASSIO': self.sys_docker.network.supervisor, + "HASSIO": self.sys_docker.network.supervisor, ENV_TIME: self.sys_timezone, ENV_TOKEN: self.sys_homeassistant.hassio_token, }, volumes={ - str(self.sys_config.path_extern_homeassistant): - {'bind': '/config', 'mode': 'rw'}, - str(self.sys_config.path_extern_ssl): - {'bind': '/ssl', 'mode': 'ro'}, - str(self.sys_config.path_extern_share): - {'bind': '/share', 'mode': 'rw'}, - } + str(self.sys_config.path_extern_homeassistant): { + "bind": "/config", + "mode": "rw", + }, + str(self.sys_config.path_extern_ssl): {"bind": "/ssl", "mode": "ro"}, + str(self.sys_config.path_extern_share): { + "bind": "/share", + "mode": "rw", + }, + }, ) if ret: - _LOGGER.info("Start homeassistant %s with version %s", - self.image, self.version) + _LOGGER.info( + "Start homeassistant %s with version %s", self.image, self.version + ) return ret @@ -94,17 +98,18 @@ class DockerHomeAssistant(DockerInterface): detach=True, stdout=True, stderr=True, - environment={ - ENV_TIME: self.sys_timezone, - }, + environment={ENV_TIME: self.sys_timezone}, volumes={ - str(self.sys_config.path_extern_homeassistant): - {'bind': '/config', 'mode': 'rw'}, - str(self.sys_config.path_extern_ssl): - {'bind': '/ssl', 'mode': 'ro'}, - str(self.sys_config.path_extern_share): - {'bind': '/share', 'mode': 'ro'}, - } + str(self.sys_config.path_extern_homeassistant): { + "bind": "/config", + "mode": "rw", + }, + str(self.sys_config.path_extern_ssl): {"bind": "/ssl", "mode": "ro"}, + str(self.sys_config.path_extern_share): { + "bind": "/share", + "mode": "ro", + }, + }, ) def is_initialize(self): @@ -117,8 +122,13 @@ class DockerHomeAssistant(DockerInterface): Need run inside executor. """ try: - self.sys_docker.containers.get(self.name) + docker_container = self.sys_docker.containers.get(self.name) + docker_image = self.sys_docker.images.get(self.image) except docker.errors.DockerException: return False + # we run on an old image, stop and start it + if docker_container.image.id != docker_image.id: + return False + return True diff --git a/hassio/docker/interface.py b/hassio/docker/interface.py index b427cab53..e227035cb 100644 --- a/hassio/docker/interface.py +++ b/hassio/docker/interface.py @@ -5,10 +5,10 @@ import logging import docker -from .stats import DockerStats -from ..const import LABEL_VERSION, LABEL_ARCH +from ..const import LABEL_ARCH, LABEL_VERSION from ..coresys import CoreSysAttributes from ..utils import process_lock +from .stats import DockerStats _LOGGER = logging.getLogger(__name__) @@ -37,17 +37,17 @@ class DockerInterface(CoreSysAttributes): """Return meta data of configuration for container/image.""" if not self._meta: return {} - return self._meta.get('Config', {}) + return self._meta.get("Config", {}) @property def meta_labels(self): """Return meta data of labels for container/image.""" - return self.meta_config.get('Labels') or {} + return self.meta_config.get("Labels") or {} @property def image(self): """Return name of Docker image.""" - return self.meta_config.get('Image') + return self.meta_config.get("Image") @property def version(self): @@ -80,7 +80,7 @@ class DockerInterface(CoreSysAttributes): _LOGGER.info("Pull image %s tag %s.", image, tag) docker_image = self.sys_docker.images.pull(f"{image}:{tag}") - docker_image.tag(image, tag='latest') + docker_image.tag(image, tag="latest") self._meta = docker_image.attrs except docker.errors.APIError as err: _LOGGER.error("Can't install %s:%s -> %s.", image, tag, err) @@ -125,7 +125,7 @@ class DockerInterface(CoreSysAttributes): return False # container is not running - if docker_container.status != 'running': + if docker_container.status != "running": return False # we run on an old image, stop and start it @@ -152,8 +152,7 @@ class DockerInterface(CoreSysAttributes): except docker.errors.DockerException: return False - _LOGGER.info("Attach to image %s with version %s", self.image, - self.version) + _LOGGER.info("Attach to image %s with version %s", self.image, self.version) return True @@ -170,12 +169,12 @@ class DockerInterface(CoreSysAttributes): raise NotImplementedError() @process_lock - def stop(self): + def stop(self, remove_container=True): """Stop/remove Docker container.""" - return self.sys_run_in_executor(self._stop) + return self.sys_run_in_executor(self._stop, remove_container) - def _stop(self): - """Stop/remove and remove docker container. + def _stop(self, remove_container=True): + """Stop/remove Docker container. Need run inside executor. """ @@ -184,14 +183,39 @@ class DockerInterface(CoreSysAttributes): except docker.errors.DockerException: return False - if docker_container.status == 'running': + if docker_container.status == "running": _LOGGER.info("Stop %s Docker application", self.image) with suppress(docker.errors.DockerException): docker_container.stop(timeout=self.timeout) - with suppress(docker.errors.DockerException): - _LOGGER.info("Clean %s Docker application", self.image) - docker_container.remove(force=True) + if remove_container: + with suppress(docker.errors.DockerException): + _LOGGER.info("Clean %s Docker application", self.image) + docker_container.remove(force=True) + + return True + + @process_lock + def start(self): + """Start Docker container.""" + return self.sys_run_in_executor(self._start) + + def _start(self): + """Start docker container. + + Need run inside executor. + """ + try: + docker_container = self.sys_docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + _LOGGER.info("Start %s", self.image) + try: + docker_container.start() + except docker.errors.DockerException as err: + _LOGGER.error("Can't start %s: %s", self.image, err) + return False return True @@ -208,17 +232,16 @@ class DockerInterface(CoreSysAttributes): # Cleanup container self._stop() - _LOGGER.info("Remove Docker %s with latest and %s", self.image, - self.version) + _LOGGER.info("Remove Docker %s with latest and %s", self.image, self.version) try: with suppress(docker.errors.ImageNotFound): - self.sys_docker.images.remove( - image=f"{self.image}:latest", force=True) + self.sys_docker.images.remove(image=f"{self.image}:latest", force=True) with suppress(docker.errors.ImageNotFound): self.sys_docker.images.remove( - image=f"{self.image}:{self.version}", force=True) + image=f"{self.image}:{self.version}", force=True + ) except docker.errors.DockerException as err: _LOGGER.warning("Can't remove image %s: %s", self.image, err) @@ -239,8 +262,9 @@ class DockerInterface(CoreSysAttributes): """ image = image or self.image - _LOGGER.info("Update Docker %s:%s to %s:%s", self.image, self.version, - image, tag) + _LOGGER.info( + "Update Docker %s:%s to %s:%s", self.image, self.version, image, tag + ) # Update docker image if not self._install(tag, image): @@ -300,6 +324,29 @@ class DockerInterface(CoreSysAttributes): return True + @process_lock + def restart(self): + """Restart docker container.""" + return self.sys_loop.run_in_executor(None, self._restart) + + def _restart(self): + """Restart docker container. + + Need run inside executor. + """ + try: + container = self.sys_docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + _LOGGER.info("Restart %s", self.image) + try: + container.restart(timeout=self.timeout) + except docker.errors.DockerException as err: + _LOGGER.warning("Can't restart %s: %s", self.image, err) + return False + return True + @process_lock def execute_command(self, command): """Create a temporary container and run command.""" @@ -332,3 +379,30 @@ class DockerInterface(CoreSysAttributes): except docker.errors.DockerException as err: _LOGGER.error("Can't read stats from %s: %s", self.name, err) return None + + def is_fails(self): + """Return True if Docker is failing state. + + Return a Future. + """ + return self.sys_run_in_executor(self._is_fails) + + def _is_fails(self): + """Return True if Docker is failing state. + + Need run inside executor. + """ + try: + docker_container = self.sys_docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + # container is not running + if docker_container.status != "exited": + return False + + # Check return value + if int(docker_container.attrs["State"]["ExitCode"]) != 0: + return True + + return False diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index 5b6930cbb..579451089 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -2,26 +2,44 @@ import asyncio from contextlib import asynccontextmanager, suppress from datetime import datetime, timedelta +from ipaddress import IPv4Address import logging import os -import re from pathlib import Path +import re import socket import time +from typing import Any, AsyncContextManager, Coroutine, Dict, Optional +from uuid import UUID import aiohttp from aiohttp import hdrs import attr -from .const import (FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, - ATTR_UUID, ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, - ATTR_WATCHDOG, ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN, - ATTR_ACCESS_TOKEN, HEADER_HA_ACCESS) -from .coresys import CoreSysAttributes +from .const import ( + ATTR_ACCESS_TOKEN, + ATTR_BOOT, + ATTR_IMAGE, + ATTR_LAST_VERSION, + ATTR_PASSWORD, + ATTR_PORT, + ATTR_REFRESH_TOKEN, + ATTR_SSL, + ATTR_UUID, + ATTR_WAIT_BOOT, + ATTR_WATCHDOG, + FILE_HASSIO_HOMEASSISTANT, + HEADER_HA_ACCESS, +) +from .coresys import CoreSys, CoreSysAttributes from .docker.homeassistant import DockerHomeAssistant -from .exceptions import (HomeAssistantUpdateError, HomeAssistantError, - HomeAssistantAPIError, HomeAssistantAuthError) -from .utils import convert_to_ascii, process_lock, create_token +from .exceptions import ( + HomeAssistantAPIError, + HomeAssistantAuthError, + HomeAssistantError, + HomeAssistantUpdateError, +) +from .utils import convert_to_ascii, create_token, process_lock from .utils.json import JsonConfig from .validate import SCHEMA_HASS_CONFIG @@ -40,18 +58,19 @@ class ConfigResult: class HomeAssistant(JsonConfig, CoreSysAttributes): """Home Assistant core object for handle it.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize Home Assistant object.""" super().__init__(FILE_HASSIO_HOMEASSISTANT, SCHEMA_HASS_CONFIG) - self.coresys = coresys - self.instance = DockerHomeAssistant(coresys) - self.lock = asyncio.Lock(loop=coresys.loop) - self._error_state = False - # We don't persist access tokens. Instead we fetch new ones when needed - self.access_token = None - self._access_token_expires = None + self.coresys: CoreSys = coresys + self.instance: DockerHomeAssistant = DockerHomeAssistant(coresys) + self.lock: asyncio.Lock = asyncio.Lock(loop=coresys.loop) + self._error_state: bool = False - async def load(self): + # 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.""" if await self.instance.attach(): return @@ -60,95 +79,95 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await self.install_landingpage() @property - def machine(self): + def machine(self) -> str: """Return the system machines.""" return self.instance.machine @property - def arch(self): + def arch(self) -> str: """Return arch of running Home Assistant.""" return self.instance.arch @property - def error_state(self): + def error_state(self) -> bool: """Return True if system is in error.""" return self._error_state @property - def api_ip(self): + def api_ip(self) -> IPv4Address: """Return IP of Home Assistant instance.""" return self.sys_docker.network.gateway @property - def api_port(self): + 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): + def api_port(self, value: int) -> None: """Set network port for Home Assistant instance.""" self._data[ATTR_PORT] = value @property - def api_password(self): + def api_password(self) -> str: """Return password for Home Assistant instance.""" return self._data.get(ATTR_PASSWORD) @api_password.setter - def api_password(self, value): + def api_password(self, value: str): """Set password for Home Assistant instance.""" self._data[ATTR_PASSWORD] = value @property - def api_ssl(self): + 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): + def api_ssl(self, value: bool): """Set SSL for Home Assistant instance.""" self._data[ATTR_SSL] = value @property - def api_url(self): + def api_url(self) -> str: """Return API url to Home Assistant.""" return "{}://{}:{}".format('https' if self.api_ssl else 'http', self.api_ip, self.api_port) @property - def watchdog(self): + def watchdog(self) -> bool: """Return True if the watchdog should protect Home Assistant.""" return self._data[ATTR_WATCHDOG] @watchdog.setter - def watchdog(self, value): + def watchdog(self, value: bool): """Return True if the watchdog should protect Home Assistant.""" self._data[ATTR_WATCHDOG] = value @property - def wait_boot(self): + 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): + def wait_boot(self, value: int): """Set time to wait for Home Assistant startup.""" self._data[ATTR_WAIT_BOOT] = value @property - def version(self): + def version(self) -> str: """Return version of running Home Assistant.""" return self.instance.version @property - def last_version(self): + def last_version(self) -> str: """Return last available version of Home Assistant.""" if self.is_custom_image: return self._data.get(ATTR_LAST_VERSION) return self.sys_updater.version_homeassistant @last_version.setter - def last_version(self, value): + def last_version(self, value: str): """Set last available version of Home Assistant.""" if value: self._data[ATTR_LAST_VERSION] = value @@ -156,14 +175,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): self._data.pop(ATTR_LAST_VERSION, None) @property - def image(self): + def image(self) -> str: """Return image name of the Home Assistant container.""" if self._data.get(ATTR_IMAGE): return self._data[ATTR_IMAGE] return os.environ['HOMEASSISTANT_REPOSITORY'] @image.setter - def image(self, value): + def image(self, value: str): """Set image name of Home Assistant container.""" if value: self._data[ATTR_IMAGE] = value @@ -171,43 +190,43 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): self._data.pop(ATTR_IMAGE, None) @property - def is_custom_image(self): + def is_custom_image(self) -> bool: """Return True if a custom image is used.""" return all( attr in self._data for attr in (ATTR_IMAGE, ATTR_LAST_VERSION)) @property - def boot(self): + def boot(self) -> bool: """Return True if Home Assistant boot is enabled.""" return self._data[ATTR_BOOT] @boot.setter - def boot(self, value): + def boot(self, value: bool): """Set Home Assistant boot options.""" self._data[ATTR_BOOT] = value @property - def uuid(self): + def uuid(self) -> UUID: """Return a UUID of this Home Assistant instance.""" return self._data[ATTR_UUID] @property - def hassio_token(self): + def hassio_token(self) -> str: """Return an access token for the Hass.io API.""" return self._data.get(ATTR_ACCESS_TOKEN) @property - def refresh_token(self): + def refresh_token(self) -> 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): + def refresh_token(self, value: str): """Set Home Assistant refresh_token.""" self._data[ATTR_REFRESH_TOKEN] = value @process_lock - async def install_landingpage(self): + async def install_landingpage(self) -> None: """Install a landing page.""" _LOGGER.info("Setup HomeAssistant landingpage") while True: @@ -217,7 +236,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await asyncio.sleep(30) @process_lock - async def install(self): + async def install(self) -> None: """Install a landing page.""" _LOGGER.info("Setup Home Assistant") while True: @@ -244,7 +263,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await self.instance.cleanup() @process_lock - async def update(self, version=None): + async def update(self, version=None) -> None: """Update HomeAssistant version.""" version = version or self.last_version rollback = self.version if not self.error_state else None @@ -258,14 +277,13 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): # process an update async def _update(to_version): """Run Home Assistant update.""" - try: - _LOGGER.info("Update Home Assistant to version %s", to_version) - if not await self.instance.update(to_version): - raise HomeAssistantUpdateError() - finally: - if running: - await self._start() - _LOGGER.info("Successful run Home Assistant %s", to_version) + _LOGGER.info("Update Home Assistant to version %s", to_version) + if not await self.instance.update(to_version): + raise HomeAssistantUpdateError() + + if running: + await self._start() + _LOGGER.info("Successful run Home Assistant %s", to_version) # Update Home Assistant with suppress(HomeAssistantError): @@ -279,7 +297,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): else: raise HomeAssistantUpdateError() - async def _start(self): + async def _start(self) -> None: """Start Home Assistant Docker & wait.""" if await self.instance.is_running(): _LOGGER.warning("Home Assistant is already running!") @@ -294,61 +312,74 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await self._block_till_run() @process_lock - def start(self): - """Run Home Assistant docker. + async def start(self) -> None: + """Run Home Assistant docker.""" + if await self.instance.is_running(): + await self.instance.restart() + elif await self.instance.is_initialize(): + await self.instance.start() + else: + await self._start() + return - Return a coroutine. - """ - return self._start() + await self._block_till_run() @process_lock - def stop(self): + def stop(self) -> Coroutine: """Stop Home Assistant Docker. Return a coroutine. """ - return self.instance.stop() + return self.instance.stop(remove_container=False) @process_lock - async def restart(self): + async def restart(self) -> None: """Restart Home Assistant Docker.""" + if not await self.instance.restart(): + raise HomeAssistantError() + + await self._block_till_run() + + @process_lock + async def rebuild(self) -> None: + """Rebuild Home Assistant Docker container.""" await self.instance.stop() await self._start() - def logs(self): + def logs(self) -> Coroutine: """Get HomeAssistant docker logs. Return a coroutine. """ return self.instance.logs() - def stats(self): + def stats(self) -> Coroutine: """Return stats of Home Assistant. Return a coroutine. """ return self.instance.stats() - def is_running(self): + def is_running(self) -> Coroutine: """Return True if Docker container is running. Return a coroutine. """ return self.instance.is_running() - def is_initialize(self): - """Return True if a Docker container is exists. + def is_fails(self) -> Coroutine: + """Return True if a Docker container is fails state. Return a coroutine. """ - return self.instance.is_initialize() + return self.instance.is_fails() @property - def in_progress(self): + def in_progress(self) -> bool: """Return True if a task is in progress.""" return self.instance.in_progress or self.lock.locked() - async def check_config(self): + async def check_config(self) -> ConfigResult: """Run Home Assistant config check.""" result = await self.instance.execute_command( "python3 -m homeassistant -c /config --script check_config") @@ -367,7 +398,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): _LOGGER.info("Home Assistant config is valid") return ConfigResult(True, log) - async def ensure_access_token(self): + async def ensure_access_token(self) -> None: """Ensures there is an access token.""" if self.access_token is not None and self._access_token_expires > datetime.utcnow(): return @@ -392,12 +423,12 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): @asynccontextmanager async def make_request(self, - method, - path, - json=None, - content_type=None, - data=None, - timeout=30): + method: str, + path: str, + json: Optional[Dict[str, Any]] = None, + content_type: Optional[str] = None, + data: Optional[bytes] = None, + timeout=30) -> AsyncContextManager[aiohttp.ClientResponse]: """Async context manager to make a request with right auth.""" url = f"{self.api_url}/{path}" headers = {} @@ -432,7 +463,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): raise HomeAssistantAPIError() - async def check_api_state(self): + async def check_api_state(self) -> bool: """Return True if Home Assistant up and running.""" with suppress(HomeAssistantAPIError): async with self.make_request('get', 'api/') as resp: @@ -443,7 +474,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): return False - async def _block_till_run(self): + async def _block_till_run(self) -> None: """Block until Home-Assistant is booting up or startup timeout.""" start_time = time.monotonic() migration_progress = False diff --git a/hassio/services/__init__.py b/hassio/services/__init__.py index 1fec40187..bfe235cee 100644 --- a/hassio/services/__init__.py +++ b/hassio/services/__init__.py @@ -1,38 +1,38 @@ """Handle internal services discovery.""" -from .mqtt import MQTTService +from typing import Dict, List + +from ..coresys import CoreSys, CoreSysAttributes +from .const import SERVICE_MQTT from .data import ServicesData -from ..const import SERVICE_MQTT -from ..coresys import CoreSysAttributes +from .interface import ServiceInterface +from .modules.mqtt import MQTTService - -AVAILABLE_SERVICES = { - SERVICE_MQTT: MQTTService -} +AVAILABLE_SERVICES = {SERVICE_MQTT: MQTTService} class ServiceManager(CoreSysAttributes): """Handle internal services discovery.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize Services handler.""" - self.coresys = coresys - self.data = ServicesData() - self.services_obj = {} + self.coresys: CoreSys = coresys + self.data: ServicesData = ServicesData() + self.services_obj: Dict[str, ServiceInterface] = {} @property - def list_services(self): + def list_services(self) -> List[ServiceInterface]: """Return a list of services.""" return list(self.services_obj.values()) - def get(self, slug): + def get(self, slug: str) -> ServiceInterface: """Return service object from slug.""" return self.services_obj.get(slug) - async def load(self): + async def load(self) -> None: """Load available services.""" for slug, service in AVAILABLE_SERVICES.items(): self.services_obj[slug] = service(self.coresys) - def reset(self): + def reset(self) -> None: """Reset available data.""" self.data.reset_data() diff --git a/hassio/services/const.py b/hassio/services/const.py new file mode 100644 index 000000000..f4a42e14c --- /dev/null +++ b/hassio/services/const.py @@ -0,0 +1,11 @@ +"""Service API static data.""" + +ATTR_ADDON = "addon" +ATTR_HOST = "host" +ATTR_PASSWORD = "password" +ATTR_PORT = "port" +ATTR_PROTOCOL = "protocol" +ATTR_SSL = "ssl" +ATTR_USERNAME = "username" + +SERVICE_MQTT = "mqtt" diff --git a/hassio/services/data.py b/hassio/services/data.py index 5df9df2d0..a77c81dcb 100644 --- a/hassio/services/data.py +++ b/hassio/services/data.py @@ -1,8 +1,10 @@ """Handle service data for persistent supervisor reboot.""" +from typing import Any, Dict -from .validate import SCHEMA_SERVICES_CONFIG -from ..const import FILE_HASSIO_SERVICES, SERVICE_MQTT +from ..const import FILE_HASSIO_SERVICES from ..utils.json import JsonConfig +from .const import SERVICE_MQTT +from .validate import SCHEMA_SERVICES_CONFIG class ServicesData(JsonConfig): @@ -13,6 +15,6 @@ class ServicesData(JsonConfig): super().__init__(FILE_HASSIO_SERVICES, SCHEMA_SERVICES_CONFIG) @property - def mqtt(self): + def mqtt(self) -> Dict[str, Any]: """Return settings for MQTT service.""" return self._data[SERVICE_MQTT] diff --git a/hassio/services/interface.py b/hassio/services/interface.py index 7215963e1..976f640a1 100644 --- a/hassio/services/interface.py +++ b/hassio/services/interface.py @@ -1,33 +1,37 @@ """Interface for single service.""" +from typing import Any, Dict, List, Optional -from ..coresys import CoreSysAttributes +import voluptuous as vol + +from ..addons.addon import Addon from ..const import PROVIDE_SERVICE +from ..coresys import CoreSys, CoreSysAttributes class ServiceInterface(CoreSysAttributes): """Interface class for service integration.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize service interface.""" - self.coresys = coresys + self.coresys: CoreSys = coresys @property - def slug(self): + def slug(self) -> str: """Return slug of this service.""" - return None + raise NotImplementedError() @property - def _data(self): + def _data(self) -> Dict[str, Any]: """Return data of this service.""" - return None + raise NotImplementedError() @property - def schema(self): + def schema(self) -> vol.Schema: """Return data schema of this service.""" - return None + raise NotImplementedError() @property - def providers(self): + def providers(self) -> List[str]: """Return name of service providers addon.""" addons = [] for addon in self.sys_addons.list_installed: @@ -36,24 +40,24 @@ class ServiceInterface(CoreSysAttributes): return addons @property - def enabled(self): + def enabled(self) -> bool: """Return True if the service is in use.""" return bool(self._data) - def save(self): + def save(self) -> None: """Save changes.""" self.sys_services.data.save_data() - def get_service_data(self): + def get_service_data(self) -> Optional[Dict[str, Any]]: """Return the requested service data.""" if self.enabled: return self._data return None - def set_service_data(self, addon, data): + def set_service_data(self, addon: Addon, data: Dict[str, Any]) -> None: """Write the data into service object.""" raise NotImplementedError() - def del_service_data(self, addon): + def del_service_data(self, addon: Addon) -> None: """Remove the data from service object.""" raise NotImplementedError() diff --git a/hassio/services/modules/__init__.py b/hassio/services/modules/__init__.py new file mode 100644 index 000000000..d5ca020e1 --- /dev/null +++ b/hassio/services/modules/__init__.py @@ -0,0 +1 @@ +"""Services modules.""" diff --git a/hassio/services/modules/mqtt.py b/hassio/services/modules/mqtt.py new file mode 100644 index 000000000..9a85c80fd --- /dev/null +++ b/hassio/services/modules/mqtt.py @@ -0,0 +1,81 @@ +"""Provide the MQTT Service.""" +import logging +from typing import Any, Dict + +from hassio.addons.addon import Addon +from hassio.exceptions import ServicesError +from hassio.validate import NETWORK_PORT +import voluptuous as vol + +from ..const import ( + ATTR_ADDON, + ATTR_HOST, + ATTR_PASSWORD, + ATTR_PORT, + ATTR_PROTOCOL, + ATTR_SSL, + ATTR_USERNAME, + SERVICE_MQTT, +) +from ..interface import ServiceInterface + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=no-value-for-parameter +SCHEMA_SERVICE_MQTT = vol.Schema( + { + vol.Required(ATTR_HOST): vol.Coerce(str), + vol.Required(ATTR_PORT): NETWORK_PORT, + vol.Optional(ATTR_USERNAME): vol.Coerce(str), + vol.Optional(ATTR_PASSWORD): vol.Coerce(str), + vol.Optional(ATTR_SSL, default=False): vol.Boolean(), + vol.Optional(ATTR_PROTOCOL, default="3.1.1"): vol.All( + vol.Coerce(str), vol.In(["3.1", "3.1.1"]) + ), + } +) + +SCHEMA_CONFIG_MQTT = SCHEMA_SERVICE_MQTT.extend( + {vol.Required(ATTR_ADDON): vol.Coerce(str)} +) + + +class MQTTService(ServiceInterface): + """Provide MQTT services.""" + + @property + def slug(self) -> str: + """Return slug of this service.""" + return SERVICE_MQTT + + @property + def _data(self) -> Dict[str, Any]: + """Return data of this service.""" + return self.sys_services.data.mqtt + + @property + def schema(self) -> vol.Schema: + """Return data schema of this service.""" + return SCHEMA_SERVICE_MQTT + + def set_service_data(self, addon: Addon, data: Dict[str, Any]) -> None: + """Write the data into service object.""" + if self.enabled: + _LOGGER.error("It is already a MQTT in use from %s", self._data[ATTR_ADDON]) + raise ServicesError() + + self._data.update(data) + self._data[ATTR_ADDON] = addon.slug + + _LOGGER.info("Set %s as service provider for mqtt", addon.slug) + self.save() + + def del_service_data(self, addon: Addon) -> None: + """Remove the data from service object.""" + if not self.enabled: + _LOGGER.warning("Can't remove not exists services") + raise ServicesError() + + self._data.clear() + self.save() diff --git a/hassio/services/mqtt.py b/hassio/services/mqtt.py deleted file mode 100644 index 05f98c292..000000000 --- a/hassio/services/mqtt.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Provide the MQTT Service.""" -import logging - -from .interface import ServiceInterface -from .validate import SCHEMA_SERVICE_MQTT -from ..const import ATTR_ADDON, SERVICE_MQTT -from ..exceptions import ServicesError - -_LOGGER = logging.getLogger(__name__) - - -class MQTTService(ServiceInterface): - """Provide MQTT services.""" - - @property - def slug(self): - """Return slug of this service.""" - return SERVICE_MQTT - - @property - def _data(self): - """Return data of this service.""" - return self.sys_services.data.mqtt - - @property - def schema(self): - """Return data schema of this service.""" - return SCHEMA_SERVICE_MQTT - - def set_service_data(self, addon, data): - """Write the data into service object.""" - if self.enabled: - _LOGGER.error( - "It is already a MQTT in use from %s", self._data[ATTR_ADDON]) - raise ServicesError() - - self._data.update(data) - self._data[ATTR_ADDON] = addon.slug - - _LOGGER.info("Set %s as service provider for mqtt", addon.slug) - self.save() - - def del_service_data(self, addon): - """Remove the data from service object.""" - if not self.enabled: - _LOGGER.warning("Can't remove not exists services") - raise ServicesError() - - self._data.clear() - self.save() diff --git a/hassio/services/validate.py b/hassio/services/validate.py index a7217be90..ff30217f1 100644 --- a/hassio/services/validate.py +++ b/hassio/services/validate.py @@ -1,35 +1,12 @@ """Validate services schema.""" import voluptuous as vol -from ..const import ( - SERVICE_MQTT, ATTR_HOST, ATTR_PORT, ATTR_PASSWORD, ATTR_USERNAME, ATTR_SSL, - ATTR_ADDON, ATTR_PROTOCOL) -from ..validate import NETWORK_PORT from ..utils.validate import schema_or +from .const import SERVICE_MQTT +from .modules.mqtt import SCHEMA_CONFIG_MQTT -# pylint: disable=no-value-for-parameter -SCHEMA_SERVICE_MQTT = vol.Schema({ - vol.Required(ATTR_HOST): vol.Coerce(str), - vol.Required(ATTR_PORT): NETWORK_PORT, - vol.Optional(ATTR_USERNAME): vol.Coerce(str), - vol.Optional(ATTR_PASSWORD): vol.Coerce(str), - vol.Optional(ATTR_SSL, default=False): vol.Boolean(), - vol.Optional(ATTR_PROTOCOL, default='3.1.1'): - vol.All(vol.Coerce(str), vol.In(['3.1', '3.1.1'])), -}) - - -SCHEMA_CONFIG_MQTT = SCHEMA_SERVICE_MQTT.extend({ - vol.Required(ATTR_ADDON): vol.Coerce(str), -}) - - -SCHEMA_SERVICES_CONFIG = vol.Schema({ - vol.Optional(SERVICE_MQTT, default=dict): schema_or(SCHEMA_CONFIG_MQTT), -}, extra=vol.REMOVE_EXTRA) - - -DISCOVERY_SERVICES = { - SERVICE_MQTT: SCHEMA_SERVICE_MQTT, -} +SCHEMA_SERVICES_CONFIG = vol.Schema( + {vol.Optional(SERVICE_MQTT, default=dict): schema_or(SCHEMA_CONFIG_MQTT)}, + extra=vol.REMOVE_EXTRA, +) diff --git a/hassio/tasks.py b/hassio/tasks.py index 0752af7fd..1679e42ba 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -94,7 +94,7 @@ class Tasks(CoreSysAttributes): async def _watchdog_homeassistant_docker(self): """Check running state of Docker and start if they is close.""" # if Home Assistant is active - if not await self.sys_homeassistant.is_initialize() or \ + if not await self.sys_homeassistant.is_fails() or \ not self.sys_homeassistant.watchdog or \ self.sys_homeassistant.error_state: return @@ -117,7 +117,7 @@ class Tasks(CoreSysAttributes): a delay in our system. """ # If Home-Assistant is active - if not await self.sys_homeassistant.is_initialize() or \ + if not await self.sys_homeassistant.is_fails() or \ not self.sys_homeassistant.watchdog or \ self.sys_homeassistant.error_state: return diff --git a/hassio/validate.py b/hassio/validate.py index f5a9fdd50..c6189323f 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -5,14 +5,30 @@ import re import voluptuous as vol from .const import ( - ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE, ATTR_HASSOS, - ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO, - ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, ATTR_CONFIG, - ATTR_WAIT_BOOT, ATTR_UUID, ATTR_REFRESH_TOKEN, ATTR_HASSOS_CLI, - ATTR_ACCESS_TOKEN, ATTR_DISCOVERY, ATTR_ADDON, ATTR_SERVICE, - SERVICE_MQTT, - CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV) -from .utils.validate import schema_or, validate_timezone + ATTR_IMAGE, + ATTR_LAST_VERSION, + ATTR_CHANNEL, + ATTR_TIMEZONE, + ATTR_HASSOS, + ATTR_ADDONS_CUSTOM_LIST, + ATTR_PASSWORD, + ATTR_HOMEASSISTANT, + ATTR_HASSIO, + ATTR_BOOT, + ATTR_LAST_BOOT, + ATTR_SSL, + ATTR_PORT, + ATTR_WATCHDOG, + ATTR_WAIT_BOOT, + ATTR_UUID, + ATTR_REFRESH_TOKEN, + ATTR_HASSOS_CLI, + ATTR_ACCESS_TOKEN, + CHANNEL_STABLE, + CHANNEL_BETA, + CHANNEL_DEV, +) +from .utils.validate import validate_timezone RE_REPOSITORY = re.compile(r"^(?P[^#]+)(?:#(?P[\w\-]+))?$") @@ -24,7 +40,6 @@ ALSA_DEVICE = vol.Maybe(vol.Match(r"\d+,\d+")) CHANNELS = vol.In([CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV]) UUID_MATCH = vol.Match(r"^[0-9a-f]{32}$") SHA256 = vol.Match(r"^[0-9a-f]{64}$") -SERVICE_ALL = vol.In([SERVICE_MQTT]) def validate_repository(repository): @@ -35,7 +50,7 @@ def validate_repository(repository): # Validate URL # pylint: disable=no-value-for-parameter - vol.Url()(data.group('url')) + vol.Url()(data.group("url")) return repository @@ -66,63 +81,61 @@ def convert_to_docker_ports(data): raise vol.Invalid("Can't validate Docker host settings") -DOCKER_PORTS = vol.Schema({ - vol.All(vol.Coerce(str), vol.Match(r"^\d+(?:/tcp|/udp)?$")): - convert_to_docker_ports, -}) +DOCKER_PORTS = vol.Schema( + { + vol.All( + vol.Coerce(str), vol.Match(r"^\d+(?:/tcp|/udp)?$") + ): convert_to_docker_ports + } +) # pylint: disable=no-value-for-parameter -SCHEMA_HASS_CONFIG = vol.Schema({ - vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): UUID_MATCH, - vol.Optional(ATTR_ACCESS_TOKEN): SHA256, - vol.Optional(ATTR_BOOT, default=True): vol.Boolean(), - vol.Inclusive(ATTR_IMAGE, 'custom_hass'): DOCKER_IMAGE, - vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str), - vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT, - vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)), - vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)), - vol.Optional(ATTR_SSL, default=False): vol.Boolean(), - vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(), - vol.Optional(ATTR_WAIT_BOOT, default=600): - vol.All(vol.Coerce(int), vol.Range(min=60)), -}, extra=vol.REMOVE_EXTRA) +SCHEMA_HASS_CONFIG = vol.Schema( + { + vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): UUID_MATCH, + vol.Optional(ATTR_ACCESS_TOKEN): SHA256, + vol.Optional(ATTR_BOOT, default=True): vol.Boolean(), + vol.Inclusive(ATTR_IMAGE, "custom_hass"): DOCKER_IMAGE, + vol.Inclusive(ATTR_LAST_VERSION, "custom_hass"): vol.Coerce(str), + vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT, + vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_SSL, default=False): vol.Boolean(), + vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(), + vol.Optional(ATTR_WAIT_BOOT, default=600): vol.All( + vol.Coerce(int), vol.Range(min=60) + ), + }, + extra=vol.REMOVE_EXTRA, +) -SCHEMA_UPDATER_CONFIG = vol.Schema({ - vol.Optional(ATTR_CHANNEL, default=CHANNEL_STABLE): CHANNELS, - vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str), - vol.Optional(ATTR_HASSIO): vol.Coerce(str), - vol.Optional(ATTR_HASSOS): vol.Coerce(str), - vol.Optional(ATTR_HASSOS_CLI): vol.Coerce(str), -}, extra=vol.REMOVE_EXTRA) +SCHEMA_UPDATER_CONFIG = vol.Schema( + { + vol.Optional(ATTR_CHANNEL, default=CHANNEL_STABLE): CHANNELS, + vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str), + vol.Optional(ATTR_HASSIO): vol.Coerce(str), + vol.Optional(ATTR_HASSOS): vol.Coerce(str), + vol.Optional(ATTR_HASSOS_CLI): vol.Coerce(str), + }, + extra=vol.REMOVE_EXTRA, +) # pylint: disable=no-value-for-parameter -SCHEMA_HASSIO_CONFIG = vol.Schema({ - vol.Optional(ATTR_TIMEZONE, default='UTC'): validate_timezone, - vol.Optional(ATTR_LAST_BOOT): vol.Coerce(str), - vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[ - "https://github.com/hassio-addons/repository", - ]): REPOSITORIES, - vol.Optional(ATTR_WAIT_BOOT, default=5): WAIT_BOOT, -}, extra=vol.REMOVE_EXTRA) +SCHEMA_HASSIO_CONFIG = vol.Schema( + { + vol.Optional(ATTR_TIMEZONE, default="UTC"): validate_timezone, + vol.Optional(ATTR_LAST_BOOT): vol.Coerce(str), + vol.Optional( + ATTR_ADDONS_CUSTOM_LIST, + default=["https://github.com/hassio-addons/repository"], + ): REPOSITORIES, + vol.Optional(ATTR_WAIT_BOOT, default=5): WAIT_BOOT, + }, + extra=vol.REMOVE_EXTRA, +) -SCHEMA_DISCOVERY = vol.Schema([ - vol.Schema({ - vol.Required(ATTR_UUID): UUID_MATCH, - vol.Required(ATTR_ADDON): vol.Coerce(str), - vol.Required(ATTR_SERVICE): SERVICE_ALL, - vol.Required(ATTR_CONFIG): vol.Maybe(dict), - }, extra=vol.REMOVE_EXTRA) -]) - -SCHEMA_DISCOVERY_CONFIG = vol.Schema({ - vol.Optional(ATTR_DISCOVERY, default=list): schema_or(SCHEMA_DISCOVERY), -}, extra=vol.REMOVE_EXTRA) - - -SCHEMA_AUTH_CONFIG = vol.Schema({ - SHA256: SHA256 -}) +SCHEMA_AUTH_CONFIG = vol.Schema({SHA256: SHA256}) diff --git a/requirements.txt b/requirements.txt index c051a074c..6fc863a39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ attrs==18.2.0 cchardet==2.1.4 colorlog==4.0.2 cpe==1.2.1 -cryptography==2.5 +cryptography==2.6.1 docker==3.7.0 gitpython==2.1.11 pytz==2018.9 diff --git a/tests/discovery/__init__.py b/tests/discovery/__init__.py new file mode 100644 index 000000000..ce066fb26 --- /dev/null +++ b/tests/discovery/__init__.py @@ -0,0 +1 @@ +"""Tests for discovery.""" diff --git a/tests/discovery/test_deconz.py b/tests/discovery/test_deconz.py new file mode 100644 index 000000000..092cd35cd --- /dev/null +++ b/tests/discovery/test_deconz.py @@ -0,0 +1,19 @@ +"""Test DeConz discovery.""" + +import voluptuous as vol +import pytest + +from hassio.discovery.validate import valid_discovery_config + + +def test_good_config(): + """Test good deconz config.""" + + valid_discovery_config("deconz", {"host": "test", "port": 3812}) + + +def test_bad_config(): + """Test good deconz config.""" + + with pytest.raises(vol.Invalid): + valid_discovery_config("deconz", {"host": "test"}) diff --git a/tests/discovery/test_mqtt.py b/tests/discovery/test_mqtt.py new file mode 100644 index 000000000..06fa9a39a --- /dev/null +++ b/tests/discovery/test_mqtt.py @@ -0,0 +1,21 @@ +"""Test MQTT discovery.""" + +import voluptuous as vol +import pytest + +from hassio.discovery.validate import valid_discovery_config + + +def test_good_config(): + """Test good mqtt config.""" + + valid_discovery_config( + "mqtt", {"host": "test", "port": 3812, "username": "bla", "ssl": True} + ) + + +def test_bad_config(): + """Test good mqtt config.""" + + with pytest.raises(vol.Invalid): + valid_discovery_config("mqtt", {"host": "test", "username": "bla", "ssl": True}) diff --git a/tests/discovery/test_validate.py b/tests/discovery/test_validate.py new file mode 100644 index 000000000..0aa6f7938 --- /dev/null +++ b/tests/discovery/test_validate.py @@ -0,0 +1,21 @@ +"""Test validate of discovery.""" + +import voluptuous as vol +import pytest + +from hassio.discovery import validate + + +def test_valid_services(): + """Validate that service is valid.""" + + for service in ("mqtt", "deconz"): + validate.valid_discovery_service(service) + + +def test_invalid_services(): + """Test that validate is invalid for a service.""" + + for service in ("fadsfasd", "203432"): + with pytest.raises(vol.Invalid): + validate.valid_discovery_service(service)