diff --git a/API.md b/API.md index 2b5362ad0..f42d0b0cd 100644 --- a/API.md +++ b/API.md @@ -331,6 +331,10 @@ Image with `null` and last_version with `null` reset this options. Proxy to real home-assistant instance. +- GET `/homeassistant/websocket` + +Proxy to real websocket instance. + ### RESTful for API addons - GET `/addons` @@ -373,6 +377,7 @@ Get all available addons. { "name": "xy bla", "description": "description", + "long_description": "null|markdown", "auto_update": "bool", "url": "null|url of addon", "detached": "bool", diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index cb47ce7cc..076dc82b1 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -137,6 +137,7 @@ class Addon(object): """Return if auto update is enable.""" if ATTR_AUTO_UPDATE in self.data.user.get(self._id, {}): return self.data.user[self._id][ATTR_AUTO_UPDATE] + return None @auto_update.setter def auto_update(self, value): @@ -159,12 +160,26 @@ class Addon(object): """Return a API token for this add-on.""" if self.is_installed: return self.data.user[self._id][ATTR_UUID] + return None @property def description(self): """Return description of addon.""" return self._mesh[ATTR_DESCRIPTON] + @property + def long_description(self): + """Return README.md as long_description.""" + readme = Path(self.path_location, 'README.md') + + # If readme not exists + if not readme.exists(): + return None + + # Return data + with readme.open('r') as readme_file: + return readme_file.read() + @property def repository(self): """Return repository of addon.""" @@ -333,7 +348,7 @@ class Addon(object): def audio_input(self): """Return ALSA config for input or None.""" if not self.with_audio: - return + return None setting = self.config.audio_input if self.is_installed and ATTR_AUDIO_INPUT in self.data.user[self._id]: diff --git a/hassio/addons/build.py b/hassio/addons/build.py index 0e9f3c930..cad11ad39 100644 --- a/hassio/addons/build.py +++ b/hassio/addons/build.py @@ -1,7 +1,7 @@ """HassIO addons build environment.""" from pathlib import Path -from .validate import SCHEMA_BUILD_CONFIG +from .validate import SCHEMA_BUILD_CONFIG, BASE_IMAGE from ..const import ATTR_SQUASH, ATTR_BUILD_FROM, ATTR_ARGS, META_ADDON from ..tools import JsonConfig @@ -24,7 +24,8 @@ class AddonBuild(JsonConfig): @property def base_image(self): """Base images for this addon.""" - return self._data[ATTR_BUILD_FROM][self.config.arch] + return self._data[ATTR_BUILD_FROM].get( + self.config.arch, BASE_IMAGE[self.config.arch]) @property def squash(self): diff --git a/hassio/addons/built-in.json b/hassio/addons/built-in.json index 4d0b11ac6..73d225d82 100644 --- a/hassio/addons/built-in.json +++ b/hassio/addons/built-in.json @@ -2,7 +2,7 @@ "local": { "name": "Local Add-Ons", "url": "https://home-assistant.io/hassio", - "maintainer": "By our self" + "maintainer": "you" }, "core": { "name": "Built-in Add-Ons", diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index a0001d289..ae40c067c 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -219,6 +219,7 @@ def validate_options(raw_schema): # pylint: disable=no-value-for-parameter +# pylint: disable=inconsistent-return-statements def _single_validate(typ, value, key): """Validate a single element.""" # if required argument diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 269fbfd6f..31757d193 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -8,6 +8,7 @@ from .addons import APIAddons from .homeassistant import APIHomeAssistant from .host import APIHost from .network import APINetwork +from .proxy import APIProxy from .supervisor import APISupervisor from .security import APISecurity from .snapshots import APISnapshots @@ -63,9 +64,9 @@ class RestAPI(object): '/supervisor/options', api_supervisor.options) self.webapp.router.add_get('/supervisor/logs', api_supervisor.logs) - def register_homeassistant(self, dock_homeassistant): + def register_homeassistant(self, homeassistant): """Register homeassistant function.""" - api_hass = APIHomeAssistant(self.config, self.loop, dock_homeassistant) + api_hass = APIHomeAssistant(self.config, self.loop, homeassistant) self.webapp.router.add_get('/homeassistant/info', api_hass.info) self.webapp.router.add_get('/homeassistant/logs', api_hass.logs) @@ -75,10 +76,21 @@ class RestAPI(object): self.webapp.router.add_post('/homeassistant/stop', api_hass.stop) self.webapp.router.add_post('/homeassistant/start', api_hass.start) self.webapp.router.add_post('/homeassistant/check', api_hass.check) - self.webapp.router.add_post( - '/homeassistant/api/{path:.+}', api_hass.api) + + def register_proxy(self, homeassistant, websession): + """Register HomeAssistant API Proxy.""" + api_proxy = APIProxy(self.loop, homeassistant, websession) + self.webapp.router.add_get( - '/homeassistant/api/{path:.+}', api_hass.api) + '/homeassistant/api/websocket', api_proxy.websocket) + self.webapp.router.add_get( + '/homeassistant/websocket', api_proxy.websocket) + self.webapp.router.add_post( + '/homeassistant/api/{path:.+}', api_proxy.api) + self.webapp.router.add_get( + '/homeassistant/api/{path:.+}', api_proxy.api) + self.webapp.router.add_get( + '/homeassistant/api', api_proxy.api) def register_addons(self, addons): """Register homeassistant function.""" diff --git a/hassio/api/addons.py b/hassio/api/addons.py index bdb4512ee..295d133b5 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -14,7 +14,7 @@ from ..const import ( ATTR_INSTALLED, ATTR_LOGO, ATTR_WEBUI, ATTR_DEVICES, ATTR_PRIVILEGED, ATTR_AUDIO, ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, BOOT_AUTO, BOOT_MANUAL, - ATTR_CHANGELOG, ATTR_HOST_IPC, ATTR_HOST_DBUS, + ATTR_CHANGELOG, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_LONG_DESCRIPTION, CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT) from ..validate import DOCKER_PORTS @@ -57,7 +57,7 @@ class APIAddons(object): """Return a simplified device list.""" dev_list = addon.devices if not dev_list: - return + return None return [row.split(':')[0] for row in dev_list] @api_process @@ -108,6 +108,7 @@ class APIAddons(object): return { ATTR_NAME: addon.name, ATTR_DESCRIPTON: addon.description, + ATTR_LONG_DESCRIPTION: addon.long_description, ATTR_VERSION: addon.version_installed, ATTR_AUTO_UPDATE: addon.auto_update, ATTR_REPOSITORY: addon.repository, diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index a455956c2..893f49d40 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -2,18 +2,13 @@ import asyncio import logging -import aiohttp -from aiohttp import web -from aiohttp.web_exceptions import HTTPBadGateway -from aiohttp.hdrs import CONTENT_TYPE -import async_timeout import voluptuous as vol from .util import api_process, api_process_raw, api_validate from ..const import ( ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM, ATTR_BOOT, ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, - CONTENT_TYPE_BINARY, HEADER_HA_ACCESS) + CONTENT_TYPE_BINARY) from ..validate import HASS_DEVICES, NETWORK_PORT _LOGGER = logging.getLogger(__name__) @@ -46,45 +41,6 @@ class APIHomeAssistant(object): self.loop = loop self.homeassistant = homeassistant - async def homeassistant_proxy(self, path, request): - """Return a client request with proxy origin for Home-Assistant.""" - url = "{}/api/{}".format(self.homeassistant.api_url, path) - - try: - data = None - headers = {} - method = getattr( - self.homeassistant.websession, request.method.lower()) - - # read data - with async_timeout.timeout(10, loop=self.loop): - data = await request.read() - - if data: - headers.update({CONTENT_TYPE: request.content_type}) - - # need api password? - if self.homeassistant.api_password: - headers = {HEADER_HA_ACCESS: self.homeassistant.api_password} - - # reset headers - if not headers: - headers = None - - client = await method( - url, data=data, headers=headers, timeout=300 - ) - - return client - - except aiohttp.ClientError as err: - _LOGGER.error("Client error on api %s request %s.", path, err) - - except asyncio.TimeoutError: - _LOGGER.error("Client timeout error on api request %s.", path) - - raise HTTPBadGateway() - @api_process async def info(self, request): """Return host information.""" @@ -169,14 +125,3 @@ class APIHomeAssistant(object): raise RuntimeError(message) return True - - async def api(self, request): - """Proxy API request to Home-Assistant.""" - path = request.match_info.get('path') - - client = await self.homeassistant_proxy(path, request) - return web.Response( - body=await client.read(), - status=client.status, - content_type=client.content_type - ) diff --git a/hassio/api/proxy.py b/hassio/api/proxy.py new file mode 100644 index 000000000..13a759a40 --- /dev/null +++ b/hassio/api/proxy.py @@ -0,0 +1,195 @@ +"""Utils for HomeAssistant Proxy.""" +import asyncio +import logging + +import aiohttp +from aiohttp import web +from aiohttp.web_exceptions import HTTPBadGateway +from aiohttp.hdrs import CONTENT_TYPE +import async_timeout + +from ..const import HEADER_HA_ACCESS + +_LOGGER = logging.getLogger(__name__) + + +class APIProxy(object): + """API Proxy for Home-Assistant.""" + + def __init__(self, loop, homeassistant, websession): + """Initialize api proxy.""" + self.loop = loop + self.homeassistant = homeassistant + self.websession = websession + + async def _api_client(self, request, path, timeout=300): + """Return a client request with proxy origin for Home-Assistant.""" + url = f"{self.homeassistant.api_url}/api/{path}" + + try: + data = None + headers = {} + method = getattr(self.websession, request.method.lower()) + + # read data + with async_timeout.timeout(30, loop=self.loop): + data = await request.read() + + if data: + headers.update({CONTENT_TYPE: request.content_type}) + + # need api password? + if self.homeassistant.api_password: + headers = {HEADER_HA_ACCESS: self.homeassistant.api_password} + + # reset headers + if not headers: + headers = None + + client = await method( + url, data=data, headers=headers, timeout=timeout + ) + + return client + + except aiohttp.ClientError as err: + _LOGGER.error("Client error on API %s request %s.", path, err) + + except asyncio.TimeoutError: + _LOGGER.error("Client timeout error on API request %s.", path) + + raise HTTPBadGateway() + + async def api(self, request): + """Proxy HomeAssistant API Requests.""" + path = request.match_info.get('path', '') + + # API stream + if path.startswith("stream"): + _LOGGER.info("Home-Assistant Event-Stream start") + client = await self._api_client(request, path, timeout=None) + + response = web.StreamResponse() + response.content_type = request.headers.get(CONTENT_TYPE) + try: + await response.prepare(request) + while True: + data = await client.content.read(10) + if not data: + await response.write_eof() + break + response.write(data) + + except aiohttp.ClientError: + await response.write_eof() + + except asyncio.CancelledError: + pass + + finally: + client.close() + + _LOGGER.info("Home-Assistant Event-Stream close") + + # Normal request + else: + _LOGGER.info("Home-Assistant '/api/%s' request", path) + client = await self._api_client(request, path) + + data = await client.read() + return web.Response( + body=data, + status=client.status, + content_type=client.content_type + ) + + async def _websocket_client(self): + """Initialize a websocket api connection.""" + url = f"{self.homeassistant.api_url}/api/websocket" + + try: + client = await self.websession.ws_connect( + url, heartbeat=60) + + # handle authentication + for _ in range(2): + data = await client.receive_json() + if data.get('type') == 'auth_ok': + return client + elif data.get('type') == 'auth_required': + await client.send_json({ + 'type': 'auth', + 'api_password': self.homeassistant.api_password, + }) + + _LOGGER.error("Authentication to Home-Assistant websocket") + + except (aiohttp.ClientError, RuntimeError) as err: + _LOGGER.error("Client error on websocket API %s.", err) + + raise HTTPBadGateway() + + async def websocket(self, request): + """Initialize a websocket api connection.""" + _LOGGER.info("Home-Assistant Websocket API request initialze") + + # init server + server = web.WebSocketResponse(heartbeat=60) + await server.prepare(request) + + # handle authentication + await server.send_json({'type': 'auth_required'}) + await server.receive_json() # get internal token + await server.send_json({'type': 'auth_ok'}) + + # init connection to hass + client = await self._websocket_client() + + _LOGGER.info("Home-Assistant Websocket API request running") + try: + client_read = None + server_read = None + while not server.closed and not client.closed: + if not client_read: + client_read = asyncio.ensure_future( + client.receive_str(), loop=self.loop) + if not server_read: + server_read = asyncio.ensure_future( + server.receive_str(), loop=self.loop) + + # wait until data need to be processed + await asyncio.wait( + [client_read, server_read], + loop=self.loop, return_when=asyncio.FIRST_COMPLETED + ) + + # server + if server_read.done() and not client.closed: + server_read.exception() + await client.send_str(server_read.result()) + server_read = None + + # client + if client_read.done() and not server.closed: + client_read.exception() + await server.send_str(client_read.result()) + client_read = None + + except asyncio.CancelledError: + pass + + except RuntimeError as err: + _LOGGER.info("Home-Assistant Websocket API error: %s", err) + + finally: + if client_read: + client_read.cancel() + if server_read: + server_read.cancel() + + # close connections + await client.close() + await server.close() + + _LOGGER.info("Home-Assistant Websocket API connection is closed") + return server diff --git a/hassio/const.py b/hassio/const.py index 1a1bf30b2..c1875acec 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -2,7 +2,7 @@ from pathlib import Path from ipaddress import ip_network -HASSIO_VERSION = '0.76' +HASSIO_VERSION = '0.77' URL_HASSIO_VERSION = ('https://raw.githubusercontent.com/home-assistant/' 'hassio/{}/version.json') @@ -57,6 +57,7 @@ ATTR_WATCHDOG = 'watchdog' ATTR_CHANGELOG = 'changelog' ATTR_DATE = 'date' ATTR_ARCH = 'arch' +ATTR_LONG_DESCRIPTION = 'long_description' ATTR_HOSTNAME = 'hostname' ATTR_TIMEZONE = 'timezone' ATTR_ARGS = 'args' diff --git a/hassio/core.py b/hassio/core.py index 0043c505b..3be311e3e 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -91,6 +91,7 @@ class HassIO(object): self.supervisor, self.snapshots, self.addons, self.host_control, self.updater) self.api.register_homeassistant(self.homeassistant) + self.api.register_proxy(self.homeassistant, self.websession) self.api.register_addons(self.addons) self.api.register_security() self.api.register_snapshots(self.snapshots) diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py index fa9b075e5..141be97df 100644 --- a/hassio/dock/addon.py +++ b/hassio/dock/addon.py @@ -25,6 +25,7 @@ class DockerAddon(DockerInterface): config, loop, api, image=addon.image, timeout=addon.timeout) self.addon = addon + # pylint: disable=inconsistent-return-statements def process_metadata(self, metadata, force=False): """Use addon data instead meta data with legacy.""" if not self.addon.legacy: @@ -50,6 +51,7 @@ class DockerAddon(DockerInterface): """Return the IPC namespace.""" if self.addon.host_ipc: return 'host' + return None @property def hostname(self): @@ -114,6 +116,7 @@ class DockerAddon(DockerInterface): return [ "apparmor:unconfined", ] + return None @property def tmpfs(self): diff --git a/hassio/dock/homeassistant.py b/hassio/dock/homeassistant.py index 09a2d8918..2037f7364 100644 --- a/hassio/dock/homeassistant.py +++ b/hassio/dock/homeassistant.py @@ -27,7 +27,7 @@ class DockerHomeAssistant(DockerInterface): def devices(self): """Create list of special device to map into docker.""" if not self.data.devices: - return + return None devices = [] for device in self.data.devices: @@ -41,7 +41,7 @@ class DockerHomeAssistant(DockerInterface): Need run inside executor. """ if self._is_running(): - return + return False # cleanup self._stop() diff --git a/hassio/hardware.py b/hassio/hardware.py index d8ba8eb82..a97774eea 100644 --- a/hassio/hardware.py +++ b/hassio/hardware.py @@ -70,7 +70,7 @@ class Hardware(object): devices = devices_file.read() except OSError as err: _LOGGER.error("Can't read asound data -> %s", err) - return + return None audio_list = {} @@ -110,12 +110,12 @@ class Hardware(object): stats = stat_file.read() except OSError as err: _LOGGER.error("Can't read stat data -> %s", err) - return + return None # parse stat file found = RE_BOOT_TIME.search(stats) if not found: _LOGGER.error("Can't found last boot time!") - return + return None return datetime.utcfromtimestamp(int(found.group(1))) diff --git a/hassio/validate.py b/hassio/validate.py index f0507c531..71688d5ae 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -29,11 +29,12 @@ def validate_timezone(timezone): return timezone +# pylint: disable=inconsistent-return-statements def convert_to_docker_ports(data): """Convert data into docker port list.""" # dynamic ports if data is None: - return + return None # single port if isinstance(data, int): diff --git a/version.json b/version.json index 2eb6ce087..436758384 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "hassio": "0.76", + "hassio": "0.77", "homeassistant": "0.60", "resinos": "1.1", "resinhup": "0.3",