Add interface and home-assistant api proxy (#205)

* Add initial for hass interface

* For better compatibility, remove extra options for cleanup old stuff

* Add new functions to api

* Add api proxy to home-assistant

* use const

* fix lint

* fix lint

* Add check_api_state function

* Add api watchdog

* Fix lint

* update output

* fix url

* Fix API call

* fix API documentation

* remove password

* fix api call to hass api only

* fix problem with config missmatch

* test

* Detect wrong ssl settings

* disable watchdog & add options

* Update API
This commit is contained in:
Pascal Vizeli 2017-10-03 00:31:14 +02:00 committed by GitHub
parent 2998cd94ff
commit f38e28a4d9
9 changed files with 246 additions and 23 deletions

18
API.md
View File

@ -284,7 +284,10 @@ Optional:
"devices": [""], "devices": [""],
"image": "str", "image": "str",
"custom": "bool -> if custom image", "custom": "bool -> if custom image",
"boot": "bool" "boot": "bool",
"port": 8123,
"ssl": "bool",
"watchdog": "bool"
} }
``` ```
@ -303,21 +306,30 @@ Optional:
Output is the raw Docker log. Output is the raw Docker log.
- POST `/homeassistant/restart` - POST `/homeassistant/restart`
- POST `/homeassistant/options`
- POST `/homeassistant/check` - POST `/homeassistant/check`
- POST `/homeassistant/start` - POST `/homeassistant/start`
- POST `/homeassistant/stop` - POST `/homeassistant/stop`
- POST `/homeassistant/options`
```json ```json
{ {
"devices": [], "devices": [],
"image": "Optional|null", "image": "Optional|null",
"last_version": "Optional for custom image|null" "last_version": "Optional for custom image|null",
"port": "port for access hass",
"ssl": "bool",
"password": "",
"watchdog": "bool"
} }
``` ```
Image with `null` and last_version with `null` reset this options. Image with `null` and last_version with `null` reset this options.
- POST/GET `/homeassistant/api`
Proxy to real home-assistant instance.
### RESTful for API addons ### RESTful for API addons
- GET `/addons` - GET `/addons`

View File

@ -106,7 +106,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
vol.Optional(ATTR_IMAGE): vol.Match(r"^[\-\w{}]+/[\-\w{}]+$"), vol.Optional(ATTR_IMAGE): vol.Match(r"^[\-\w{}]+/[\-\w{}]+$"),
vol.Optional(ATTR_TIMEOUT, default=10): vol.Optional(ATTR_TIMEOUT, default=10):
vol.All(vol.Coerce(int), vol.Range(min=10, max=120)) vol.All(vol.Coerce(int), vol.Range(min=10, max=120))
}) }, extra=vol.REMOVE_EXTRA)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
@ -114,7 +114,7 @@ SCHEMA_REPOSITORY_CONFIG = vol.Schema({
vol.Required(ATTR_NAME): vol.Coerce(str), vol.Required(ATTR_NAME): vol.Coerce(str),
vol.Optional(ATTR_URL): vol.Url(), vol.Optional(ATTR_URL): vol.Url(),
vol.Optional(ATTR_MAINTAINER): vol.Coerce(str), vol.Optional(ATTR_MAINTAINER): vol.Coerce(str),
}) }, extra=vol.REMOVE_EXTRA)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter

View File

@ -75,6 +75,10 @@ class RestAPI(object):
self.webapp.router.add_post('/homeassistant/stop', api_hass.stop) 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/start', api_hass.start)
self.webapp.router.add_post('/homeassistant/check', api_hass.check) self.webapp.router.add_post('/homeassistant/check', api_hass.check)
self.webapp.router.add_post(
'/homeassistant/api/{path:.+}', api_hass.api)
self.webapp.router.add_get(
'/homeassistant/api/{path:.+}', api_hass.api)
def register_addons(self, addons): def register_addons(self, addons):
"""Register homeassistant function.""" """Register homeassistant function."""

View File

