From b50756785eddd3d46b07c63185d026e1d1932caa Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 8 Feb 2018 17:19:47 +0100 Subject: [PATCH] Add support to expose internal services (#339) * Init services discovery * extend it * Add mqtt provider * Service support * More protocol stuff * Update validate.py * Update validate.py * Update API.md * Update API.md * update api * add API for services * fix lint * add security middleware * Add discovery layout * update * Finish discovery * improve discovery * fix * Update API * Update api * fix * Fix lint * Update API.md * Update __init__.py * Update API.md * Update interface.py * Update mqtt.py * Update discovery.py * Update const.py * Update validate.py * Update validate.py * Update mqtt.py * Update mqtt.py * Update discovery.py * Update discovery.py * Update discovery.py * Update interface.py * Update mqtt.py * Update mqtt.py * Update services.py * Update discovery.py * Update discovery.py * Update mqtt.py * Update discovery.py * Update services.py * Update discovery.py * Update discovery.py * Update mqtt.py * Update discovery.py * fix aiohttp * test * Update const.py * Update addon.py * Update homeassistant.py * Update const.py * Update addon.py * Update homeassistant.py * Update addon.py * Update security.py * Update const.py * Update validate.py * Update const.py * Update addon.py * Update API.md * Update addons.py * Update addon.py * Update validate.py * Update security.py * Update security.py * Update const.py * Update services.py * Update discovery.py * Update API.md * Update services.py * Update API.md * Update services.py * Update discovery.py * Update discovery.py * Update mqtt.py * Update discovery.py * Update discovery.py * Update __init__.py * Update mqtt.py * Update security.py * fix lint * Update core.py * Update API.md * Update services.py --- API.md | 102 ++++++++++++++++++++++++++++++- hassio/addons/__init__.py | 4 +- hassio/addons/addon.py | 24 +++++++- hassio/addons/data.py | 6 +- hassio/addons/validate.py | 8 ++- hassio/api/__init__.py | 37 +++++++++++- hassio/api/addons.py | 5 +- hassio/api/discovery.py | 72 ++++++++++++++++++++++ hassio/api/security.py | 34 +++++++++++ hassio/api/services.py | 55 +++++++++++++++++ hassio/bootstrap.py | 6 +- hassio/const.py | 21 +++++++ hassio/core.py | 7 +++ hassio/coresys.py | 19 +++++- hassio/docker/addon.py | 12 ++-- hassio/docker/homeassistant.py | 5 +- hassio/homeassistant.py | 25 ++++++++ hassio/services/__init__.py | 45 ++++++++++++++ hassio/services/data.py | 23 +++++++ hassio/services/discovery.py | 107 +++++++++++++++++++++++++++++++++ hassio/services/interface.py | 54 +++++++++++++++++ hassio/services/mqtt.py | 89 +++++++++++++++++++++++++++ hassio/services/validate.py | 44 ++++++++++++++ hassio/snapshots/__init__.py | 2 +- hassio/utils/json.py | 10 ++- 25 files changed, 789 insertions(+), 27 deletions(-) create mode 100644 hassio/api/discovery.py create mode 100644 hassio/api/security.py create mode 100644 hassio/api/services.py create mode 100644 hassio/services/__init__.py create mode 100644 hassio/services/data.py create mode 100644 hassio/services/discovery.py create mode 100644 hassio/services/interface.py create mode 100644 hassio/services/mqtt.py create mode 100644 hassio/services/validate.py diff --git a/API.md b/API.md index eb0af881e..79b69c953 100644 --- a/API.md +++ b/API.md @@ -398,7 +398,9 @@ Get all available addons. "gpio": "bool", "audio": "bool", "audio_input": "null|0,0", - "audio_output": "null|0,0" + "audio_output": "null|0,0", + "services": "null|['mqtt']", + "discovery": "null|['component/platform']" } ``` @@ -462,6 +464,104 @@ Write data to add-on stdin } ``` +### Service discovery + +- GET `/services/discovery` +```json +{ + "discovery": [ + { + "provider": "name", + "uuid": "uuid", + "component": "component", + "platform": "null|platform", + "config": {} + } + ] +} +``` + +- GET `/services/discovery/{UUID}` +```json +{ + "provider": "name", + "uuid": "uuid", + "component": "component", + "platform": "null|platform", + "config": {} +} +``` + +- POST `/services/discovery` +```json +{ + "component": "component", + "platform": "null|platform", + "config": {} +} +``` + +return: +```json +{ + "uuid": "uuid" +} +``` + +- DEL `/services/discovery/{UUID}` + +- GET `/services` +```json +{ + "services": [ + { + "slug": "name", + "available": "bool", + "provider": "null|name|list" + } + ] +} +``` + +- GET `/services/xy` +```json +{ + "available": "bool", + "xy": {} +} +``` + +#### MQTT + +This service perform a auto discovery to Home-Assistant. + +- GET `/services/mqtt` +```json +{ + "provider": "name", + "host": "xy", + "port": "8883", + "ssl": "bool", + "username": "optional", + "password": "optional", + "protocol": "3.1.1" +} +``` + +- POST `/services/mqtt` +```json +{ + "host": "xy", + "port": "8883", + "ssl": "bool|optional", + "username": "optional", + "password": "optional", + "protocol": "3.1.1" +} +``` + +- DEL `/services/mqtt` + ## Host Control Communicate over UNIX socket with a host daemon. diff --git a/hassio/addons/__init__.py b/hassio/addons/__init__.py index afc77ba37..90cbeaf7c 100644 --- a/hassio/addons/__init__.py +++ b/hassio/addons/__init__.py @@ -4,7 +4,7 @@ import logging from .addon import Addon from .repository import Repository -from .data import Data +from .data import AddonsData from ..const import REPOSITORY_CORE, REPOSITORY_LOCAL, BOOT_AUTO from ..coresys import CoreSysAttributes @@ -19,7 +19,7 @@ class AddonManager(CoreSysAttributes): def __init__(self, coresys): """Initialize docker base wrapper.""" self.coresys = coresys - self.data = Data(coresys) + self.data = AddonsData(coresys) self.addons_obj = {} self.repositories_obj = {} diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 5208f52c8..4fc2715c6 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -12,7 +12,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error from .validate import ( - validate_options, SCHEMA_ADDON_SNAPSHOT, RE_VOLUME) + validate_options, SCHEMA_ADDON_SNAPSHOT, RE_VOLUME, RE_SERVICE) from .utils import check_installed from ..const import ( ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_BOOT, ATTR_MAP, @@ -23,7 +23,7 @@ from ..const import ( ATTR_STATE, ATTR_TIMEOUT, ATTR_AUTO_UPDATE, ATTR_NETWORK, ATTR_WEBUI, ATTR_HASSIO_API, ATTR_AUDIO, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_IPC, - ATTR_HOST_DBUS, ATTR_AUTO_UART) + ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_DISCOVERY, ATTR_SERVICES) from ..coresys import CoreSysAttributes from ..docker.addon import DockerAddon from ..utils.json import write_json_file, read_json_file @@ -201,6 +201,26 @@ class Addon(CoreSysAttributes): """Return startup type of addon.""" return self._mesh.get(ATTR_STARTUP) + @property + def services(self): + """Return dict of services with rights.""" + raw_services = self._mesh.get(ATTR_SERVICES) + if not raw_services: + return None + + formated_services = {} + for data in raw_services: + service = RE_SERVICE.match(data) + formated_services[service.group('service')] = \ + service.group('rights') or 'ro' + + return formated_services + + @property + def discovery(self): + """Return list of discoverable components/platforms.""" + return self._mesh.get(ATTR_DISCOVERY) + @property def ports(self): """Return ports of addon.""" diff --git a/hassio/addons/data.py b/hassio/addons/data.py index 1fe362fd9..777d2b025 100644 --- a/hassio/addons/data.py +++ b/hassio/addons/data.py @@ -9,7 +9,7 @@ from voluptuous.humanize import humanize_error from .utils import extract_hash_from_path from .validate import ( - SCHEMA_ADDON_CONFIG, SCHEMA_ADDON_FILE, SCHEMA_REPOSITORY_CONFIG) + SCHEMA_ADDON_CONFIG, SCHEMA_ADDONS_FILE, SCHEMA_REPOSITORY_CONFIG) from ..const import ( FILE_HASSIO_ADDONS, ATTR_VERSION, ATTR_SLUG, ATTR_REPOSITORY, ATTR_LOCATON, REPOSITORY_CORE, REPOSITORY_LOCAL, ATTR_USER, ATTR_SYSTEM) @@ -19,12 +19,12 @@ from ..utils.json import JsonConfig, read_json_file _LOGGER = logging.getLogger(__name__) -class Data(JsonConfig, CoreSysAttributes): +class AddonsData(JsonConfig, CoreSysAttributes): """Hold data for addons inside HassIO.""" def __init__(self, coresys): """Initialize data holder.""" - super().__init__(FILE_HASSIO_ADDONS, SCHEMA_ADDON_FILE) + super().__init__(FILE_HASSIO_ADDONS, SCHEMA_ADDONS_FILE) self.coresys = coresys self._repositories = {} self._cache = {} diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index e60ce5211..9298098bf 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -17,13 +17,15 @@ from ..const import ( ATTR_AUTO_UPDATE, ATTR_WEBUI, ATTR_AUDIO, ATTR_AUDIO_INPUT, ATTR_HOST_IPC, ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API, ATTR_BUILD_FROM, ATTR_SQUASH, ATTR_ARGS, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, - ATTR_HOST_DBUS, ATTR_AUTO_UART) + ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY) from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_CHANNEL _LOGGER = logging.getLogger(__name__) RE_VOLUME = re.compile(r"^(config|ssl|addons|backup|share)(?::(rw|:ro))?$") +RE_SERVICE = re.compile(r"^(?Pmqtt)(?::(?Prw|:ro))?$") +RE_DISCOVERY = re.compile(r"^(?P\w*)(?:/(?P\w*>))?$") V_STR = 'str' V_INT = 'int' @@ -110,6 +112,8 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ vol.Optional(ATTR_HOMEASSISTANT_API, default=False): vol.Boolean(), vol.Optional(ATTR_STDIN, default=False): vol.Boolean(), vol.Optional(ATTR_LEGACY, default=False): vol.Boolean(), + vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)], + vol.Optional(ATTR_DISCOVERY): [vol.Match(RE_DISCOVERY)], vol.Required(ATTR_OPTIONS): dict, vol.Required(ATTR_SCHEMA): vol.Any(vol.Schema({ vol.Coerce(str): vol.Any(SCHEMA_ELEMENT, [ @@ -168,7 +172,7 @@ SCHEMA_ADDON_SYSTEM = SCHEMA_ADDON_CONFIG.extend({ }) -SCHEMA_ADDON_FILE = vol.Schema({ +SCHEMA_ADDONS_FILE = vol.Schema({ vol.Optional(ATTR_USER, default=dict): { vol.Coerce(str): SCHEMA_ADDON_USER, }, diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 351c1f4d2..d5e2d6e4a 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -5,12 +5,15 @@ from pathlib import Path from aiohttp import web from .addons import APIAddons +from .discovery import APIDiscovery from .homeassistant import APIHomeAssistant from .host import APIHost from .network import APINetwork from .proxy import APIProxy from .supervisor import APISupervisor from .snapshots import APISnapshots +from .services import APIServices +from .security import security_layer from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) @@ -22,12 +25,16 @@ class RestAPI(CoreSysAttributes): def __init__(self, coresys): """Initialize docker base wrapper.""" self.coresys = coresys - self.webapp = web.Application(loop=self._loop) + self.webapp = web.Application( + middlewares=[security_layer], loop=self._loop) # service stuff self._handler = None self.server = None + # middleware + self.webapp['coresys'] = coresys + async def load(self): """Register REST API Calls.""" self._register_supervisor() @@ -38,6 +45,8 @@ class RestAPI(CoreSysAttributes): self._register_addons() self._register_snapshots() self._register_network() + self._register_discovery() + self._register_services() def _register_host(self): """Register hostcontrol function.""" @@ -162,6 +171,32 @@ class RestAPI(CoreSysAttributes): '/snapshots/{snapshot}/restore/partial', api_snapshots.restore_partial) + def _register_services(self): + api_services = APIServices() + api_services.coresys = self.coresys + + self.webapp.router.add_get('/services', api_services.list) + + self.webapp.router.add_get( + '/services/{service}', api_services.get_service) + self.webapp.router.add_post( + '/services/{service}', api_services.set_service) + self.webapp.router.add_delete( + '/services/{service}', api_services.del_service) + + def _register_discovery(self): + api_discovery = APIDiscovery() + api_discovery.coresys = self.coresys + + self.webapp.router.add_get( + '/services/discovery', api_discovery.list) + self.webapp.router.add_get( + '/services/discovery/{uuid}', api_discovery.get_discovery) + self.webapp.router.add_delete( + '/services/discovery/{uuid}', api_discovery.del_discovery) + self.webapp.router.add_post( + '/services/discovery', api_discovery.set_discovery) + def _register_panel(self): """Register panel for homeassistant.""" def create_panel_response(build_type): diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 2884da0c3..b1909cbd3 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -16,7 +16,8 @@ from ..const import ( ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, BOOT_AUTO, BOOT_MANUAL, ATTR_CHANGELOG, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_LONG_DESCRIPTION, ATTR_CPU_PERCENT, ATTR_MEMORY_LIMIT, ATTR_MEMORY_USAGE, ATTR_NETWORK_TX, - ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, + ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, ATTR_SERVICES, + ATTR_DISCOVERY, CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT) from ..coresys import CoreSysAttributes from ..validate import DOCKER_PORTS @@ -134,6 +135,8 @@ class APIAddons(CoreSysAttributes): ATTR_AUDIO: addon.with_audio, ATTR_AUDIO_INPUT: addon.audio_input, ATTR_AUDIO_OUTPUT: addon.audio_output, + ATTR_SERVICES: addon.services, + ATTR_DISCOVERY: addon.discovery, } @api_process diff --git a/hassio/api/discovery.py b/hassio/api/discovery.py new file mode 100644 index 000000000..fb028fcd9 --- /dev/null +++ b/hassio/api/discovery.py @@ -0,0 +1,72 @@ +"""Init file for HassIO network rest api.""" + +import voluptuous as vol + +from .utils import api_process, api_validate +from ..const import ( + ATTR_PROVIDER, ATTR_UUID, ATTR_COMPONENT, ATTR_PLATFORM, ATTR_CONFIG, + ATTR_DISCOVERY, REQUEST_FROM) +from ..coresys import CoreSysAttributes + + +SCHEMA_DISCOVERY = vol.Schema({ + vol.Required(ATTR_COMPONENT): vol.Coerce(str), + vol.Optional(ATTR_PLATFORM): vol.Any(None, vol.Coerce(str)), + vol.Optional(ATTR_CONFIG): vol.Any(None, dict), +}) + + +class APIDiscovery(CoreSysAttributes): + """Handle rest api for discovery functions.""" + + def _extract_message(self, request): + """Extract discovery message from URL.""" + message = self._services.discovery.get(request.match_info.get('uuid')) + if not message: + raise RuntimeError("Discovery message not found") + return message + + @api_process + async def list(self, request): + """Show register services.""" + discovery = [] + for message in self._services.discovery.list_messages: + discovery.append({ + ATTR_PROVIDER: message.provider, + ATTR_UUID: message.uuid, + ATTR_COMPONENT: message.component, + ATTR_PLATFORM: message.platform, + ATTR_CONFIG: message.config, + }) + + return {ATTR_DISCOVERY: discovery} + + @api_process + async def set_discovery(self, request): + """Write data into a discovery pipeline.""" + body = await api_validate(SCHEMA_DISCOVERY, request) + message = self._services.discovery.send( + provider=request[REQUEST_FROM], **body) + + return {ATTR_UUID: message.uuid} + + @api_process + async def get_discovery(self, request): + """Read data into a discovery message.""" + message = self._extract_message(request) + + return { + ATTR_PROVIDER: message.provider, + ATTR_UUID: message.uuid, + ATTR_COMPONENT: message.component, + ATTR_PLATFORM: message.platform, + ATTR_CONFIG: message.config, + } + + @api_process + async def del_discovery(self, request): + """Delete data into a discovery message.""" + message = self._extract_message(request) + + self._services.discovery.remove(message) + return True diff --git a/hassio/api/security.py b/hassio/api/security.py new file mode 100644 index 000000000..8de4cd3be --- /dev/null +++ b/hassio/api/security.py @@ -0,0 +1,34 @@ +"""Handle security part of this API.""" +import logging + +from aiohttp.web import middleware + +from ..const import HEADER_TOKEN, REQUEST_FROM + +_LOGGER = logging.getLogger(__name__) + + +@middleware +async def security_layer(request, handler): + """Check security access of this layer.""" + coresys = request.app['coresys'] + hassio_token = request.headers.get(HEADER_TOKEN) + + # Need to be removed later + if not hassio_token: + _LOGGER.warning("No valid hassio token for API access!") + request[REQUEST_FROM] = 'UNKNOWN' + + # From Home-Assistant + elif hassio_token == coresys.homeassistant.uuid: + request[REQUEST_FROM] = 'homeassistant' + + # From Add-on + else: + for addon in coresys.addons.list_addons: + if hassio_token != addon.uuid: + continue + request[REQUEST_FROM] = addon.slug + break + + return await handler(request) diff --git a/hassio/api/services.py b/hassio/api/services.py new file mode 100644 index 000000000..9d3e0b651 --- /dev/null +++ b/hassio/api/services.py @@ -0,0 +1,55 @@ +"""Init file for HassIO network rest api.""" + +from .utils import api_process, api_validate +from ..const import ( + ATTR_AVAILABLE, ATTR_PROVIDER, ATTR_SLUG, ATTR_SERVICES, REQUEST_FROM) +from ..coresys import CoreSysAttributes + + +class APIServices(CoreSysAttributes): + """Handle rest api for services functions.""" + + def _extract_service(self, request): + """Return service and if not exists trow a exception.""" + service = self._services.get(request.match_info.get('service')) + if not service: + raise RuntimeError("Service not exists") + + return service + + @api_process + async def list(self, request): + """Show register services.""" + services = [] + for service in self._services.list_services: + services.append({ + ATTR_SLUG: service.slug, + ATTR_AVAILABLE: service.enabled, + ATTR_PROVIDER: service.provider, + }) + + return {ATTR_SERVICES: services} + + @api_process + async def set_service(self, request): + """Write data into a service.""" + service = self._extract_service(request) + body = await api_validate(service.schema, request) + + return service.set_service_data(request[REQUEST_FROM], body) + + @api_process + async def get_service(self, request): + """Read data into a service.""" + service = self._extract_service(request) + + return { + ATTR_AVAILABLE: service.enabled, + service.slug: service.get_service_data(), + } + + @api_process + async def del_service(self, request): + """Delete data into a service.""" + service = self._extract_service(request) + return service.del_service_data(request[REQUEST_FROM]) diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index d4a839194..82ece0f13 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -13,9 +13,10 @@ from .const import SOCKET_DOCKER from .coresys import CoreSys from .supervisor import Supervisor from .homeassistant import HomeAssistant -from .snapshots import SnapshotsManager +from .snapshots import SnapshotManager from .tasks import Tasks from .updater import Updater +from .services import ServiceManager _LOGGER = logging.getLogger(__name__) @@ -30,8 +31,9 @@ def initialize_coresys(loop): coresys.supervisor = Supervisor(coresys) coresys.homeassistant = HomeAssistant(coresys) coresys.addons = AddonManager(coresys) - coresys.snapshots = SnapshotsManager(coresys) + coresys.snapshots = SnapshotManager(coresys) coresys.tasks = Tasks(coresys) + coresys.services = ServiceManager(coresys) # bootstrap config initialize_system_data(coresys) diff --git a/hassio/const.py b/hassio/const.py index 948df314a..5e86e6dae 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -15,6 +15,7 @@ FILE_HASSIO_ADDONS = Path(HASSIO_DATA, "addons.json") FILE_HASSIO_CONFIG = Path(HASSIO_DATA, "config.json") FILE_HASSIO_HOMEASSISTANT = Path(HASSIO_DATA, "homeassistant.json") FILE_HASSIO_UPDATER = Path(HASSIO_DATA, "updater.json") +FILE_HASSIO_SERVICES = Path(HASSIO_DATA, "services.json") SOCKET_DOCKER = Path("/var/run/docker.sock") SOCKET_HC = Path("/var/run/hassio-hc.sock") @@ -43,6 +44,12 @@ CONTENT_TYPE_PNG = 'image/png' CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_TEXT = 'text/plain' HEADER_HA_ACCESS = 'x-ha-access' +HEADER_TOKEN = 'X-HASSIO-KEY' + +ENV_TOKEN = 'HASSIO_TOKEN' +ENV_TIME = 'TZ' + +REQUEST_FROM = 'HASSIO_FROM' ATTR_WAIT_BOOT = 'wait_boot' ATTR_WATCHDOG = 'watchdog' @@ -136,6 +143,20 @@ ATTR_MEMORY_LIMIT = 'memory_limit' ATTR_MEMORY_USAGE = 'memory_usage' ATTR_BLK_READ = 'blk_read' ATTR_BLK_WRITE = 'blk_write' +ATTR_PROVIDER = 'provider' +ATTR_AVAILABLE = 'available' +ATTR_HOST = 'host' +ATTR_USERNAME = 'username' +ATTR_PROTOCOL = 'protocol' +ATTR_DISCOVERY = 'discovery' +ATTR_PLATFORM = 'platform' +ATTR_COMPONENT = 'component' +ATTR_CONFIG = 'config' +ATTR_DISCOVERY_ID = 'discovery_id' +ATTR_SERVICES = 'services' +ATTR_DISCOVERY = 'discovery' + +SERVICE_MQTT = 'mqtt' STARTUP_INITIALIZE = 'initialize' STARTUP_SYSTEM = 'system' diff --git a/hassio/core.py b/hassio/core.py index b947c1ade..613128499 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -44,6 +44,9 @@ class HassIO(CoreSysAttributes): # load last available data await self._snapshots.load() + # load services + await self._services.load() + # start dns forwarding self._loop.create_task(self._dns.start()) @@ -70,6 +73,9 @@ class HassIO(CoreSysAttributes): _LOGGER.info("Hass.io reboot detected") return + # reset register services / discovery + self._services.reset() + # start addon mark as system await self._addons.auto_boot(STARTUP_SYSTEM) @@ -85,6 +91,7 @@ class HassIO(CoreSysAttributes): # store new last boot self._config.last_boot = self._hardware.last_boot + self._config.save_data() finally: # Add core tasks into scheduler diff --git a/hassio/coresys.py b/hassio/coresys.py index 782fa3480..8fda0bf0e 100644 --- a/hassio/coresys.py +++ b/hassio/coresys.py @@ -40,6 +40,7 @@ class CoreSys(object): self._updater = None self._snapshots = None self._tasks = None + self._services = None @property def arch(self): @@ -155,19 +156,19 @@ class CoreSys(object): @property def snapshots(self): - """Return SnapshotsManager object.""" + """Return SnapshotManager object.""" return self._snapshots @snapshots.setter def snapshots(self, value): - """Set a SnapshotsManager object.""" + """Set a SnapshotManager object.""" if self._snapshots: raise RuntimeError("SnapshotsManager already set!") self._snapshots = value @property def tasks(self): - """Return SnapshotsManager object.""" + """Return Tasks object.""" return self._tasks @tasks.setter @@ -177,6 +178,18 @@ class CoreSys(object): raise RuntimeError("Tasks already set!") self._tasks = value + @property + def services(self): + """Return ServiceManager object.""" + return self._services + + @services.setter + def services(self, value): + """Set a ServiceManager object.""" + if self._services: + raise RuntimeError("Services already set!") + self._services = value + class CoreSysAttributes(object): """Inheret basic CoreSysAttributes.""" diff --git a/hassio/docker/addon.py b/hassio/docker/addon.py index 26df96c8f..759beff32 100644 --- a/hassio/docker/addon.py +++ b/hassio/docker/addon.py @@ -9,7 +9,8 @@ from .interface import DockerInterface from .utils import docker_process from ..addons.build import AddonBuild from ..const import ( - MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP, MAP_SHARE) + MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP, MAP_SHARE, ENV_TOKEN, + ENV_TIME) _LOGGER = logging.getLogger(__name__) @@ -74,19 +75,18 @@ class DockerAddon(DockerInterface): def environment(self): """Return environment for docker add-on.""" addon_env = self.addon.environment or {} + + # Need audio settings if self.addon.with_audio: addon_env.update({ 'ALSA_OUTPUT': self.addon.audio_output, 'ALSA_INPUT': self.addon.audio_input, }) - # Set api token if any API access is needed - if self.addon.access_hassio_api or self.addon.access_homeassistant_api: - addon_env['HASSIO_TOKEN'] = self.addon.uuid - return { **addon_env, - 'TZ': self._config.timezone, + ENV_TIME: self._config.timezone, + ENV_TOKEN: self.addon.uuid, } @property diff --git a/hassio/docker/homeassistant.py b/hassio/docker/homeassistant.py index 9fff28172..c35cd679f 100644 --- a/hassio/docker/homeassistant.py +++ b/hassio/docker/homeassistant.py @@ -4,6 +4,7 @@ import logging import docker from .interface import DockerInterface +from ..const import ENV_TOKEN, ENV_TIME _LOGGER = logging.getLogger(__name__) @@ -53,8 +54,8 @@ class DockerHomeAssistant(DockerInterface): network_mode='host', environment={ 'HASSIO': self._docker.network.supervisor, - 'TZ': self._config.timezone, - 'HASSIO_TOKEN': self._homeassistant.uuid, + ENV_TIME: self._config.timezone, + ENV_TOKEN: self._homeassistant.uuid, }, volumes={ str(self._config.path_extern_config): diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index ffc7eccff..85f7bbef6 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -285,3 +285,28 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): if status not in (200, 201): _LOGGER.warning("Home-Assistant API config missmatch") return True + + async def send_event(self, event_type, event_data=None): + """Send event to Home-Assistant.""" + url = f"{self.api_url}/api/events/{event_type}" + header = {CONTENT_TYPE: CONTENT_TYPE_JSON} + + if self.api_password: + header.update({HEADER_HA_ACCESS: self.api_password}) + + try: + # pylint: disable=bad-continuation + async with self._websession_ssl.post( + url, headers=header, timeout=30, + json=event_data) as request: + status = request.status + + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.warning( + "Home-Assistant event %s fails: %s", event_type, err) + return False + + if status not in (200, 201): + _LOGGER.warning("Home-Assistant event %s fails", event_type) + return False + return True diff --git a/hassio/services/__init__.py b/hassio/services/__init__.py new file mode 100644 index 000000000..5f15940a1 --- /dev/null +++ b/hassio/services/__init__.py @@ -0,0 +1,45 @@ +"""Handle internal services discovery.""" + +from .mqtt import MQTTService +from .data import ServicesData +from .discovery import Discovery +from ..const import SERVICE_MQTT +from ..coresys import CoreSysAttributes + + +AVAILABLE_SERVICES = { + SERVICE_MQTT: MQTTService +} + + +class ServiceManager(CoreSysAttributes): + """Handle internal services discovery.""" + + def __init__(self, coresys): + """Initialize Services handler.""" + self.coresys = coresys + self.data = ServicesData() + self.discovery = Discovery(coresys) + self.services_obj = {} + + @property + def list_services(self): + """Return a list of services.""" + return list(self.services_obj.values()) + + def get(self, slug): + """Return service object from slug.""" + return self.services_obj.get(slug) + + async def load(self): + """Load available services.""" + for slug, service in AVAILABLE_SERVICES.items(): + self.services_obj[slug] = service(self.coresys) + + # Read exists discovery messages + self.discovery.load() + + def reset(self): + """Reset available data.""" + self.data.reset_data() + self.discovery.load() diff --git a/hassio/services/data.py b/hassio/services/data.py new file mode 100644 index 000000000..525ff5c46 --- /dev/null +++ b/hassio/services/data.py @@ -0,0 +1,23 @@ +"""Handle service data for persistent supervisor reboot.""" + +from .validate import SCHEMA_SERVICES_FILE +from ..const import FILE_HASSIO_SERVICES, ATTR_DISCOVERY, SERVICE_MQTT +from ..utils.json import JsonConfig + + +class ServicesData(JsonConfig): + """Class to handle services data.""" + + def __init__(self): + """Initialize services data.""" + super().__init__(FILE_HASSIO_SERVICES, SCHEMA_SERVICES_FILE) + + @property + def discovery(self): + """Return discovery data for home-assistant.""" + return self._data[ATTR_DISCOVERY] + + @property + def mqtt(self): + """Return settings for mqtt service.""" + return self._data[SERVICE_MQTT] diff --git a/hassio/services/discovery.py b/hassio/services/discovery.py new file mode 100644 index 000000000..f0166589a --- /dev/null +++ b/hassio/services/discovery.py @@ -0,0 +1,107 @@ +"""Handle discover message for Home-Assistant.""" +import logging +from uuid import uuid4 + +from ..const import ATTR_UUID +from ..coresys import CoreSysAttributes + +_LOGGER = logging.getLogger(__name__) + +EVENT_DISCOVERY_ADD = 'hassio_discovery_add' +EVENT_DISCOVERY_DEL = 'hassio_discovery_del' + + +class Discovery(CoreSysAttributes): + """Home-Assistant Discovery handler.""" + + def __init__(self, coresys): + """Initialize discovery handler.""" + self.coresys = coresys + self.message_obj = {} + + def load(self): + """Load exists discovery message into storage.""" + messages = {} + for message in self._data: + discovery = Message(**message) + messages[discovery.uuid] = discovery + + self.message_obj = messages + + def save(self): + """Write discovery message into data file.""" + messages = [] + for message in self.message_obj.values(): + messages.append(message.raw()) + + self._data.clear() + self._data.extend(messages) + self._services.data.save_data() + + def get(self, uuid): + """Return discovery message.""" + return self.message_obj.get(uuid) + + @property + def _data(self): + """Return discovery data.""" + return self._services.data.discovery + + @property + def list_messages(self): + """Return list of available discovery messages.""" + return self.message_obj.values() + + def send(self, provider, component, platform=None, config=None): + """Send a discovery message to Home-Assistant.""" + message = Message(provider, component, platform, config) + + # Allready exists? + for exists_message in self.message_obj: + if exists_message == message: + _LOGGER.warning("Found douplicate discovery message from %s", + provider) + return exists_message + + _LOGGER.info("Send discovery to Home-Assistant %s/%s from %s", + component, platform, provider) + self.message_obj[message.uuid] = message + self.save() + + # send event to Home-Assistant + self._loop.create_task(self._homeassistant.send_event( + EVENT_DISCOVERY_ADD, {ATTR_UUID: message.uuid})) + + return message + + def remove(self, message): + """Remove a discovery message from Home-Assistant.""" + self.message_obj.pop(message.uuid, None) + self.save() + + # send event to Home-Assistant + self._loop.create_task(self._homeassistant.send_event( + EVENT_DISCOVERY_DEL, {ATTR_UUID: message.uuid})) + + +class Message(object): + """Represent a single Discovery message.""" + + def __init__(self, provider, component, platform, config, uuid=None): + """Initialize discovery message.""" + self.provider = provider + self.component = component + self.platform = platform + self.config = config + self.uuid = uuid or uuid4().hex + + def raw(self): + """Return raw discovery message.""" + return self.__dict__ + + def __eq__(self, other): + """Compare with other message.""" + for attribute in ('provider', 'component', 'platform', 'config'): + if getattr(self, attribute) != getattr(other, attribute): + return False + return True diff --git a/hassio/services/interface.py b/hassio/services/interface.py new file mode 100644 index 000000000..a3e13a387 --- /dev/null +++ b/hassio/services/interface.py @@ -0,0 +1,54 @@ +"""Interface for single service.""" + +from ..coresys import CoreSysAttributes + + +class ServiceInterface(CoreSysAttributes): + """Interface class for service integration.""" + + def __init__(self, coresys): + """Initialize service interface.""" + self.coresys = coresys + + @property + def slug(self): + """Return slug of this service.""" + return None + + @property + def _data(self): + """Return data of this service.""" + return None + + @property + def schema(self): + """Return data schema of this service.""" + return None + + @property + def provider(self): + """Return name of service provider.""" + return None + + @property + def enabled(self): + """Return True if the service is in use.""" + return bool(self._data) + + def save(self): + """Save changes.""" + self._services.data.save_data() + + def get_service_data(self): + """Return the requested service data.""" + if self.enabled: + return self._data + return None + + def set_service_data(self, provider, data): + """Write the data into service object.""" + raise NotImplementedError() + + def del_service_data(self, provider): + """Remove the data from service object.""" + raise NotImplementedError() diff --git a/hassio/services/mqtt.py b/hassio/services/mqtt.py new file mode 100644 index 000000000..fa4436622 --- /dev/null +++ b/hassio/services/mqtt.py @@ -0,0 +1,89 @@ +"""Provide MQTT Service.""" +import logging + +from .interface import ServiceInterface +from .validate import SCHEMA_SERVICE_MQTT +from ..const import ( + ATTR_PROVIDER, SERVICE_MQTT, ATTR_HOST, ATTR_PORT, ATTR_USERNAME, + ATTR_PASSWORD, ATTR_PROTOCOL, ATTR_DISCOVERY_ID) + +_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._services.data.mqtt + + @property + def schema(self): + """Return data schema of this service.""" + return SCHEMA_SERVICE_MQTT + + @property + def provider(self): + """Return name of service provider.""" + return self._data.get(ATTR_PROVIDER) + + @property + def hass_config(self): + """Return Home-Assistant mqtt config.""" + if not self.enabled: + return None + + hass_config = { + 'host': self._data[ATTR_HOST], + 'port': self._data[ATTR_PORT], + 'protocol': self._data[ATTR_PROTOCOL] + } + if ATTR_USERNAME in self._data: + hass_config['user']: self._data[ATTR_USERNAME] + if ATTR_PASSWORD in self._data: + hass_config['password']: self._data[ATTR_PASSWORD] + + return hass_config + + def set_service_data(self, provider, data): + """Write the data into service object.""" + if self.enabled: + _LOGGER.error("It is already a mqtt in use from %s", self.provider) + return False + + self._data.update(data) + self._data[ATTR_PROVIDER] = provider + + if provider == 'homeassistant': + _LOGGER.info("Use mqtt settings from Home-Assistant") + self.save() + return True + + # discover mqtt to homeassistant + message = self._services.discovery.send( + provider, SERVICE_MQTT, None, self.hass_config) + + self._data[ATTR_DISCOVERY_ID] = message.uuid + self.save() + return True + + def del_service_data(self, provider): + """Remove the data from service object.""" + if not self.enabled: + _LOGGER.warning("Can't remove not exists services.") + return False + + discovery_id = self._data.get(ATTR_DISCOVERY_ID) + if discovery_id: + self._services.discovery.remove( + self._services.discovery.get(discovery_id)) + + self._data.clear() + self.save() + return True diff --git a/hassio/services/validate.py b/hassio/services/validate.py new file mode 100644 index 000000000..4a0567d44 --- /dev/null +++ b/hassio/services/validate.py @@ -0,0 +1,44 @@ +"""Validate services schema.""" + +import voluptuous as vol + +from ..const import ( + SERVICE_MQTT, ATTR_HOST, ATTR_PORT, ATTR_PASSWORD, ATTR_USERNAME, ATTR_SSL, + ATTR_PROVIDER, ATTR_PROTOCOL, ATTR_DISCOVERY, ATTR_COMPONENT, ATTR_UUID, + ATTR_PLATFORM, ATTR_CONFIG, ATTR_DISCOVERY_ID) +from ..validate import NETWORK_PORT + + +SCHEMA_DISCOVERY = vol.Schema([ + vol.Schema({ + vol.Required(ATTR_UUID): vol.Match(r"^[0-9a-f]{32}$"), + vol.Required(ATTR_PROVIDER): vol.Coerce(str), + vol.Required(ATTR_COMPONENT): vol.Coerce(str), + vol.Required(ATTR_PLATFORM): vol.Any(None, vol.Coerce(str)), + vol.Required(ATTR_CONFIG): vol.Any(None, dict), + }, extra=vol.REMOVE_EXTRA) +]) + + +# 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_PROVIDER): vol.Coerce(str), + vol.Optional(ATTR_DISCOVERY_ID): vol.Match(r"^[0-9a-f]{32}$"), +}) + + +SCHEMA_SERVICES_FILE = vol.Schema({ + vol.Optional(SERVICE_MQTT, default=dict): vol.Any({}, SCHEMA_CONFIG_MQTT), + vol.Optional(ATTR_DISCOVERY, default=list): vol.Any([], SCHEMA_DISCOVERY), +}, extra=vol.REMOVE_EXTRA) diff --git a/hassio/snapshots/__init__.py b/hassio/snapshots/__init__.py index 28e206ec8..af041b1eb 100644 --- a/hassio/snapshots/__init__.py +++ b/hassio/snapshots/__init__.py @@ -14,7 +14,7 @@ from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) -class SnapshotsManager(CoreSysAttributes): +class SnapshotManager(CoreSysAttributes): """Manage snapshots.""" def __init__(self, coresys): diff --git a/hassio/utils/json.py b/hassio/utils/json.py index b4cfded06..fa29f8172 100644 --- a/hassio/utils/json.py +++ b/hassio/utils/json.py @@ -32,6 +32,14 @@ class JsonConfig(object): self.read_data() + def reset_data(self): + """Reset json file to default.""" + try: + self._data = self._schema({}) + except vol.Invalid as ex: + _LOGGER.error("Can't reset %s: %s", + self._file, humanize_error(self._data, ex)) + def read_data(self): """Read json file & validate.""" if self._file.is_file(): @@ -63,7 +71,7 @@ class JsonConfig(object): # Load last valid data _LOGGER.warning("Reset %s to last version", self._file) - self.save_data() + self.read_data() return # write