Merge pull request #580 from home-assistant/dev

Release 116
This commit is contained in:
Pascal Vizeli 2018-07-21 00:05:56 +02:00 committed by GitHub
commit 1ec1082068
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 443 additions and 182 deletions

4
.github/release-drafter.yml vendored Normal file
View File

@ -0,0 +1,4 @@
template: |
## What's Changed
$CHANGES

View File

@ -1,12 +1,6 @@
sudo: false sudo: true
matrix: dist: xenial
fast_finish: true
include:
- python: "3.6"
cache:
directories:
- $HOME/.cache/pip
install: pip install -U tox install: pip install -U tox
language: python language: python
python: 3.7
script: tox script: tox

9
API.md
View File

@ -273,7 +273,9 @@ return:
```json ```json
{ {
"version": "2.3", "version": "2.3",
"version_cli": "7",
"version_latest": "2.4", "version_latest": "2.4",
"version_cli_latest": "8",
"board": "ova|rpi" "board": "ova|rpi"
} }
``` ```
@ -285,6 +287,13 @@ return:
} }
``` ```
- POST `/hassos/update/cli`
```json
{
"version": "optional"
}
```
- POST `/hassos/config/sync` - POST `/hassos/config/sync`
Load host configs from a USB stick. Load host configs from a USB stick.

View File

@ -6,7 +6,6 @@ ENV LANG C.UTF-8
# Setup base # Setup base
RUN apk add --no-cache \ RUN apk add --no-cache \
python3 \
git \ git \
socat \ socat \
glib \ glib \
@ -14,12 +13,11 @@ RUN apk add --no-cache \
eudev-libs \ eudev-libs \
&& apk add --no-cache --virtual .build-dependencies \ && apk add --no-cache --virtual .build-dependencies \
make \ make \
python3-dev \
g++ \ g++ \
&& pip3 install --no-cache-dir \ && pip3 install --no-cache-dir \
uvloop==0.10.2 \ uvloop==0.10.2 \
cchardet==2.1.1 \ cchardet==2.1.1 \
pycryptodome==3.4.11 \ pycryptodome==3.6.4 \
&& apk del .build-dependencies && apk del .build-dependencies
# Install HassIO # Install HassIO

View File