@ -2,13 +2,19 @@
import asyncio import asyncio
import logging 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 import voluptuous as vol
from .util import api_process, api_process_raw, api_validate from .util import api_process, api_process_raw, api_validate
from ..const import ( from ..const import (
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM, ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM,
ATTR_BOOT, CONTENT_TYPE_BINARY) ATTR_BOOT, ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG,
from ..validate import HASS_DEVICES CONTENT_TYPE_BINARY, HEADER_HA_ACCESS)
from ..validate import HASS_DEVICES, NETWORK_PORT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -20,6 +26,10 @@ SCHEMA_OPTIONS = vol.Schema({
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Any(None, vol.Coerce(str)), vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Any(None, vol.Coerce(str)),
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'):
vol.Any(None, vol.Coerce(str)), vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_PORT): NETWORK_PORT,
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_SSL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
}) })
SCHEMA_VERSION = vol.Schema({ SCHEMA_VERSION = vol.Schema({
@ -36,6 +46,45 @@ class APIHomeAssistant(object):
self.loop = loop self.loop = loop
self.homeassistant = homeassistant 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 @api_process
async def info(self, request): async def info(self, request):
"""Return host information.""" """Return host information."""
@ -46,6 +95,9 @@ class APIHomeAssistant(object):
ATTR_DEVICES: self.homeassistant.devices, ATTR_DEVICES: self.homeassistant.devices,
ATTR_CUSTOM: self.homeassistant.is_custom_image, ATTR_CUSTOM: self.homeassistant.is_custom_image,
ATTR_BOOT: self.homeassistant.boot, ATTR_BOOT: self.homeassistant.boot,
ATTR_PORT: self.homeassistant.api_port,
ATTR_SSL: self.homeassistant.api_ssl,
ATTR_WATCHDOG: self.homeassistant.watchdog,
} }
@api_process @api_process
@ -63,6 +115,18 @@ class APIHomeAssistant(object):
if ATTR_BOOT in body: if ATTR_BOOT in body:
self.homeassistant.boot = body[ATTR_BOOT] self.homeassistant.boot = body[ATTR_BOOT]
if ATTR_PORT in body:
self.homeassistant.api_port = body[ATTR_PORT]
if ATTR_PASSWORD in body:
self.homeassistant.api_password = body[ATTR_PASSWORD]
if ATTR_SSL in body:
self.homeassistant.api_ssl = body[ATTR_SSL]
if ATTR_WATCHDOG in body:
self.homeassistant.watchdog = body[ATTR_WATCHDOG]
return True return True
@api_process @api_process
@ -105,3 +169,14 @@ class APIHomeAssistant(object):
raise RuntimeError(message) raise RuntimeError(message)
return True 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
)

View File

@ -16,7 +16,8 @@ RUN_UPDATE_SUPERVISOR_TASKS = 29100
RUN_UPDATE_ADDONS_TASKS = 57600 RUN_UPDATE_ADDONS_TASKS = 57600
RUN_RELOAD_ADDONS_TASKS = 28800 RUN_RELOAD_ADDONS_TASKS = 28800
RUN_RELOAD_SNAPSHOTS_TASKS = 72000 RUN_RELOAD_SNAPSHOTS_TASKS = 72000
RUN_WATCHDOG_HOMEASSISTANT = 15 RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15
RUN_WATCHDOG_HOMEASSISTANT_API = 300
RUN_CLEANUP_API_SESSIONS = 900 RUN_CLEANUP_API_SESSIONS = 900
RESTART_EXIT_CODE = 100 RESTART_EXIT_CODE = 100
@ -50,7 +51,10 @@ RESULT_OK = 'ok'
CONTENT_TYPE_BINARY = 'application/octet-stream' CONTENT_TYPE_BINARY = 'application/octet-stream'
CONTENT_TYPE_PNG = 'image/png' CONTENT_TYPE_PNG = 'image/png'
CONTENT_TYPE_JSON = 'application/json'
HEADER_HA_ACCESS = 'x-ha-access'
ATTR_WATCHDOG = 'watchdog'
ATTR_DATE = 'date' ATTR_DATE = 'date'
ATTR_ARCH = 'arch' ATTR_ARCH = 'arch'
ATTR_HOSTNAME = 'hostname' ATTR_HOSTNAME = 'hostname'
@ -71,6 +75,8 @@ ATTR_DESCRIPTON = 'description'
ATTR_STARTUP = 'startup' ATTR_STARTUP = 'startup'
ATTR_BOOT = 'boot' ATTR_BOOT = 'boot'
ATTR_PORTS = 'ports' ATTR_PORTS = 'ports'
ATTR_PORT = 'port'
ATTR_SSL = 'ssl'
ATTR_MAP = 'map' ATTR_MAP = 'map'
ATTR_WEBUI = 'webui' ATTR_WEBUI = 'webui'
ATTR_OPTIONS = 'options' ATTR_OPTIONS = 'options'

View File

@ -9,7 +9,7 @@ from .api import RestAPI
from .host_control import HostControl from .host_control import HostControl
from .const import ( from .const import (
RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS, RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS,
RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT, RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT_DOCKER,
RUN_CLEANUP_API_SESSIONS, STARTUP_SYSTEM, STARTUP_SERVICES, RUN_CLEANUP_API_SESSIONS, STARTUP_SYSTEM, STARTUP_SERVICES,
STARTUP_APPLICATION, STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS, STARTUP_APPLICATION, STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS,
RUN_UPDATE_ADDONS_TASKS) RUN_UPDATE_ADDONS_TASKS)
@ -22,7 +22,8 @@ from .dns import DNSForward
from .snapshots import SnapshotsManager from .snapshots import SnapshotsManager
from .updater import Updater from .updater import Updater
from .tasks import ( from .tasks import (
hassio_update, homeassistant_watchdog, api_sessions_cleanup, addons_update) hassio_update, homeassistant_watchdog_docker, api_sessions_cleanup,
addons_update)
from .tools import fetch_timezone from .tools import fetch_timezone
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -165,8 +166,12 @@ class HassIO(object):
finally: finally:
# schedule homeassistant watchdog # schedule homeassistant watchdog
self.scheduler.register_task( self.scheduler.register_task(
homeassistant_watchdog(self.loop, self.homeassistant), homeassistant_watchdog_docker(self.loop, self.homeassistant),
RUN_WATCHDOG_HOMEASSISTANT) RUN_WATCHDOG_HOMEASSISTANT_DOCKER)
# self.scheduler.register_task(
# homeassistant_watchdog_api(self.loop, self.homeassistant),
# RUN_WATCHDOG_HOMEASSISTANT_API)
# If landingpage / run upgrade in background # If landingpage / run upgrade in background
if self.homeassistant.version == 'landingpage': if self.homeassistant.version == 'landingpage':
@ -179,6 +184,7 @@ class HassIO(object):
# process stop tasks # process stop tasks
self.websession.close() self.websession.close()
self.homeassistant.websession.close()
await asyncio.wait([self.api.stop(), self.dns.stop()], loop=self.loop) await asyncio.wait([self.api.stop(), self.dns.stop()], loop=self.loop)
self.exit_code = exit_code self.exit_code = exit_code

View File

@ -4,9 +4,14 @@ import logging
import os import os
import re import re
import aiohttp
from aiohttp.hdrs import CONTENT_TYPE
import async_timeout
from .const import ( from .const import (
FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION,
ATTR_VERSION, ATTR_BOOT) ATTR_VERSION, ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG,
HEADER_HA_ACCESS, CONTENT_TYPE_JSON)
from .dock.homeassistant import DockerHomeAssistant from .dock.homeassistant import DockerHomeAssistant
from .tools import JsonConfig, convert_to_ascii from .tools import JsonConfig, convert_to_ascii
from .validate import SCHEMA_HASS_CONFIG from .validate import SCHEMA_HASS_CONFIG
@ -26,6 +31,9 @@ class HomeAssistant(JsonConfig):
self.loop = loop self.loop = loop
self.updater = updater self.updater = updater
self.docker = DockerHomeAssistant(config, loop, docker, self) self.docker = DockerHomeAssistant(config, loop, docker, self)
self.api_ip = docker.network.gateway
self.websession = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(verify_ssl=False), loop=loop)
async def prepare(self): async def prepare(self):
"""Prepare HomeAssistant object.""" """Prepare HomeAssistant object."""
@ -38,6 +46,57 @@ class HomeAssistant(JsonConfig):
else: else:
await self.docker.attach() await self.docker.attach()
@property
def api_port(self):
"""Return network port to home-assistant instance."""
return self._data[ATTR_PORT]
@api_port.setter
def api_port(self, value):
"""Set network port for home-assistant instance."""
self._data[ATTR_PORT] = value
self.save()
@property
def api_password(self):
"""Return password for home-assistant instance."""
return self._data.get(ATTR_PASSWORD)
@api_password.setter
def api_password(self, value):
"""Set password for home-assistant instance."""
self._data[ATTR_PASSWORD] = value
self.save()
@property
def api_ssl(self):
"""Return if we need ssl to home-assistant instance."""
return self._data[ATTR_SSL]
@api_ssl.setter
def api_ssl(self, value):
"""Set SSL for home-assistant instance."""
self._data[ATTR_SSL] = value
self.save()
@property
def api_url(self):
"""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):
"""Return True if the watchdog should protect Home-Assistant."""
return self._data[ATTR_WATCHDOG]
@watchdog.setter
def watchdog(self, value):
"""Return True if the watchdog should protect Home-Assistant."""
self._data[ATTR_WATCHDOG] = value
self._data.save()
@property @property
def version(self): def version(self):
"""Return version of running homeassistant.""" """Return version of running homeassistant."""
@ -209,3 +268,23 @@ class HomeAssistant(JsonConfig):
if exit_code != 0 or RE_YAML_ERROR.search(log): if exit_code != 0 or RE_YAML_ERROR.search(log):
return (False, log) return (False, log)
return (True, log) return (True, log)
async def check_api_state(self):
"""Check if Home-Assistant up and running."""
url = "{}/api/".format(self.api_url)
header = {CONTENT_TYPE: CONTENT_TYPE_JSON}
if self.api_password:
header.update({HEADER_HA_ACCESS: self.api_password})
try:
async with async_timeout.timeout(30, loop=self.loop):
async with self.websession.get(url, headers=header) as request:
status = request.status
except (asyncio.TimeoutError, aiohttp.ClientError):
return False
if status not in (200, 201):
_LOGGER.warning("Home-Assistant API config missmatch")
return True

View File

@ -62,18 +62,54 @@ def hassio_update(supervisor, updater):
return _hassio_update return _hassio_update
def homeassistant_watchdog(loop, homeassistant): def homeassistant_watchdog_docker(loop, homeassistant):
"""Create scheduler task for montoring running state.""" """Create scheduler task for montoring running state of docker."""
async def _homeassistant_watchdog(): async def _homeassistant_watchdog_docker():
"""Check running state and start if they is close.""" """Check running state of docker and start if they is close."""
# if Home-Assistant is active # if Home-Assistant is active
if not await homeassistant.is_initialize(): if not await homeassistant.is_initialize() or \
not homeassistant.watchdog:
return return
# If Home-Assistant is running # if Home-Assistant is running
if homeassistant.in_progress or await homeassistant.is_running(): if homeassistant.in_progress or await homeassistant.is_running():
return return
loop.create_task(homeassistant.run()) loop.create_task(homeassistant.run())
_LOGGER.error("Watchdog found a problem with Home-Assistant docker!")
return _homeassistant_watchdog return _homeassistant_watchdog_docker
def homeassistant_watchdog_api(loop, homeassistant):
"""Create scheduler task for montoring running state of API.
Try 2 times to call API before we restart Home-Assistant. Maybe we had a
delay in our system.
"""
retry_scan = 0
async def _homeassistant_watchdog_api():
"""Check running state of API and start if they is close."""
nonlocal retry_scan
# if Home-Assistant is active
if not await homeassistant.is_initialize() or \
not homeassistant.watchdog:
return
# if Home-Assistant API is up
if homeassistant.in_progress or await homeassistant.check_api_state():
return
retry_scan += 1
# Retry active
if retry_scan == 1:
_LOGGER.warning("Watchdog miss API response from Home-Assistant")
return
loop.create_task(homeassistant.restart())
_LOGGER.error("Watchdog found a problem with Home-Assistant API!")
retry_scan = 0
return _homeassistant_watchdog_api

View File

@ -7,7 +7,8 @@ from .const import (
ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD,
ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE, ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE,
ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT, ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT,
ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT) ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL,
ATTR_PORT, ATTR_WATCHDOG)
NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
@ -61,7 +62,11 @@ SCHEMA_HASS_CONFIG = vol.Schema({
vol.Optional(ATTR_BOOT, default=True): vol.Boolean(), vol.Optional(ATTR_BOOT, default=True): vol.Boolean(),
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Coerce(str), vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Coerce(str),
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str), vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),
}) vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT,
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_SSL, default=False): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(),
}, extra=vol.REMOVE_EXTRA)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
@ -69,7 +74,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({
vol.Optional(ATTR_BETA_CHANNEL, default=False): vol.Boolean(), vol.Optional(ATTR_BETA_CHANNEL, default=False): vol.Boolean(),
vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str), vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str),
vol.Optional(ATTR_HASSIO): vol.Coerce(str), vol.Optional(ATTR_HASSIO): vol.Coerce(str),
}) }, extra=vol.REMOVE_EXTRA)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter