From 4df42e054dbd184ab887c7b9708ec0a5b3861b9d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Jul 2018 16:55:48 +0200 Subject: [PATCH] Leverage access and refresh tokens if available (#575) * Leverage access and refresh tokens if available * Update homeassistant.py * Update homeassistant.py * Update proxy.py * Migrate HomeAssistant to new exception layout * Fix build for 3.7 * Cleanups * Fix style * fix log strings * Fix new style * Fix travis build * python 3.7 * next try * fix * fix lint * Fix lint p2 * Add logging * Fix logging * fix access * Fix spell * fix return * Fix runtime * Add to hass config --- .travis.yml | 12 +-- hassio/api/homeassistant.py | 16 +-- hassio/api/proxy.py | 170 ++++++++++++++++++-------------- hassio/const.py | 3 +- hassio/exceptions.py | 26 +++++ hassio/homeassistant.py | 190 ++++++++++++++++++++++++------------ hassio/tasks.py | 6 +- hassio/validate.py | 4 +- pylintrc | 3 +- setup.py | 2 +- 10 files changed, 275 insertions(+), 157 deletions(-) diff --git a/.travis.yml b/.travis.yml index e16f6a9fb..137d61017 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,6 @@ -sudo: false -matrix: - fast_finish: true - include: - - python: "3.6" - -cache: - directories: - - $HOME/.cache/pip +sudo: true +dist: xenial install: pip install -U tox language: python +python: 3.7 script: tox diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index dd5e9698b..0cda6dcdb 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -10,7 +10,7 @@ from ..const import ( ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT, ATTR_MEMORY_USAGE, ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_WAIT_BOOT, ATTR_MACHINE, - CONTENT_TYPE_BINARY) + ATTR_REFRESH_TOKEN, CONTENT_TYPE_BINARY) from ..coresys import CoreSysAttributes from ..validate import NETWORK_PORT, DOCKER_IMAGE @@ -30,6 +30,8 @@ SCHEMA_OPTIONS = vol.Schema({ vol.Optional(ATTR_WATCHDOG): vol.Boolean(), vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)), + # Required once we enforce user system + vol.Optional(ATTR_REFRESH_TOKEN): str, }) SCHEMA_VERSION = vol.Schema({ @@ -83,8 +85,10 @@ class APIHomeAssistant(CoreSysAttributes): if ATTR_WAIT_BOOT in body: self.sys_homeassistant.wait_boot = body[ATTR_WAIT_BOOT] + if ATTR_REFRESH_TOKEN in body: + self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN] + self.sys_homeassistant.save_data() - return True @api_process async def stats(self, request): @@ -109,11 +113,7 @@ class APIHomeAssistant(CoreSysAttributes): body = await api_validate(SCHEMA_VERSION, request) version = body.get(ATTR_VERSION, self.sys_homeassistant.last_version) - if version == self.sys_homeassistant.version: - raise RuntimeError("Version {} is already in use".format(version)) - - return await asyncio.shield( - self.sys_homeassistant.update(version)) + await asyncio.shield(self.sys_homeassistant.update(version)) @api_process def stop(self, request): @@ -123,7 +123,7 @@ class APIHomeAssistant(CoreSysAttributes): @api_process def start(self, request): """Start homeassistant.""" - return asyncio.shield(self.sys_homeassistant.start()) + asyncio.shield(self.sys_homeassistant.start()) @api_process def restart(self, request): diff --git a/hassio/api/proxy.py b/hassio/api/proxy.py index a6e32e87b..7abd31ffa 100644 --- a/hassio/api/proxy.py +++ b/hassio/api/proxy.py @@ -1,15 +1,18 @@ """Utils for HomeAssistant Proxy.""" import asyncio +from contextlib import asynccontextmanager import logging import aiohttp from aiohttp import web -from aiohttp.web_exceptions import HTTPBadGateway, HTTPInternalServerError +from aiohttp.web_exceptions import ( + HTTPBadGateway, HTTPInternalServerError, HTTPUnauthorized) from aiohttp.hdrs import CONTENT_TYPE import async_timeout from ..const import HEADER_HA_ACCESS from ..coresys import CoreSysAttributes +from ..exceptions import HomeAssistantAuthError, HomeAssistantAPIError _LOGGER = logging.getLogger(__name__) @@ -23,49 +26,43 @@ class APIProxy(CoreSysAttributes): addon = self.sys_addons.from_uuid(hassio_token) if not addon: - _LOGGER.warning("Unknown Home-Assistant API access!") + _LOGGER.warning("Unknown HomeAssistant API access!") + elif not addon.access_homeassistant_api: + _LOGGER.warning("Not permitted API access: %s", addon.slug) else: _LOGGER.info("%s access from %s", request.path, addon.slug) + return + raise HTTPUnauthorized() + + @asynccontextmanager async def _api_client(self, request, path, timeout=300): """Return a client request with proxy origin for Home-Assistant.""" - url = f"{self.sys_homeassistant.api_url}/api/{path}" - try: - data = None - headers = {} - method = getattr(self.sys_websession_ssl, request.method.lower()) - params = request.query or None - # read data with async_timeout.timeout(30): data = await request.read() if data: - headers.update({CONTENT_TYPE: request.content_type}) + content_type = request.content_type + else: + content_type = None - # need api password? - if self.sys_homeassistant.api_password: - headers = { - HEADER_HA_ACCESS: self.sys_homeassistant.api_password, - } - - # reset headers - if not headers: - headers = None - - client = await method( - url, data=data, headers=headers, timeout=timeout, - params=params - ) - - return client + async with self.sys_homeassistant.make_request( + request.method.lower(), f'api/{path}', + content_type=content_type, + data=data, + timeout=timeout, + ) as resp: + yield resp + return + except HomeAssistantAuthError: + _LOGGER.error("Authenticate error on API for request %s", path) except aiohttp.ClientError as err: - _LOGGER.error("Client error on API %s request %s.", path, 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) + _LOGGER.error("Client timeout error on API request %s", path) raise HTTPBadGateway() @@ -74,30 +71,29 @@ class APIProxy(CoreSysAttributes): self._check_access(request) _LOGGER.info("Home-Assistant EventStream start") - client = await self._api_client(request, 'stream', timeout=None) + async with self._api_client(request, 'stream', timeout=None) as client: + 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 + await response.write(data) - 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 - await response.write(data) + except aiohttp.ClientError: + await response.write_eof() - except aiohttp.ClientError: - await response.write_eof() + except asyncio.CancelledError: + pass - except asyncio.CancelledError: - pass + finally: + client.close() + _LOGGER.info("Home-Assistant EventStream close") - finally: - client.close() - _LOGGER.info("Home-Assistant EventStream close") - - return response + return response async def api(self, request): """Proxy HomeAssistant API Requests.""" @@ -105,14 +101,13 @@ class APIProxy(CoreSysAttributes): # Normal request path = request.match_info.get('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 with self._api_client(request, path) as client: + 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.""" @@ -123,19 +118,44 @@ class APIProxy(CoreSysAttributes): url, heartbeat=60, verify_ssl=False) # 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.sys_homeassistant.api_password, - }) + data = await client.receive_json() - _LOGGER.error("Authentication to Home-Assistant websocket") + if data.get('type') == 'auth_ok': + return client - except (aiohttp.ClientError, RuntimeError) as err: + if data.get('type') != 'auth_required': + # Invalid protocol + _LOGGER.error( + 'Got unexpected response from HA websocket: %s', data) + raise HTTPBadGateway() + + if self.sys_homeassistant.refresh_token: + await self.sys_homeassistant.ensure_access_token() + await client.send_json({ + 'type': 'auth', + 'access_token': self.sys_homeassistant.access_token, + }) + else: + await client.send_json({ + 'type': 'auth', + 'api_password': self.sys_homeassistant.api_password, + }) + + data = await client.receive_json() + + if data.get('type') == 'auth_ok': + return client + + # Renew the Token is invalid + if (data.get('type') == 'invalid_auth' and + self.sys_homeassistant.refresh_token): + self.sys_homeassistant.access_token = None + return await self._websocket_client() + + _LOGGER.error( + "Failed authentication to Home-Assistant websocket: %s", data) + + except (RuntimeError, HomeAssistantAPIError) as err: _LOGGER.error("Client error on websocket API %s.", err) raise HTTPBadGateway() @@ -157,13 +177,19 @@ class APIProxy(CoreSysAttributes): # Check API access response = await server.receive_json() - hassio_token = response.get('api_password') + hassio_token = (response.get('api_password') or + response.get('access_token')) addon = self.sys_addons.from_uuid(hassio_token) - if not addon: + if not addon or not addon.access_homeassistant_api: _LOGGER.warning("Unauthorized websocket access!") - else: - _LOGGER.info("Websocket access from %s", addon.slug) + await server.send_json({ + 'type': 'auth_invalid', + 'message': 'Invalid access', + }) + return server + + _LOGGER.info("Websocket access from %s", addon.slug) await server.send_json({ 'type': 'auth_ok', diff --git a/hassio/const.py b/hassio/const.py index fe9a1f80a..1b664dd26 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -50,7 +50,7 @@ CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_TEXT = 'text/plain' CONTENT_TYPE_TAR = 'application/tar' HEADER_HA_ACCESS = 'x-ha-access' -HEADER_TOKEN = 'X-HASSIO-KEY' +HEADER_TOKEN = 'x-hassio-key' ENV_TOKEN = 'HASSIO_TOKEN' ENV_TIME = 'TZ' @@ -174,6 +174,7 @@ ATTR_DEVICETREE = 'devicetree' ATTR_CPE = 'cpe' ATTR_BOARD = 'board' ATTR_HASSOS = 'hassos' +ATTR_REFRESH_TOKEN = 'refresh_token' SERVICE_MQTT = 'mqtt' diff --git a/hassio/exceptions.py b/hassio/exceptions.py index bfa8fb899..cd9499ba3 100644 --- a/hassio/exceptions.py +++ b/hassio/exceptions.py @@ -1,4 +1,7 @@ """Core Exceptions.""" +import asyncio + +import aiohttp class HassioError(Exception): @@ -11,6 +14,29 @@ class HassioNotSupportedError(HassioError): pass +# HomeAssistant + +class HomeAssistantError(HassioError): + """Home Assistant exception.""" + pass + + +class HomeAssistantUpdateError(HomeAssistantError): + """Error on update of a Home Assistant.""" + pass + + +class HomeAssistantAuthError(HomeAssistantError): + """Home Assistant Auth API exception.""" + pass + + +class HomeAssistantAPIError( + HomeAssistantAuthError, asyncio.TimeoutError, aiohttp.ClientError): + """Home Assistant API exception.""" + pass + + # HassOS class HassOSError(HassioError): diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index bb2ea661c..404b0686a 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -1,5 +1,6 @@ """HomeAssistant control object.""" import asyncio +from contextlib import asynccontextmanager, suppress import logging import os import re @@ -7,15 +8,19 @@ import socket import time import aiohttp -from aiohttp.hdrs import CONTENT_TYPE +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, HEADER_HA_ACCESS, CONTENT_TYPE_JSON) + ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN, + HEADER_HA_ACCESS) from .coresys import CoreSysAttributes from .docker.homeassistant import DockerHomeAssistant +from .exceptions import ( + HomeAssistantUpdateError, HomeAssistantError, HomeAssistantAPIError, + HomeAssistantAuthError) from .utils import convert_to_ascii, process_lock from .utils.json import JsonConfig from .validate import SCHEMA_HASS_CONFIG @@ -25,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") # pylint: disable=invalid-name -ConfigResult = attr.make_class('ConfigResult', ['valid', 'log']) +ConfigResult = attr.make_class('ConfigResult', ['valid', 'log'], frozen=True) class HomeAssistant(JsonConfig, CoreSysAttributes): @@ -38,6 +43,8 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): 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 async def load(self): """Prepare HomeAssistant object.""" @@ -175,6 +182,16 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): """Return a UUID of this HomeAssistant.""" return self._data[ATTR_UUID] + @property + def refresh_token(self): + """Return the refresh token to authenticate with HomeAssistant.""" + return self._data.get(ATTR_REFRESH_TOKEN) + + @refresh_token.setter + def refresh_token(self, value): + """Set Home Assistant refresh_token.""" + self._data[ATTR_REFRESH_TOKEN] = value + @process_lock async def install_landingpage(self): """Install a landingpage.""" @@ -186,7 +203,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await asyncio.sleep(60) # Run landingpage after installation - await self._start() + _LOGGER.info("Start landingpage") + try: + await self._start() + except HomeAssistantError: + _LOGGER.warning("Can't start landingpage") @process_lock async def install(self): @@ -205,9 +226,15 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): # finishing _LOGGER.info("HomeAssistant docker now installed") - if self.boot: + try: + if not self.boot: + return + _LOGGER.info("Start HomeAssistant") await self._start() - await self.instance.cleanup() + except HomeAssistantError: + _LOGGER.error("Can't start HomeAssistant!") + finally: + await self.instance.cleanup() @process_lock async def update(self, version=None): @@ -219,32 +246,37 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): if exists and version == self.instance.version: _LOGGER.warning("Version %s is already installed", version) - return False + return HomeAssistantUpdateError() # process a update async def _update(to_version): """Run Home Assistant update.""" try: - return await self.instance.update(to_version) + _LOGGER.info("Update HomeAssistant to version %s", to_version) + if not await self.instance.update(to_version): + raise HomeAssistantUpdateError() finally: if running: await self._start() + _LOGGER.info("Successfull run HomeAssistant %s", to_version) # Update Home Assistant - ret = await _update(version) + with suppress(HomeAssistantError): + await _update(version) + return # Update going wrong, revert it if self.error_state and rollback: - _LOGGER.fatal("Home Assistant update fails -> rollback!") - ret = await _update(rollback) - - return ret + _LOGGER.fatal("HomeAssistant update fails -> rollback!") + await _update(rollback) + else: + raise HomeAssistantUpdateError() async def _start(self): """Start HomeAssistant docker & wait.""" if not await self.instance.run(): - return False - return await self._block_till_run() + raise HomeAssistantError() + await self._block_till_run() @process_lock def start(self): @@ -266,7 +298,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): async def restart(self): """Restart HomeAssistant docker.""" await self.instance.stop() - return await self._start() + await self._start() def logs(self): """Get HomeAssistant docker logs. @@ -309,7 +341,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): # if not valid if result.exit_code is None: - return ConfigResult(False, "") + raise HomeAssistantError() # parse output log = convert_to_ascii(result.output) @@ -317,51 +349,84 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): return ConfigResult(False, log) return ConfigResult(True, log) - async def check_api_state(self): - """Check if Home-Assistant up and running.""" - url = f"{self.api_url}/api/" - header = {CONTENT_TYPE: CONTENT_TYPE_JSON} + async def ensure_access_token(self): + """Ensures there is an access token.""" + if self.access_token is not None: + return - if self.api_password: - header.update({HEADER_HA_ACCESS: self.api_password}) - - try: - # pylint: disable=bad-continuation + with suppress(asyncio.TimeoutError, aiohttp.ClientError): async with self.sys_websession_ssl.get( - url, headers=header, timeout=30) as request: - status = request.status + f"{self.api_url}/auth/token", + timeout=30, + data={ + "grant_type": "refresh_token", + "refresh_token": self.refresh_token + } + ) as resp: + if resp.status != 200: + _LOGGER.error("Authenticate problem with HomeAssistant!") + raise HomeAssistantAuthError() + tokens = await resp.json() + self.access_token = tokens['access_token'] + return - except (asyncio.TimeoutError, aiohttp.ClientError): - return False + _LOGGER.error("Can't update HomeAssistant access token!") + raise HomeAssistantAPIError() - if status not in (200, 201): - _LOGGER.warning("Home-Assistant API config missmatch") - return True + @asynccontextmanager + async def make_request(self, method, path, json=None, content_type=None, + data=None, timeout=30): + """Async context manager to make a request with right auth.""" + url = f"{self.api_url}/{path}" + headers = {} + + if content_type is not None: + headers[hdrs.CONTENT_TYPE] = content_type + + elif self.api_password: + headers[HEADER_HA_ACCESS] = self.api_password + + for _ in (1, 2): + # Prepare Access token + if self.refresh_token: + await self.ensure_access_token() + headers[hdrs.AUTHORIZATION] = f'Bearer {self.access_token}' + + async with getattr(self.sys_websession_ssl, method)( + url, timeout=timeout, json=json + ) as resp: + # Access token expired + if resp.status == 401 and self.refresh_token: + self.access_token = None + continue + yield resp + return + + raise HomeAssistantAPIError() + + async def check_api_state(self): + """Return True if Home-Assistant up and running.""" + with suppress(HomeAssistantAPIError): + async with self.make_request('get', 'api/') as resp: + if resp.status in (200, 201): + return True + err = resp.status + + _LOGGER.warning("Home-Assistant API config missmatch: %d", err) + return False 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} + with suppress(HomeAssistantAPIError): + async with self.make_request( + 'get', f'api/events/{event_type}' + ) as resp: + if resp.status in (200, 201): + return + err = resp.status - if self.api_password: - header.update({HEADER_HA_ACCESS: self.api_password}) - - try: - # pylint: disable=bad-continuation - async with self.sys_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 + _LOGGER.warning("HomeAssistant event %s fails: %s", event_type, err) + return HomeAssistantError() async def _block_till_run(self): """Block until Home-Assistant is booting up or startup timeout.""" @@ -374,27 +439,28 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): result = sock.connect_ex((str(self.api_ip), self.api_port)) sock.close() + # Check if the port is available if result == 0: return True - return False except OSError: pass + return False while time.monotonic() - start_time < self.wait_boot: # Check if API response if await self.sys_run_in_executor(check_port): - _LOGGER.info("Detect a running Home-Assistant instance") + _LOGGER.info("Detect a running HomeAssistant instance") self._error_state = False - return True + return + + # wait and don't hit the system + await asyncio.sleep(10) # Check if Container is is_running if not await self.instance.is_running(): _LOGGER.error("Home Assistant is crashed!") break - # wait and don't hit the system - await asyncio.sleep(10) - - _LOGGER.warning("Don't wait anymore of Home-Assistant startup!") + _LOGGER.warning("Don't wait anymore of HomeAssistant startup!") self._error_state = True - return False + raise HomeAssistantError() diff --git a/hassio/tasks.py b/hassio/tasks.py index f83132f02..ff1a50092 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -131,5 +131,7 @@ class Tasks(CoreSysAttributes): return _LOGGER.error("Watchdog found a problem with Home-Assistant API!") - await self.sys_homeassistant.restart() - self._cache[HASS_WATCHDOG_API] = 0 + try: + await self.sys_homeassistant.restart() + finally: + self._cache[HASS_WATCHDOG_API] = 0 diff --git a/hassio/validate.py b/hassio/validate.py index e31164d5a..fc2040eda 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -9,7 +9,8 @@ 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_WAIT_BOOT, ATTR_UUID, CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV) + ATTR_WAIT_BOOT, ATTR_UUID, ATTR_REFRESH_TOKEN, + CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV) RE_REPOSITORY = re.compile(r"^(?P[^#]+)(?:#(?P[\w\-]+))?$") @@ -88,6 +89,7 @@ SCHEMA_HASS_CONFIG = vol.Schema({ 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): diff --git a/pylintrc b/pylintrc index dde7e395e..6bbac9854 100644 --- a/pylintrc +++ b/pylintrc @@ -33,7 +33,8 @@ disable= too-few-public-methods, abstract-method, no-else-return, - useless-return + useless-return, + not-async-context-manager [EXCEPTIONS] overgeneral-exceptions=Exception,HomeAssistantError diff --git a/setup.py b/setup.py index 67ac382d4..8214c8a27 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ setup( 'attr==0.3.1', 'async_timeout==3.0.0', 'aiohttp==3.3.2', - 'docker==3.3.0', + 'docker==3.4.0', 'colorlog==3.1.2', 'voluptuous==0.11.1', 'gitpython==2.1.10',