@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor
import logging import logging
import sys import sys
import hassio.bootstrap as bootstrap from hassio import bootstrap
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@ -14,7 +14,7 @@ from voluptuous.humanize import humanize_error
from .validate import ( from .validate import (
validate_options, SCHEMA_ADDON_SNAPSHOT, RE_VOLUME, RE_SERVICE) validate_options, SCHEMA_ADDON_SNAPSHOT, RE_VOLUME, RE_SERVICE)
from .utils import check_installed from .utils import check_installed, remove_data
from ..const import ( from ..const import (
ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_BOOT, ATTR_MAP, ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_BOOT, ATTR_MAP,
ATTR_OPTIONS, ATTR_PORTS, ATTR_SCHEMA, ATTR_IMAGE, ATTR_REPOSITORY, ATTR_OPTIONS, ATTR_PORTS, ATTR_SCHEMA, ATTR_IMAGE, ATTR_REPOSITORY,
@ -636,7 +636,7 @@ class Addon(CoreSysAttributes):
if self.path_data.is_dir(): if self.path_data.is_dir():
_LOGGER.info( _LOGGER.info(
"Remove Home-Assistant addon data folder %s", self.path_data) "Remove Home-Assistant addon data folder %s", self.path_data)
shutil.rmtree(str(self.path_data)) await remove_data(self.path_data)
# Cleanup audio settings # Cleanup audio settings
if self.path_asound.exists(): if self.path_asound.exists():
@ -856,12 +856,12 @@ class Addon(CoreSysAttributes):
# restore data # restore data
def _restore_data(): def _restore_data():
"""Restore data.""" """Restore data."""
if self.path_data.is_dir():
shutil.rmtree(str(self.path_data), ignore_errors=True)
shutil.copytree(str(Path(temp, "data")), str(self.path_data)) shutil.copytree(str(Path(temp, "data")), str(self.path_data))
_LOGGER.info("Restore data for addon %s", self._id)
if self.path_data.is_dir():
await remove_data(self.path_data)
try: try:
_LOGGER.info("Restore data for addon %s", self._id)
await self.sys_run_in_executor(_restore_data) await self.sys_run_in_executor(_restore_data)
except shutil.Error as err: except shutil.Error as err:
_LOGGER.error("Can't restore origin data: %s", err) _LOGGER.error("Can't restore origin data: %s", err)

View File

@ -1,4 +1,5 @@
"""Util addons functions.""" """Util addons functions."""
import asyncio
import hashlib import hashlib
import logging import logging
import re import re
@ -33,3 +34,20 @@ def check_installed(method):
return await method(addon, *args, **kwargs) return await method(addon, *args, **kwargs)
return wrap_check return wrap_check
async def remove_data(folder):
"""Remove folder and reset privileged."""
try:
proc = await asyncio.create_subprocess_exec(
"rm", "-rf", str(folder),
stdout=asyncio.subprocess.DEVNULL
)
_, error_msg = await proc.communicate()
except OSError as err:
error_msg = str(err)
if proc.returncode == 0:
return
_LOGGER.error("Can't remove Add-on Data: %s", error_msg)

View File

@ -76,6 +76,7 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes([ self.webapp.add_routes([
web.get('/hassos/info', api_hassos.info), web.get('/hassos/info', api_hassos.info),
web.post('/hassos/update', api_hassos.update), web.post('/hassos/update', api_hassos.update),
web.post('/hassos/update/cli', api_hassos.update_cli),
web.post('/hassos/config/sync', api_hassos.config_sync), web.post('/hassos/config/sync', api_hassos.config_sync),
]) ])

View File

@ -5,7 +5,9 @@ import logging
import voluptuous as vol import voluptuous as vol
from .utils import api_process, api_validate from .utils import api_process, api_validate
from ..const import ATTR_VERSION, ATTR_BOARD, ATTR_VERSION_LATEST from ..const import (
ATTR_VERSION, ATTR_BOARD, ATTR_VERSION_LATEST, ATTR_VERSION_CLI,
ATTR_VERSION_CLI_LATEST)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -23,7 +25,9 @@ class APIHassOS(CoreSysAttributes):
"""Return hassos information.""" """Return hassos information."""
return { return {
ATTR_VERSION: self.sys_hassos.version, ATTR_VERSION: self.sys_hassos.version,
ATTR_VERSION_CLI: self.sys_hassos.version_cli,
ATTR_VERSION_LATEST: self.sys_hassos.version_latest, ATTR_VERSION_LATEST: self.sys_hassos.version_latest,
ATTR_VERSION_CLI_LATEST: self.sys_hassos.version_cli_latest,
ATTR_BOARD: self.sys_hassos.board, ATTR_BOARD: self.sys_hassos.board,
} }
@ -35,6 +39,14 @@ class APIHassOS(CoreSysAttributes):
await asyncio.shield(self.sys_hassos.update(version)) await asyncio.shield(self.sys_hassos.update(version))
@api_process
async def update_cli(self, request):
"""Update HassOS CLI."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_cli_latest)
await asyncio.shield(self.sys_hassos.update_cli(version))
@api_process @api_process
def config_sync(self, request): def config_sync(self, request):
"""Trigger config reload on HassOS.""" """Trigger config reload on HassOS."""

View File

@ -10,7 +10,7 @@ from ..const import (
ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT, ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT,
ATTR_MEMORY_USAGE, ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX, ATTR_MEMORY_USAGE, ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX,
ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_WAIT_BOOT, ATTR_MACHINE, 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 ..coresys import CoreSysAttributes
from ..validate import NETWORK_PORT, DOCKER_IMAGE from ..validate import NETWORK_PORT, DOCKER_IMAGE
@ -30,6 +30,8 @@ SCHEMA_OPTIONS = vol.Schema({
vol.Optional(ATTR_WATCHDOG): vol.Boolean(), vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT): vol.Optional(ATTR_WAIT_BOOT):
vol.All(vol.Coerce(int), vol.Range(min=60)), 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({ SCHEMA_VERSION = vol.Schema({
@ -83,8 +85,10 @@ class APIHomeAssistant(CoreSysAttributes):
if ATTR_WAIT_BOOT in body: if ATTR_WAIT_BOOT in body:
self.sys_homeassistant.wait_boot = body[ATTR_WAIT_BOOT] 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() self.sys_homeassistant.save_data()
return True
@api_process @api_process
async def stats(self, request): async def stats(self, request):
@ -109,11 +113,7 @@ class APIHomeAssistant(CoreSysAttributes):
body = await api_validate(SCHEMA_VERSION, request) body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_homeassistant.last_version) version = body.get(ATTR_VERSION, self.sys_homeassistant.last_version)
if version == self.sys_homeassistant.version: await asyncio.shield(self.sys_homeassistant.update(version))
raise RuntimeError("Version {} is already in use".format(version))
return await asyncio.shield(
self.sys_homeassistant.update(version))
@api_process @api_process
def stop(self, request): def stop(self, request):
@ -123,7 +123,7 @@ class APIHomeAssistant(CoreSysAttributes):
@api_process @api_process
def start(self, request): def start(self, request):
"""Start homeassistant.""" """Start homeassistant."""
return asyncio.shield(self.sys_homeassistant.start()) asyncio.shield(self.sys_homeassistant.start())
@api_process @api_process
def restart(self, request): def restart(self, request):

View File

@ -1,15 +1,18 @@
"""Utils for HomeAssistant Proxy.""" """Utils for HomeAssistant Proxy."""
import asyncio import asyncio
from contextlib import asynccontextmanager
import logging import logging
import aiohttp import aiohttp
from aiohttp import web 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 from aiohttp.hdrs import CONTENT_TYPE
import async_timeout import async_timeout
from ..const import HEADER_HA_ACCESS from ..const import HEADER_HA_ACCESS
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import HomeAssistantAuthError, HomeAssistantAPIError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -23,49 +26,43 @@ class APIProxy(CoreSysAttributes):
addon = self.sys_addons.from_uuid(hassio_token) addon = self.sys_addons.from_uuid(hassio_token)
if not addon: 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: else:
_LOGGER.info("%s access from %s", request.path, addon.slug) _LOGGER.info("%s access from %s", request.path, addon.slug)
return
raise HTTPUnauthorized()
@asynccontextmanager
async def _api_client(self, request, path, timeout=300): async def _api_client(self, request, path, timeout=300):
"""Return a client request with proxy origin for Home-Assistant.""" """Return a client request with proxy origin for Home-Assistant."""
url = f"{self.sys_homeassistant.api_url}/api/{path}"
try: try:
data = None
headers = {}
method = getattr(self.sys_websession_ssl, request.method.lower())
params = request.query or None
# read data # read data
with async_timeout.timeout(30): with async_timeout.timeout(30):
data = await request.read() data = await request.read()
if data: if data:
headers.update({CONTENT_TYPE: request.content_type}) content_type = request.content_type
else:
content_type = None
# need api password? async with self.sys_homeassistant.make_request(
if self.sys_homeassistant.api_password: request.method.lower(), f'api/{path}',
headers = { content_type=content_type,
HEADER_HA_ACCESS: self.sys_homeassistant.api_password, data=data,
} timeout=timeout,
) as resp:
# reset headers yield resp
if not headers: return
headers = None
client = await method(
url, data=data, headers=headers, timeout=timeout,
params=params
)
return client
except HomeAssistantAuthError:
_LOGGER.error("Authenticate error on API for request %s", path)
except aiohttp.ClientError as err: 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: 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() raise HTTPBadGateway()
@ -74,30 +71,25 @@ class APIProxy(CoreSysAttributes):
self._check_access(request) self._check_access(request)
_LOGGER.info("Home-Assistant EventStream start") _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:
break
await response.write(data)
response = web.StreamResponse() except aiohttp.ClientError:
response.content_type = request.headers.get(CONTENT_TYPE) pass
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: finally:
await response.write_eof() client.close()
_LOGGER.info("Home-Assistant EventStream close")
except asyncio.CancelledError: return response
pass
finally:
client.close()
_LOGGER.info("Home-Assistant EventStream close")
return response
async def api(self, request): async def api(self, request):
"""Proxy HomeAssistant API Requests.""" """Proxy HomeAssistant API Requests."""
@ -105,14 +97,13 @@ class APIProxy(CoreSysAttributes):
# Normal request # Normal request
path = request.match_info.get('path', '') path = request.match_info.get('path', '')
client = await self._api_client(request, path) async with self._api_client(request, path) as client:
data = await client.read()
data = await client.read() return web.Response(
return web.Response( body=data,
body=data, status=client.status,
status=client.status, content_type=client.content_type
content_type=client.content_type )
)
async def _websocket_client(self): async def _websocket_client(self):
"""Initialize a websocket api connection.""" """Initialize a websocket api connection."""
@ -123,19 +114,44 @@ class APIProxy(CoreSysAttributes):
url, heartbeat=60, verify_ssl=False) url, heartbeat=60, verify_ssl=False)
# handle authentication # handle authentication
for _ in range(2): data = await client.receive_json()
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,
})
_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) _LOGGER.error("Client error on websocket API %s.", err)
raise HTTPBadGateway() raise HTTPBadGateway()
@ -157,13 +173,19 @@ class APIProxy(CoreSysAttributes):
# Check API access # Check API access
response = await server.receive_json() 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) 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!") _LOGGER.warning("Unauthorized websocket access!")
else: await server.send_json({
_LOGGER.info("Websocket access from %s", addon.slug) 'type': 'auth_invalid',
'message': 'Invalid access',
})
return server
_LOGGER.info("Websocket access from %s", addon.slug)
await server.send_json({ await server.send_json({
'type': 'auth_ok', 'type': 'auth_ok',

View File

@ -2,7 +2,7 @@
from pathlib import Path from pathlib import Path
from ipaddress import ip_network from ipaddress import ip_network
HASSIO_VERSION = '115' HASSIO_VERSION = '116'
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
URL_HASSIO_VERSION = \ URL_HASSIO_VERSION = \
@ -50,7 +50,7 @@ CONTENT_TYPE_JSON = 'application/json'
CONTENT_TYPE_TEXT = 'text/plain' CONTENT_TYPE_TEXT = 'text/plain'
CONTENT_TYPE_TAR = 'application/tar' CONTENT_TYPE_TAR = 'application/tar'
HEADER_HA_ACCESS = 'x-ha-access' HEADER_HA_ACCESS = 'x-ha-access'
HEADER_TOKEN = 'X-HASSIO-KEY' HEADER_TOKEN = 'x-hassio-key'
ENV_TOKEN = 'HASSIO_TOKEN' ENV_TOKEN = 'HASSIO_TOKEN'
ENV_TIME = 'TZ' ENV_TIME = 'TZ'
@ -174,6 +174,10 @@ ATTR_DEVICETREE = 'devicetree'
ATTR_CPE = 'cpe' ATTR_CPE = 'cpe'
ATTR_BOARD = 'board' ATTR_BOARD = 'board'
ATTR_HASSOS = 'hassos' ATTR_HASSOS = 'hassos'
ATTR_HASSOS_CLI = 'hassos_cli'
ATTR_VERSION_CLI = 'version_cli'
ATTR_VERSION_CLI_LATEST = 'version_cli_latest'
ATTR_REFRESH_TOKEN = 'refresh_token'
SERVICE_MQTT = 'mqtt' SERVICE_MQTT = 'mqtt'

View File

@ -26,6 +26,9 @@ class HassIO(CoreSysAttributes):
self.sys_config.timezone = \ self.sys_config.timezone = \
await fetch_timezone(self.sys_websession) await fetch_timezone(self.sys_websession)
# Load Supervisor
await self.sys_supervisor.load()
# Load DBus # Load DBus
await self.sys_dbus.load() await self.sys_dbus.load()
@ -35,9 +38,6 @@ class HassIO(CoreSysAttributes):
# Load HassOS # Load HassOS
await self.sys_hassos.load() await self.sys_hassos.load()
# Load Supervisor
await self.sys_supervisor.load()
# Load Home Assistant # Load Home Assistant
await self.sys_homeassistant.load() await self.sys_homeassistant.load()

View File

@ -0,0 +1,37 @@
"""HassOS Cli docker object."""
import logging
import docker
from .interface import DockerInterface
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
class DockerHassOSCli(DockerInterface, CoreSysAttributes):
"""Docker hassio wrapper for HassOS Cli."""
@property
def image(self):
"""Return name of HassOS cli image."""
return f"homeassistant/{self.sys_arch}-hassio-cli"
def _stop(self):
"""Don't need stop."""
return True
def _attach(self):
"""Attach to running docker container.
Need run inside executor.
"""
try:
image = self.sys_docker.images.get(self.image)
except docker.errors.DockerException:
_LOGGER.warning("Can't find a HassOS cli %s", self.image)
else:
self._meta = image.attrs
_LOGGER.info("Found HassOS cli %s with version %s",
self.image, self.version)

View File

@ -11,7 +11,7 @@ _LOGGER = logging.getLogger(__name__)
class DockerSupervisor(DockerInterface, CoreSysAttributes): class DockerSupervisor(DockerInterface, CoreSysAttributes):
"""Docker hassio wrapper for HomeAssistant.""" """Docker hassio wrapper for Supervisor."""
@property @property
def name(self): def name(self):

View File

@ -1,4 +1,7 @@
"""Core Exceptions.""" """Core Exceptions."""
import asyncio
import aiohttp
class HassioError(Exception): class HassioError(Exception):
@ -11,6 +14,29 @@ class HassioNotSupportedError(HassioError):
pass 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 # HassOS
class HassOSError(HassioError): class HassOSError(HassioError):

View File

@ -7,6 +7,7 @@ from cpe import CPE
from .coresys import CoreSysAttributes from .coresys import CoreSysAttributes
from .const import URL_HASSOS_OTA from .const import URL_HASSOS_OTA
from .docker.hassos_cli import DockerHassOSCli
from .exceptions import HassOSNotSupportedError, HassOSUpdateError, DBusError from .exceptions import HassOSNotSupportedError, HassOSUpdateError, DBusError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -18,6 +19,7 @@ class HassOS(CoreSysAttributes):
def __init__(self, coresys): def __init__(self, coresys):
"""Initialize HassOS handler.""" """Initialize HassOS handler."""
self.coresys = coresys self.coresys = coresys
self.instance = DockerHassOSCli(coresys)
self._available = False self._available = False
self._version = None self._version = None
self._board = None self._board = None
@ -32,11 +34,31 @@ class HassOS(CoreSysAttributes):
"""Return version of HassOS.""" """Return version of HassOS."""
return self._version return self._version
@property
def version_cli(self):
"""Return version of HassOS cli."""
return self.instance.version
@property @property
def version_latest(self): def version_latest(self):
"""Return version of HassOS.""" """Return version of HassOS."""
return self.sys_updater.version_hassos return self.sys_updater.version_hassos
@property
def version_cli_latest(self):
"""Return version of HassOS."""
return self.sys_updater.version_hassos_cli
@property
def need_update(self):
"""Return true if a HassOS update is available."""
return self.version != self.version_latest
@property
def need_cli_update(self):
"""Return true if a HassOS cli update is available."""
return self.version_cli != self.version_cli_latest
@property @property
def board(self): def board(self):
"""Return board name.""" """Return board name."""
@ -56,6 +78,10 @@ class HassOS(CoreSysAttributes):
try: try:
_LOGGER.info("Fetch OTA update from %s", url) _LOGGER.info("Fetch OTA update from %s", url)
async with self.sys_websession.get(url) as request: async with self.sys_websession.get(url) as request:
if request.status != 200:
raise HassOSUpdateError()
# Download RAUCB file
with raucb.open('wb') as ota_file: with raucb.open('wb') as ota_file:
while True: while True:
chunk = await request.content.read(1048576) chunk = await request.content.read(1048576)
@ -86,7 +112,7 @@ class HassOS(CoreSysAttributes):
cpe = CPE(self.sys_host.info.cpe) cpe = CPE(self.sys_host.info.cpe)
assert cpe.get_product()[0] == 'hassos' assert cpe.get_product()[0] == 'hassos'
except (AssertionError, NotImplementedError): except (AssertionError, NotImplementedError):
_LOGGER.debug("Ignore HassOS") _LOGGER.debug("Found no HassOS")
return return
# Store meta data # Store meta data
@ -95,6 +121,7 @@ class HassOS(CoreSysAttributes):
self._board = cpe.get_target_hardware()[0] self._board = cpe.get_target_hardware()[0]
_LOGGER.info("Detect HassOS %s on host system", self.version) _LOGGER.info("Detect HassOS %s on host system", self.version)
await self.instance.attach()
def config_sync(self): def config_sync(self):
"""Trigger a host config reload from usb. """Trigger a host config reload from usb.
@ -142,3 +169,17 @@ class HassOS(CoreSysAttributes):
_LOGGER.error( _LOGGER.error(
"HassOS update fails with: %s", rauc_status.get('LastError')) "HassOS update fails with: %s", rauc_status.get('LastError'))
raise HassOSUpdateError() raise HassOSUpdateError()
async def update_cli(self, version=None):
"""Update local HassOS cli."""
version = version or self.version_cli_latest
if version == self.version_cli:
_LOGGER.warning("Version %s is already installed for CLI", version)
raise HassOSUpdateError()
if await self.instance.update(version):
return
_LOGGER.error("HassOS CLI update fails.")
raise HassOSUpdateError()

View File

@ -1,5 +1,6 @@
"""HomeAssistant control object.""" """HomeAssistant control object."""
import asyncio import asyncio
from contextlib import asynccontextmanager, suppress
import logging import logging
import os import os
import re import re
@ -7,15 +8,19 @@ import socket
import time import time
import aiohttp import aiohttp
from aiohttp.hdrs import CONTENT_TYPE from aiohttp import hdrs
import attr import attr
from .const import ( from .const import (
FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID, FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID,
ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, 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 .coresys import CoreSysAttributes
from .docker.homeassistant import DockerHomeAssistant from .docker.homeassistant import DockerHomeAssistant
from .exceptions import (
HomeAssistantUpdateError, HomeAssistantError, HomeAssistantAPIError,
HomeAssistantAuthError)
from .utils import convert_to_ascii, process_lock from .utils import convert_to_ascii, process_lock
from .utils.json import JsonConfig from .utils.json import JsonConfig
from .validate import SCHEMA_HASS_CONFIG from .validate import SCHEMA_HASS_CONFIG
@ -25,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")
# pylint: disable=invalid-name # pylint: disable=invalid-name
ConfigResult = attr.make_class('ConfigResult', ['valid', 'log']) ConfigResult = attr.make_class('ConfigResult', ['valid', 'log'], frozen=True)
class HomeAssistant(JsonConfig, CoreSysAttributes): class HomeAssistant(JsonConfig, CoreSysAttributes):
@ -38,6 +43,8 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
self.instance = DockerHomeAssistant(coresys) self.instance = DockerHomeAssistant(coresys)
self.lock = asyncio.Lock(loop=coresys.loop) self.lock = asyncio.Lock(loop=coresys.loop)
self._error_state = False 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): async def load(self):
"""Prepare HomeAssistant object.""" """Prepare HomeAssistant object."""
@ -175,6 +182,16 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
"""Return a UUID of this HomeAssistant.""" """Return a UUID of this HomeAssistant."""
return self._data[ATTR_UUID] 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 @process_lock
async def install_landingpage(self): async def install_landingpage(self):
"""Install a landingpage.""" """Install a landingpage."""
@ -186,7 +203,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
await asyncio.sleep(60) await asyncio.sleep(60)
# Run landingpage after installation # 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 @process_lock
async def install(self): async def install(self):
@ -205,9 +226,15 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
# finishing # finishing
_LOGGER.info("HomeAssistant docker now installed") _LOGGER.info("HomeAssistant docker now installed")
if self.boot: try:
if not self.boot:
return
_LOGGER.info("Start HomeAssistant")
await self._start() await self._start()
await self.instance.cleanup() except HomeAssistantError:
_LOGGER.error("Can't start HomeAssistant!")
finally:
await self.instance.cleanup()
@process_lock @process_lock
async def update(self, version=None): async def update(self, version=None):
@ -219,32 +246,37 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
if exists and version == self.instance.version: if exists and version == self.instance.version:
_LOGGER.warning("Version %s is already installed", version) _LOGGER.warning("Version %s is already installed", version)
return False return HomeAssistantUpdateError()
# process a update # process a update
async def _update(to_version): async def _update(to_version):
"""Run Home Assistant update.""" """Run Home Assistant update."""
try: 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: finally:
if running: if running:
await self._start() await self._start()
_LOGGER.info("Successfull run HomeAssistant %s", to_version)
# Update Home Assistant # Update Home Assistant
ret = await _update(version) with suppress(HomeAssistantError):
await _update(version)
return
# Update going wrong, revert it # Update going wrong, revert it
if self.error_state and rollback: if self.error_state and rollback:
_LOGGER.fatal("Home Assistant update fails -> rollback!") _LOGGER.fatal("HomeAssistant update fails -> rollback!")
ret = await _update(rollback) await _update(rollback)
else:
return ret raise HomeAssistantUpdateError()
async def _start(self): async def _start(self):
"""Start HomeAssistant docker & wait.""" """Start HomeAssistant docker & wait."""
if not await self.instance.run(): if not await self.instance.run():
return False raise HomeAssistantError()
return await self._block_till_run() await self._block_till_run()
@process_lock @process_lock
def start(self): def start(self):
@ -266,7 +298,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
async def restart(self): async def restart(self):
"""Restart HomeAssistant docker.""" """Restart HomeAssistant docker."""
await self.instance.stop() await self.instance.stop()
return await self._start() await self._start()
def logs(self): def logs(self):
"""Get HomeAssistant docker logs. """Get HomeAssistant docker logs.
@ -309,7 +341,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
# if not valid # if not valid
if result.exit_code is None: if result.exit_code is None:
return ConfigResult(False, "") raise HomeAssistantError()
# parse output # parse output
log = convert_to_ascii(result.output) log = convert_to_ascii(result.output)
@ -317,51 +349,84 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
return ConfigResult(False, log) return ConfigResult(False, log)
return ConfigResult(True, log) return ConfigResult(True, log)
async def check_api_state(self): async def ensure_access_token(self):
"""Check if Home-Assistant up and running.""" """Ensures there is an access token."""
url = f"{self.api_url}/api/" if self.access_token is not None:
header = {CONTENT_TYPE: CONTENT_TYPE_JSON} return
if self.api_password: with suppress(asyncio.TimeoutError, aiohttp.ClientError):
header.update({HEADER_HA_ACCESS: self.api_password})
try:
# pylint: disable=bad-continuation
async with self.sys_websession_ssl.get( async with self.sys_websession_ssl.get(
url, headers=header, timeout=30) as request: f"{self.api_url}/auth/token",
status = request.status 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): _LOGGER.error("Can't update HomeAssistant access token!")
return False raise HomeAssistantAPIError()
if status not in (200, 201): @asynccontextmanager
_LOGGER.warning("Home-Assistant API config missmatch") async def make_request(self, method, path, json=None, content_type=None,
return True 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): async def send_event(self, event_type, event_data=None):
"""Send event to Home-Assistant.""" """Send event to Home-Assistant."""
url = f"{self.api_url}/api/events/{event_type}" with suppress(HomeAssistantAPIError):
header = {CONTENT_TYPE: CONTENT_TYPE_JSON} 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: _LOGGER.warning("HomeAssistant event %s fails: %s", event_type, err)
header.update({HEADER_HA_ACCESS: self.api_password}) return HomeAssistantError()
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
async def _block_till_run(self): async def _block_till_run(self):
"""Block until Home-Assistant is booting up or startup timeout.""" """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)) result = sock.connect_ex((str(self.api_ip), self.api_port))
sock.close() sock.close()
# Check if the port is available
if result == 0: if result == 0:
return True return True
return False
except OSError: except OSError:
pass pass
return False
while time.monotonic() - start_time < self.wait_boot: while time.monotonic() - start_time < self.wait_boot:
# Check if API response # Check if API response
if await self.sys_run_in_executor(check_port): 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 self._error_state = False
return True return
# wait and don't hit the system
await asyncio.sleep(10)
# Check if Container is is_running # Check if Container is is_running
if not await self.instance.is_running(): if not await self.instance.is_running():
_LOGGER.error("Home Assistant is crashed!") _LOGGER.error("Home Assistant is crashed!")
break break
# wait and don't hit the system _LOGGER.warning("Don't wait anymore of HomeAssistant startup!")
await asyncio.sleep(10)
_LOGGER.warning("Don't wait anymore of Home-Assistant startup!")
self._error_state = True self._error_state = True
return False raise HomeAssistantError()

View File

@ -16,7 +16,7 @@ ALL_FOLDERS = [FOLDER_HOMEASSISTANT, FOLDER_SHARE, FOLDER_ADDONS, FOLDER_SSL]
def unique_addons(addons_list): def unique_addons(addons_list):
"""Validate that an add-on is unique.""" """Validate that an add-on is unique."""
single = set([addon[ATTR_SLUG] for addon in addons_list]) single = set(addon[ATTR_SLUG] for addon in addons_list)
if len(single) != len(addons_list): if len(single) != len(addons_list):
raise vol.Invalid("Invalid addon list on snapshot!") raise vol.Invalid("Invalid addon list on snapshot!")

View File

@ -10,6 +10,7 @@ HASS_WATCHDOG_API = 'HASS_WATCHDOG_API'
RUN_UPDATE_SUPERVISOR = 29100 RUN_UPDATE_SUPERVISOR = 29100
RUN_UPDATE_ADDONS = 57600 RUN_UPDATE_ADDONS = 57600
RUN_UPDATE_HASSOSCLI = 29100
RUN_RELOAD_ADDONS = 21600 RUN_RELOAD_ADDONS = 21600
RUN_RELOAD_SNAPSHOTS = 72000 RUN_RELOAD_SNAPSHOTS = 72000
@ -35,6 +36,8 @@ class Tasks(CoreSysAttributes):
self._update_addons, RUN_UPDATE_ADDONS)) self._update_addons, RUN_UPDATE_ADDONS))
self.jobs.add(self.sys_scheduler.register_task( self.jobs.add(self.sys_scheduler.register_task(
self._update_supervisor, RUN_UPDATE_SUPERVISOR)) self._update_supervisor, RUN_UPDATE_SUPERVISOR))
self.jobs.add(self.sys_scheduler.register_task(
self._update_hassos_cli, RUN_UPDATE_HASSOSCLI))
self.jobs.add(self.sys_scheduler.register_task( self.jobs.add(self.sys_scheduler.register_task(
self.sys_addons.reload, RUN_RELOAD_ADDONS)) self.sys_addons.reload, RUN_RELOAD_ADDONS))
@ -79,7 +82,7 @@ class Tasks(CoreSysAttributes):
if not self.sys_supervisor.need_update: if not self.sys_supervisor.need_update:
return return
# don't perform an update on beta/dev channel # don't perform an update on dev channel
if self.sys_dev: if self.sys_dev:
_LOGGER.warning("Ignore Hass.io update on dev channel!") _LOGGER.warning("Ignore Hass.io update on dev channel!")
return return
@ -131,5 +134,20 @@ class Tasks(CoreSysAttributes):
return return
_LOGGER.error("Watchdog found a problem with Home-Assistant API!") _LOGGER.error("Watchdog found a problem with Home-Assistant API!")
await self.sys_homeassistant.restart() try:
self._cache[HASS_WATCHDOG_API] = 0 await self.sys_homeassistant.restart()
finally:
self._cache[HASS_WATCHDOG_API] = 0
async def _update_hassos_cli(self):
"""Check and run update of HassOS CLI."""
if not self.sys_hassos.need_cli_update:
return
# don't perform an update on dev channel
if self.sys_dev:
_LOGGER.warning("Ignore HassOS CLI update on dev channel!")
return
_LOGGER.info("Found new HassOS CLI version")
await self.sys_hassos.update_cli()

View File

@ -8,7 +8,7 @@ import aiohttp
from .const import ( from .const import (
URL_HASSIO_VERSION, FILE_HASSIO_UPDATER, ATTR_HOMEASSISTANT, ATTR_HASSIO, URL_HASSIO_VERSION, FILE_HASSIO_UPDATER, ATTR_HOMEASSISTANT, ATTR_HASSIO,
ATTR_CHANNEL, ATTR_HASSOS) ATTR_CHANNEL, ATTR_HASSOS, ATTR_HASSOS_CLI)
from .coresys import CoreSysAttributes from .coresys import CoreSysAttributes
from .utils import AsyncThrottle from .utils import AsyncThrottle
from .utils.json import JsonConfig from .utils.json import JsonConfig
@ -51,6 +51,11 @@ class Updater(JsonConfig, CoreSysAttributes):
"""Return last version of hassos.""" """Return last version of hassos."""
return self._data.get(ATTR_HASSOS) return self._data.get(ATTR_HASSOS)
@property
def version_hassos_cli(self):
"""Return last version of hassos cli."""
return self._data.get(ATTR_HASSOS_CLI)
@property @property
def channel(self): def channel(self):
"""Return upstream channel of hassio instance.""" """Return upstream channel of hassio instance."""
@ -99,6 +104,7 @@ class Updater(JsonConfig, CoreSysAttributes):
# update hassos version # update hassos version
if self.sys_hassos.available and board: if self.sys_hassos.available and board:
self._data[ATTR_HASSOS] = data['hassos'][board] self._data[ATTR_HASSOS] = data['hassos'][board]
self._data[ATTR_HASSOS_CLI] = data['hassos-cli']
except KeyError as err: except KeyError as err:
_LOGGER.warning("Can't process version data: %s", err) _LOGGER.warning("Can't process version data: %s", err)

View File

@ -247,7 +247,7 @@ class DBusSignalWrapper:
self._proc.send_signal(SIGINT) self._proc.send_signal(SIGINT)
await self._proc.communicate() await self._proc.communicate()
async def __aiter__(self): def __aiter__(self):
"""Start Iteratation.""" """Start Iteratation."""
return self return self

View File

@ -9,7 +9,8 @@ from .const import (
ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE, ATTR_HASSOS, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE, ATTR_HASSOS,
ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO,
ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, 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, ATTR_HASSOS_CLI,
CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$") RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
@ -88,6 +89,7 @@ SCHEMA_HASS_CONFIG = vol.Schema({
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_PORT, default=8123): NETWORK_PORT,
vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)), 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_SSL, default=False): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(), vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT, default=600): vol.Optional(ATTR_WAIT_BOOT, default=600):
@ -100,6 +102,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({
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),
vol.Optional(ATTR_HASSOS): vol.Coerce(str), vol.Optional(ATTR_HASSOS): vol.Coerce(str),
vol.Optional(ATTR_HASSOS_CLI): vol.Coerce(str),
}, extra=vol.REMOVE_EXTRA) }, extra=vol.REMOVE_EXTRA)

View File

@ -21,7 +21,6 @@ disable=
abstract-class-little-used, abstract-class-little-used,
abstract-class-not-used, abstract-class-not-used,
unused-argument, unused-argument,
global-statement,
redefined-variable-type, redefined-variable-type,
too-many-arguments, too-many-arguments,
too-many-branches, too-many-branches,
@ -32,7 +31,10 @@ disable=
too-many-statements, too-many-statements,
too-many-lines, too-many-lines,
too-few-public-methods, too-few-public-methods,
abstract-method abstract-method,
no-else-return,
useless-return,
not-async-context-manager
[EXCEPTIONS] [EXCEPTIONS]
overgeneral-exceptions=Exception,HomeAssistantError overgeneral-exceptions=Exception,HomeAssistantError

View File

@ -43,13 +43,13 @@ setup(
'attr==0.3.1', 'attr==0.3.1',
'async_timeout==3.0.0', 'async_timeout==3.0.0',
'aiohttp==3.3.2', 'aiohttp==3.3.2',
'docker==3.3.0', 'docker==3.4.0',
'colorlog==3.1.2', 'colorlog==3.1.2',
'voluptuous==0.11.1', 'voluptuous==0.11.1',
'gitpython==2.1.10', 'gitpython==2.1.10',
'pytz==2018.4', 'pytz==2018.4',
'pyudev==0.21.0', 'pyudev==0.21.0',
'pycryptodome==3.4.11', 'pycryptodome==3.6.4',
"cpe==1.2.1" "cpe==1.2.1"
] ]
) )

View File

@ -3,8 +3,8 @@ envlist = lint
[testenv] [testenv]
deps = deps =
flake8 flake8==3.5.0
pylint pylint==2.0.0
[testenv:lint] [testenv:lint]
basepython = python3 basepython = python3