Skip to content

Commit f38e28a

Browse files
authored
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
1 parent 2998cd9 commit f38e28a

File tree

9 files changed

+246
-23
lines changed

9 files changed

+246
-23
lines changed

API.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,10 @@ Optional:
284284
"devices": [""],
285285
"image": "str",
286286
"custom": "bool -> if custom image",
287-
"boot": "bool"
287+
"boot": "bool",
288+
"port": 8123,
289+
"ssl": "bool",
290+
"watchdog": "bool"
288291
}
289292
```
290293

@@ -303,21 +306,30 @@ Optional:
303306
Output is the raw Docker log.
304307

305308
- POST `/homeassistant/restart`
306-
- POST `/homeassistant/options`
307309
- POST `/homeassistant/check`
308310
- POST `/homeassistant/start`
309311
- POST `/homeassistant/stop`
310312

313+
- POST `/homeassistant/options`
314+
311315
```json
312316
{
313317
"devices": [],
314318
"image": "Optional|null",
315-
"last_version": "Optional for custom image|null"
319+
"last_version": "Optional for custom image|null",
320+
"port": "port for access hass",
321+
"ssl": "bool",
322+
"password": "",
323+
"watchdog": "bool"
316324
}
317325
```
318326

319327
Image with `null` and last_version with `null` reset this options.
320328

329+
- POST/GET `/homeassistant/api`
330+
331+
Proxy to real home-assistant instance.
332+
321333
### RESTful for API addons
322334

323335
- GET `/addons`

hassio/addons/validate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,15 @@ def _simple_startup(value):
106106
vol.Optional(ATTR_IMAGE): vol.Match(r"^[\-\w{}]+/[\-\w{}]+$"),
107107
vol.Optional(ATTR_TIMEOUT, default=10):
108108
vol.All(vol.Coerce(int), vol.Range(min=10, max=120))
109-
})
109+
}, extra=vol.REMOVE_EXTRA)
110110

111111

112112
# pylint: disable=no-value-for-parameter
113113
SCHEMA_REPOSITORY_CONFIG = vol.Schema({
114114
vol.Required(ATTR_NAME): vol.Coerce(str),
115115
vol.Optional(ATTR_URL): vol.Url(),
116116
vol.Optional(ATTR_MAINTAINER): vol.Coerce(str),
117-
})
117+
}, extra=vol.REMOVE_EXTRA)
118118

119119

120120
# pylint: disable=no-value-for-parameter

hassio/api/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ def register_homeassistant(self, dock_homeassistant):
7575
self.webapp.router.add_post('/homeassistant/stop', api_hass.stop)
7676
self.webapp.router.add_post('/homeassistant/start', api_hass.start)
7777
self.webapp.router.add_post('/homeassistant/check', api_hass.check)
78+
self.webapp.router.add_post(
79+
'/homeassistant/api/{path:.+}', api_hass.api)
80+
self.webapp.router.add_get(
81+
'/homeassistant/api/{path:.+}', api_hass.api)
7882

7983
def register_addons(self, addons):
8084
"""Register homeassistant function."""

hassio/api/homeassistant.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22
import asyncio
33
import logging
44

5+
import aiohttp
6+
from aiohttp import web
7+
from aiohttp.web_exceptions import HTTPBadGateway
8+
from aiohttp.hdrs import CONTENT_TYPE
9+
import async_timeout
510
import voluptuous as vol
611

712
from .util import api_process, api_process_raw, api_validate
813
from ..const import (
914
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM,
10-
ATTR_BOOT, CONTENT_TYPE_BINARY)
11-
from ..validate import HASS_DEVICES
15+
ATTR_BOOT, ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG,
16+
CONTENT_TYPE_BINARY, HEADER_HA_ACCESS)
17+
from ..validate import HASS_DEVICES, NETWORK_PORT
1218

1319
_LOGGER = logging.getLogger(__name__)
1420

@@ -20,6 +26,10 @@
2026
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Any(None, vol.Coerce(str)),
2127
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'):
2228
vol.Any(None, vol.Coerce(str)),
29+
vol.Optional(ATTR_PORT): NETWORK_PORT,
30+
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
31+
vol.Optional(ATTR_SSL): vol.Boolean(),
32+
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
2333
})
2434

2535
SCHEMA_VERSION = vol.Schema({
@@ -36,6 +46,45 @@ def __init__(self, config, loop, homeassistant):
3646
self.loop = loop
3747
self.homeassistant = homeassistant
3848

49+
async def homeassistant_proxy(self, path, request):
50+
"""Return a client request with proxy origin for Home-Assistant."""
51+
url = "{}/api/{}".format(self.homeassistant.api_url, path)
52+
53+
try:
54+
data = None
55+
headers = {}
56+
method = getattr(
57+
self.homeassistant.websession, request.method.lower())
58+
59+
# read data
60+
with async_timeout.timeout(10, loop=self.loop):
61+
data = await request.read()
62+
63+
if data:
64+
headers.update({CONTENT_TYPE: request.content_type})
65+
66+
# need api password?
67+
if self.homeassistant.api_password:
68+
headers = {HEADER_HA_ACCESS: self.homeassistant.api_password}
69+
70+
# reset headers
71+
if not headers:
72+
headers = None
73+
74+
client = await method(
75+
url, data=data, headers=headers, timeout=300
76+
)
77+
78+
return client
79+
80+
except aiohttp.ClientError as err:
81+
_LOGGER.error("Client error on api %s request %s.", path, err)
82+
83+
except asyncio.TimeoutError:
84+
_LOGGER.error("Client timeout error on api request %s.", path)
85+
86+
raise HTTPBadGateway()
87+
3988
@api_process
4089
async def info(self, request):
4190
"""Return host information."""
@@ -46,6 +95,9 @@ async def info(self, request):
4695
ATTR_DEVICES: self.homeassistant.devices,
4796
ATTR_CUSTOM: self.homeassistant.is_custom_image,
4897
ATTR_BOOT: self.homeassistant.boot,
98+
ATTR_PORT: self.homeassistant.api_port,
99+
ATTR_SSL: self.homeassistant.api_ssl,
100+
ATTR_WATCHDOG: self.homeassistant.watchdog,
49101
}
50102

51103
@api_process
@@ -63,6 +115,18 @@ async def options(self, request):
63115
if ATTR_BOOT in body:
64116
self.homeassistant.boot = body[ATTR_BOOT]
65117

118+
if ATTR_PORT in body:
119+
self.homeassistant.api_port = body[ATTR_PORT]
120+
121+
if ATTR_PASSWORD in body:
122+
self.homeassistant.api_password = body[ATTR_PASSWORD]
123+
124+
if ATTR_SSL in body:
125+
self.homeassistant.api_ssl = body[ATTR_SSL]
126+
127+
if ATTR_WATCHDOG in body:
128+
self.homeassistant.watchdog = body[ATTR_WATCHDOG]
129+
66130
return True
67131

68132
@api_process
@@ -105,3 +169,14 @@ async def check(self, request):
105169
raise RuntimeError(message)
106170

107171
return True
172+
173+
async def api(self, request):
174+
"""Proxy API request to Home-Assistant."""
175+
path = request.match_info.get('path')
176+
177+
client = await self.homeassistant_proxy(path, request)
178+
return web.Response(
179+
body=await client.read(),
180+
status=client.status,
181+
content_type=client.content_type
182+
)

hassio/const.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
RUN_UPDATE_ADDONS_TASKS = 57600
1717
RUN_RELOAD_ADDONS_TASKS = 28800
1818
RUN_RELOAD_SNAPSHOTS_TASKS = 72000
19-
RUN_WATCHDOG_HOMEASSISTANT = 15
19+
RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15
20+
RUN_WATCHDOG_HOMEASSISTANT_API = 300
2021
RUN_CLEANUP_API_SESSIONS = 900
2122

2223
RESTART_EXIT_CODE = 100
@@ -50,7 +51,10 @@
5051

5152
CONTENT_TYPE_BINARY = 'application/octet-stream'
5253
CONTENT_TYPE_PNG = 'image/png'
54+
CONTENT_TYPE_JSON = 'application/json'
55+
HEADER_HA_ACCESS = 'x-ha-access'
5356

57+
ATTR_WATCHDOG = 'watchdog'
5458
ATTR_DATE = 'date'
5559
ATTR_ARCH = 'arch'
5660
ATTR_HOSTNAME = 'hostname'
@@ -71,6 +75,8 @@
7175
ATTR_STARTUP = 'startup'
7276
ATTR_BOOT = 'boot'
7377
ATTR_PORTS = 'ports'
78+
ATTR_PORT = 'port'
79+
ATTR_SSL = 'ssl'
7480
ATTR_MAP = 'map'
7581
ATTR_WEBUI = 'webui'
7682
ATTR_OPTIONS = 'options'

hassio/core.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from .host_control import HostControl
1010
from .const import (
1111
RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS,
12-
RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT,
12+
RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT_DOCKER,
1313
RUN_CLEANUP_API_SESSIONS, STARTUP_SYSTEM, STARTUP_SERVICES,
1414
STARTUP_APPLICATION, STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS,
1515
RUN_UPDATE_ADDONS_TASKS)
@@ -22,7 +22,8 @@
2222
from .snapshots import SnapshotsManager
2323
from .updater import Updater
2424
from .tasks import (
25-
hassio_update, homeassistant_watchdog, api_sessions_cleanup, addons_update)
25+
hassio_update, homeassistant_watchdog_docker, api_sessions_cleanup,
26+
addons_update)
2627
from .tools import fetch_timezone
2728

2829
_LOGGER = logging.getLogger(__name__)
@@ -165,8 +166,12 @@ async def start(self):
165166
finally:
166167
# schedule homeassistant watchdog
167168
self.scheduler.register_task(
168-
homeassistant_watchdog(self.loop, self.homeassistant),
169-
RUN_WATCHDOG_HOMEASSISTANT)
169+
homeassistant_watchdog_docker(self.loop, self.homeassistant),
170+
RUN_WATCHDOG_HOMEASSISTANT_DOCKER)
171+
172+
# self.scheduler.register_task(
173+
# homeassistant_watchdog_api(self.loop, self.homeassistant),
174+
# RUN_WATCHDOG_HOMEASSISTANT_API)
170175

171176
# If landingpage / run upgrade in background
172177
if self.homeassistant.version == 'landingpage':
@@ -179,6 +184,7 @@ async def stop(self, exit_code=0):
179184

180185
# process stop tasks
181186
self.websession.close()
187+
self.homeassistant.websession.close()
182188
await asyncio.wait([self.api.stop(), self.dns.stop()], loop=self.loop)
183189

184190
self.exit_code = exit_code

hassio/homeassistant.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44
import os
55
import re
66

7+
import aiohttp
8+
from aiohttp.hdrs import CONTENT_TYPE
9+
import async_timeout
10+
711
from .const import (
812
FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION,
9-
ATTR_VERSION, ATTR_BOOT)
13+
ATTR_VERSION, ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG,
14+
HEADER_HA_ACCESS, CONTENT_TYPE_JSON)
1015
from .dock.homeassistant import DockerHomeAssistant
1116
from .tools import JsonConfig, convert_to_ascii
1217
from .validate import SCHEMA_HASS_CONFIG
@@ -26,6 +31,9 @@ def __init__(self, config, loop, docker, updater):
2631
self.loop = loop
2732
self.updater = updater
2833
self.docker = DockerHomeAssistant(config, loop, docker, self)
34+
self.api_ip = docker.network.gateway
35+
self.websession = aiohttp.ClientSession(
36+
connector=aiohttp.TCPConnector(verify_ssl=False), loop=loop)
2937

3038
async def prepare(self):
3139
"""Prepare HomeAssistant object."""
@@ -38,6 +46,57 @@ async def prepare(self):
3846
else:
3947
await self.docker.attach()
4048

49+
@property
50+
def api_port(self):
51+
"""Return network port to home-assistant instance."""
52+
return self._data[ATTR_PORT]
53+
54+
@api_port.setter
55+
def api_port(self, value):
56+
"""Set network port for home-assistant instance."""
57+
self._data[ATTR_PORT] = value
58+
self.save()
59+
60+
@property
61+
def api_password(self):
62+
"""Return password for home-assistant instance."""
63+
return self._data.get(ATTR_PASSWORD)
64+
65+
@api_password.setter
66+
def api_password(self, value):
67+
"""Set password for home-assistant instance."""
68+
self._data[ATTR_PASSWORD] = value
69+
self.save()
70+
71+
@property
72+
def api_ssl(self):
73+
"""Return if we need ssl to home-assistant instance."""
74+
return self._data[ATTR_SSL]
75+
76+
@api_ssl.setter
77+
def api_ssl(self, value):
78+
"""Set SSL for home-assistant instance."""
79+
self._data[ATTR_SSL] = value
80+
self.save()
81+
82+
@property
83+
def api_url(self):
84+
"""Return API url to Home-Assistant."""
85+
return "{}://{}:{}".format(
86+
'https' if self.api_ssl else 'http', self.api_ip, self.api_port
87+
)
88+
89+
@property
90+
def watchdog(self):
91+
"""Return True if the watchdog should protect Home-Assistant."""
92+
return self._data[ATTR_WATCHDOG]
93+
94+
@watchdog.setter
95+
def watchdog(self, value):
96+
"""Return True if the watchdog should protect Home-Assistant."""
97+
self._data[ATTR_WATCHDOG] = value
98+
self._data.save()
99+
41100
@property
42101
def version(self):
43102
"""Return version of running homeassistant."""
@@ -209,3 +268,23 @@ async def check_config(self):
209268
if exit_code != 0 or RE_YAML_ERROR.search(log):
210269
return (False, log)
211270
return (True, log)
271+
272+
async def check_api_state(self):
273+
"""Check if Home-Assistant up and running."""
274+
url = "{}/api/".format(self.api_url)
275+
header = {CONTENT_TYPE: CONTENT_TYPE_JSON}
276+
277+
if self.api_password:
278+
header.update({HEADER_HA_ACCESS: self.api_password})
279+
280+
try:
281+
async with async_timeout.timeout(30, loop=self.loop):
282+
async with self.websession.get(url, headers=header) as request:
283+
status = request.status
284+
285+
except (asyncio.TimeoutError, aiohttp.ClientError):
286+
return False
287+
288+
if status not in (200, 201):
289+
_LOGGER.warning("Home-Assistant API config missmatch")
290+
return True

0 commit comments

Comments
 (0)