Compare commits

...

41 Commits
112 ... 120

Author SHA1 Message Date
Pascal Vizeli
ebe9ae2341 Merge pull request #600 from home-assistant/dev
Release 120
2018-07-24 22:11:10 +02:00
Pascal Vizeli
e777bbd024 Fix bug with proxy (#599) 2018-07-24 22:00:46 +02:00
Pascal Vizeli
2116d56124 Bump version 120 2018-07-24 16:32:46 +02:00
Pascal Vizeli
0b6a82b018 Merge pull request #598 from home-assistant/dev
Release 119
2018-07-24 16:21:06 +02:00
Pascal Vizeli
b4ea28af4e Update uvloop to 0.11.0 (#597) 2018-07-24 16:16:26 +02:00
Pascal Vizeli
22f59712df Bump version 119 2018-07-23 13:01:51 +02:00
Pascal Vizeli
efe95f7bab Merge pull request #593 from home-assistant/dev
Release 118
2018-07-23 12:59:15 +02:00
Pascal Vizeli
200c68f67f Fix proxy data passthrougth (#592)
* Fix proxy data passthrougth

* Update homeassistant.py
2018-07-23 12:53:32 +02:00
Pascal Vizeli
dcefec7b99 Cleanup old stuff (#589) 2018-07-22 01:51:45 +02:00
Pascal Vizeli
5db798bcf8 Fix API for home-assistant (#588)
* Fix API for home-assistant

* Update API.md
2018-07-22 00:42:45 +02:00
Pascal Vizeli
70005296cc Bump version 118 2018-07-21 20:25:12 +02:00
Pascal Vizeli
f2bf8dea93 Merge pull request #585 from home-assistant/dev
Release 117
2018-07-21 20:21:18 +02:00
Pascal Vizeli
fee858c956 Fix exception is HomeAssistant allready running (#587) 2018-07-21 20:13:13 +02:00
Pascal Vizeli
e3ae48c8ff Remove geo ip (#586)
* Remove geo ip

* Update core.py

* Update dt.py
2018-07-21 19:45:11 +02:00
Pascal Vizeli
fa9e20385e Bugfix passwrod (#584) 2018-07-21 19:07:22 +02:00
Pascal Vizeli
f51c9704e0 Fix timeout on freegeoip (#581)
* Fix timeout on freegeoip

* Update updater.py

* Update supervisor.py

* Update dt.py

* Update hassos.py

* Update core.py

* Update hassos.py

* Update supervisor.py

* Update updater.py
2018-07-21 19:01:20 +02:00
Pascal Vizeli
57c58d81c0 Bump version 117 2018-07-21 00:06:34 +02:00
Pascal Vizeli
1ec1082068 Merge pull request #580 from home-assistant/dev
Release 116
2018-07-21 00:05:56 +02:00
Pascal Vizeli
35b7c2269c Support control of hassos-cli (#555)
* Support control of hassos-cli

* Update const.py

* Update validate.py

* Update supervisor.py

* Create hassos_cli.py

* Update hassos_cli.py

* Update hassos_cli.py

* Update hassos.py

* Update tasks.py

* Update hassos.py

* Update API.md

* Update API.md

* Update const.py

* Update hassos.py

* Update __init__.py

* Fix lint

* fix

* Fix logging

* change order

* Fix download
2018-07-20 23:45:36 +02:00
Pascal Vizeli
cc3e6ec6fd Fix stream error with aiohttp >= 3 (#579)
* Fix stream error with aiohttp >= 3

* Update proxy.py

* Update proxy.py

* Update proxy.py

* Update proxy.py

* Update proxy.py

* Update proxy.py
2018-07-20 22:28:56 +02:00
Paulus Schoutsen
4df42e054d 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
2018-07-20 16:55:48 +02:00
Pascal Vizeli
1b481e0b37 Fix small bugs (python37) (#577)
* Fix small bugs (python37)

* Update utils.py

* Update utils.py

* Update utils.py

* Update utils.py

* Update utils.py
2018-07-19 21:22:26 +02:00
Pascal Vizeli
3aa4cdf540 Fix remove data inside executor (#576) 2018-07-19 20:25:58 +02:00
Pascal Vizeli
029f277945 Reset readonly on remove data (#569)
* Reset readonly on remove data

* Update addon.py

* Update utils.py

* Fix lint

* Update utils.py

* Update utils.py

* Update utils.py

* Update utils.py

* Update addon.py
2018-07-19 12:44:16 +02:00
Pascal Vizeli
e7e0b9adda Fix-python7 compatibility (#573) 2018-07-19 01:18:43 +02:00
Pascal Vizeli
5fbff75da8 Support new base images (#571)
* Support new base images

* Update Dockerfile

* Update setup.py
2018-07-17 23:32:50 +02:00
Paulus Schoutsen
58299a0389 Add release drafter 2018-07-10 10:38:45 +02:00
Pascal Vizeli
1151d7e17b Bump version to 116 2018-07-06 13:10:16 +02:00
Pascal Vizeli
b56ed547e3 Merge pull request #559 from home-assistant/dev
Release 115
2018-07-06 13:09:32 +02:00
Pascal Vizeli
a71ebba940 Bugfix rollback if the hass instant is complete corrupt (#558) 2018-07-06 13:08:57 +02:00
Pascal Vizeli
4fcb516c75 Bump version to 115 2018-07-06 01:38:18 +02:00
Pascal Vizeli
22142d32d2 Merge remote-tracking branch 'origin/dev'
Release 114
2018-07-06 01:37:10 +02:00
Pascal Vizeli
21194f1411 Add hostname to UI (#557)
* Add hostname to UI

* Fix dbus call

* support boolean

* support types

* revert

* test

* test

* log

* fixup

* fix bug
2018-07-06 01:36:28 +02:00
Pascal Vizeli
09df046fa8 Fix problem with Repositories (#552)
* Fix problem with Repositories

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* fix lint

* fix

* reset origin

* Git cleanup
2018-07-05 23:21:54 +02:00
Pascal Vizeli
63d3889d5c Fix problem with options / hostname (#554) 2018-07-05 13:01:48 +02:00
Pascal Vizeli
0ffc0559e2 Map devicetree 2018-07-04 01:12:58 +02:00
Pascal Vizeli
78118a502c Map devicetree 2018-07-04 01:12:25 +02:00
Pascal Vizeli
946cc3d618 Bump version to 114 2018-07-04 00:53:19 +02:00
Pascal Vizeli
c40a3f18e9 Merge remote-tracking branch 'origin/dev'
Release 113
2018-07-04 00:51:36 +02:00
Pascal Vizeli
f01945bf8c Update addon.py (#550) 2018-07-04 00:51:01 +02:00
Pascal Vizeli
0f72db45f9 Bump version to 113 2018-07-03 23:02:21 +02:00
41 changed files with 519 additions and 241 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

10
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.
@@ -372,6 +381,7 @@ Output is the raw Docker log.
"port": "port for access hass", "port": "port for access hass",
"ssl": "bool", "ssl": "bool",
"password": "", "password": "",
"refresh_token": "",
"watchdog": "bool", "watchdog": "bool",
"startup_time": 600 "startup_time": 600
} }

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.11.0 \
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))
try:
_LOGGER.info("Restore data for addon %s", self._id) _LOGGER.info("Restore data for addon %s", self._id)
if self.path_data.is_dir():
await remove_data(self.path_data)
try:
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

@@ -45,12 +45,13 @@ class GitRepo(CoreSysAttributes):
async with self.lock: async with self.lock:
try: try:
_LOGGER.info("Load addon %s repository", self.path) _LOGGER.info("Load addon %s repository", self.path)
self.repo = await self.sys_loop.run_in_executor( self.repo = await self.sys_run_in_executor(
None, git.Repo, str(self.path)) git.Repo, str(self.path))
except (git.InvalidGitRepositoryError, git.NoSuchPathError, except (git.InvalidGitRepositoryError, git.NoSuchPathError,
git.GitCommandError) as err: git.GitCommandError) as err:
_LOGGER.error("Can't load %s repo: %s.", self.path, err) _LOGGER.error("Can't load %s repo: %s.", self.path, err)
self._remove()
return False return False
return True return True
@@ -62,7 +63,9 @@ class GitRepo(CoreSysAttributes):
attribute: value attribute: value
for attribute, value in ( for attribute, value in (
('recursive', True), ('recursive', True),
('branch', self.branch) ('branch', self.branch),
('depth', 1),
('shallow-submodules', True)
) if value is not None ) if value is not None
} }
@@ -76,6 +79,7 @@ class GitRepo(CoreSysAttributes):
except (git.InvalidGitRepositoryError, git.NoSuchPathError, except (git.InvalidGitRepositoryError, git.NoSuchPathError,
git.GitCommandError) as err: git.GitCommandError) as err:
_LOGGER.error("Can't clone %s repo: %s.", self.url, err) _LOGGER.error("Can't clone %s repo: %s.", self.url, err)
self._remove()
return False return False
return True return True
@@ -87,18 +91,43 @@ class GitRepo(CoreSysAttributes):
return False return False
async with self.lock: async with self.lock:
_LOGGER.info("Update addon %s repository", self.url)
branch = self.repo.active_branch.name
try: try:
_LOGGER.info("Pull addon %s repository", self.url) # Download data
await self.sys_loop.run_in_executor( await self.sys_run_in_executor(ft.partial(
None, self.repo.remotes.origin.pull) self.repo.remotes.origin.fetch, **{
'update-shallow': True,
'depth': 1,
}))
# Jump on top of that
await self.sys_run_in_executor(ft.partial(
self.repo.git.reset, f"origin/{branch}", hard=True))
# Cleanup old data
await self.sys_run_in_executor(ft.partial(
self.repo.git.clean, "-xdf"))
except (git.InvalidGitRepositoryError, git.NoSuchPathError, except (git.InvalidGitRepositoryError, git.NoSuchPathError,
git.GitCommandError) as err: git.GitCommandError) as err:
_LOGGER.error("Can't pull %s repo: %s.", self.url, err) _LOGGER.error("Can't update %s repo: %s.", self.url, err)
return False return False
return True return True
def _remove(self):
"""Remove a repository."""
if not self.path.is_dir():
return
def log_err(funct, path, _):
"""Log error."""
_LOGGER.warning("Can't remove %s", path)
shutil.rmtree(str(self.path), onerror=log_err)
class GitRepoHassIO(GitRepo): class GitRepoHassIO(GitRepo):
"""HassIO addons repository.""" """HassIO addons repository."""
@@ -121,12 +150,6 @@ class GitRepoCustom(GitRepo):
super().__init__(coresys, path, url) super().__init__(coresys, path, url)
def remove(self): def remove(self):
"""Remove a custom addon.""" """Remove a custom repository."""
if self.path.is_dir():
_LOGGER.info("Remove custom addon repository %s", self.url) _LOGGER.info("Remove custom addon repository %s", self.url)
self._remove()
def log_err(funct, path, _):
"""Log error."""
_LOGGER.warning("Can't remove %s", path)
shutil.rmtree(str(self.path), onerror=log_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

@@ -58,6 +58,7 @@ class RestAPI(CoreSysAttributes):
web.post('/host/reboot', api_host.reboot), web.post('/host/reboot', api_host.reboot),
web.post('/host/shutdown', api_host.shutdown), web.post('/host/shutdown', api_host.shutdown),
web.post('/host/reload', api_host.reload), web.post('/host/reload', api_host.reload),
web.post('/host/options', api_host.options),
web.get('/host/services', api_host.services), web.get('/host/services', api_host.services),
web.post('/host/services/{service}/stop', api_host.service_stop), web.post('/host/services/{service}/stop', api_host.service_stop),
web.post('/host/services/{service}/start', api_host.service_start), web.post('/host/services/{service}/start', api_host.service_start),
@@ -75,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):

Binary file not shown.

View File

@@ -1 +1 @@
!function(e){function n(n){for(var t,o,i=n[0],u=n[1],a=0,c=[];a<i.length;a++)o=i[a],r[o]&&c.push(r[o][0]),r[o]=0;for(t in u)Object.prototype.hasOwnProperty.call(u,t)&&(e[t]=u[t]);for(f&&f(n);c.length;)c.shift()()}var t={},r={6:0};function o(n){if(t[n])return t[n].exports;var r=t[n]={i:n,l:!1,exports:{}};return e[n].call(r.exports,r,r.exports,o),r.l=!0,r.exports}o.e=function(e){var n=[],t=r[e];if(0!==t)if(t)n.push(t[2]);else{var i=new Promise(function(n,o){t=r[e]=[n,o]});n.push(t[2]=i);var u,a=document.getElementsByTagName("head")[0],f=document.createElement("script");f.charset="utf-8",f.timeout=120,o.nc&&f.setAttribute("nonce",o.nc),f.src=function(e){return o.p+"chunk."+{0:"f3880aa331d3ef2ddf32",1:"a8e86d80be46b3b6e16d",2:"fdf0834c750e40935b6f",3:"ff92199b0d422767d108",4:"c77b56beea1d4547ff5f",5:"c93f37c558ff32991708"}[e]+".js"}(e),u=function(n){f.onerror=f.onload=null,clearTimeout(c);var t=r[e];if(0!==t){if(t){var o=n&&("load"===n.type?"missing":n.type),i=n&&n.target&&n.target.src,u=new Error("Loading chunk "+e+" failed.\n("+o+": "+i+")");u.type=o,u.request=i,t[1](u)}r[e]=void 0}};var c=setTimeout(function(){u({type:"timeout",target:f})},12e4);f.onerror=f.onload=u,a.appendChild(f)}return Promise.all(n)},o.m=e,o.c=t,o.d=function(e,n,t){o.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:t})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(e,n){if(1&n&&(e=o(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(o.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var r in e)o.d(t,r,function(n){return e[n]}.bind(null,r));return t},o.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(n,"a",n),n},o.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},o.p="/api/hassio/app/",o.oe=function(e){throw console.error(e),e};var i=window.webpackJsonp=window.webpackJsonp||[],u=i.push.bind(i);i.push=n,i=i.slice();for(var a=0;a<i.length;a++)n(i[a]);var f=u;o(o.s=0)}([function(e,n,t){window.loadES5Adapter().then(function(){Promise.all([t.e(0),t.e(3)]).then(t.bind(null,1)),Promise.all([t.e(0),t.e(1),t.e(2)]).then(t.bind(null,2))})}]); !function(e){function n(n){for(var t,o,i=n[0],u=n[1],a=0,l=[];a<i.length;a++)o=i[a],r[o]&&l.push(r[o][0]),r[o]=0;for(t in u)Object.prototype.hasOwnProperty.call(u,t)&&(e[t]=u[t]);for(f&&f(n);l.length;)l.shift()()}var t={},r={6:0};function o(n){if(t[n])return t[n].exports;var r=t[n]={i:n,l:!1,exports:{}};return e[n].call(r.exports,r,r.exports,o),r.l=!0,r.exports}o.e=function(e){var n=[],t=r[e];if(0!==t)if(t)n.push(t[2]);else{var i=new Promise(function(n,o){t=r[e]=[n,o]});n.push(t[2]=i);var u,a=document.getElementsByTagName("head")[0],f=document.createElement("script");f.charset="utf-8",f.timeout=120,o.nc&&f.setAttribute("nonce",o.nc),f.src=function(e){return o.p+"chunk."+{0:"f3880aa331d3ef2ddf32",1:"a8e86d80be46b3b6e16d",2:"0ef4ef1053fe3d5107b5",3:"ff92199b0d422767d108",4:"c77b56beea1d4547ff5f",5:"c93f37c558ff32991708"}[e]+".js"}(e),u=function(n){f.onerror=f.onload=null,clearTimeout(l);var t=r[e];if(0!==t){if(t){var o=n&&("load"===n.type?"missing":n.type),i=n&&n.target&&n.target.src,u=new Error("Loading chunk "+e+" failed.\n("+o+": "+i+")");u.type=o,u.request=i,t[1](u)}r[e]=void 0}};var l=setTimeout(function(){u({type:"timeout",target:f})},12e4);f.onerror=f.onload=u,a.appendChild(f)}return Promise.all(n)},o.m=e,o.c=t,o.d=function(e,n,t){o.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:t})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(e,n){if(1&n&&(e=o(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(o.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var r in e)o.d(t,r,function(n){return e[n]}.bind(null,r));return t},o.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(n,"a",n),n},o.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},o.p="/api/hassio/app/",o.oe=function(e){throw console.error(e),e};var i=window.webpackJsonp=window.webpackJsonp||[],u=i.push.bind(i);i.push=n,i=i.slice();for(var a=0;a<i.length;a++)n(i[a]);var f=u;o(o.s=0)}([function(e,n,t){window.loadES5Adapter().then(function(){Promise.all([t.e(0),t.e(3)]).then(t.bind(null,1)),Promise.all([t.e(0),t.e(1),t.e(2)]).then(t.bind(null,2))})}]);

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

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,8 +71,7 @@ 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 = web.StreamResponse()
response.content_type = request.headers.get(CONTENT_TYPE) response.content_type = request.headers.get(CONTENT_TYPE)
try: try:
@@ -83,14 +79,10 @@ class APIProxy(CoreSysAttributes):
while True: while True:
data = await client.content.read(10) data = await client.content.read(10)
if not data: if not data:
await response.write_eof()
break break
await response.write(data) await response.write(data)
except aiohttp.ClientError: except aiohttp.ClientError:
await response.write_eof()
except asyncio.CancelledError:
pass pass
finally: finally:
@@ -105,8 +97,7 @@ 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,
@@ -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': if data.get('type') == 'auth_ok':
return client return client
elif data.get('type') == 'auth_required':
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({ await client.send_json({
'type': 'auth', 'type': 'auth',
'api_password': self.sys_homeassistant.api_password, 'api_password': self.sys_homeassistant.api_password,
}) })
_LOGGER.error("Authentication to Home-Assistant websocket") data = await client.receive_json()
except (aiohttp.ClientError, RuntimeError) as err: 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,12 +173,18 @@ 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({
'type': 'auth_invalid',
'message': 'Invalid access',
})
return server
_LOGGER.info("Websocket access from %s", addon.slug) _LOGGER.info("Websocket access from %s", addon.slug)
await server.send_json({ await server.send_json({

View File

@@ -1,6 +1,5 @@
"""Init file for HassIO util for rest api.""" """Init file for HassIO util for rest api."""
import json import json
import hashlib
import logging import logging
from aiohttp import web from aiohttp import web
@@ -94,9 +93,3 @@ async def api_validate(schema, request):
raise RuntimeError(humanize_error(data, ex)) from None raise RuntimeError(humanize_error(data, ex)) from None
return data return data
def hash_password(password):
"""Hash and salt our passwords."""
key = ")*()*SALT_HASSIO2123{}6554547485HSKA!!*JSLAfdasda$".format(password)
return hashlib.sha256(key.encode()).hexdigest()

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 = '112' HASSIO_VERSION = '120'
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

@@ -6,8 +6,7 @@ import logging
from .coresys import CoreSysAttributes from .coresys import CoreSysAttributes
from .const import ( from .const import (
STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, STARTUP_INITIALIZE) STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, STARTUP_INITIALIZE)
from .exceptions import HassioError from .exceptions import HassioError, HomeAssistantError
from .utils.dt import fetch_timezone
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -21,10 +20,8 @@ class HassIO(CoreSysAttributes):
async def setup(self): async def setup(self):
"""Setup HassIO orchestration.""" """Setup HassIO orchestration."""
# update timezone # Load Supervisor
if self.sys_config.timezone == 'UTC': await self.sys_supervisor.load()
self.sys_config.timezone = \
await fetch_timezone(self.sys_websession)
# Load DBus # Load DBus
await self.sys_dbus.load() await self.sys_dbus.load()
@@ -35,9 +32,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()
@@ -93,6 +87,7 @@ class HassIO(CoreSysAttributes):
# run HomeAssistant # run HomeAssistant
if self.sys_homeassistant.boot: if self.sys_homeassistant.boot:
with suppress(HomeAssistantError):
await self.sys_homeassistant.start() await self.sys_homeassistant.start()
# start addon mark as application # start addon mark as application

View File

@@ -28,7 +28,7 @@ class Hostname(DBusInterface):
Return a coroutine. Return a coroutine.
""" """
return self.dbus.SetStaticHostname(hostname) return self.dbus.SetStaticHostname(hostname, False)
@dbus_connected @dbus_connected
def get_properties(self): def get_properties(self):

View File

@@ -216,8 +216,8 @@ class DockerAddon(DockerInterface):
# DeviceTree support # DeviceTree support
if self.addon.with_devicetree: if self.addon.with_devicetree:
volumes.update({ volumes.update({
"/sys/firmware/devicetree": { "/sys/firmware/devicetree/base": {
'bind': "/sys/firmware/devicetree", 'mode': 'ro' 'bind': "/device-tree", 'mode': 'ro'
}, },
}) })

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

@@ -1,4 +1,5 @@
"""HassOS support on supervisor.""" """HassOS support on supervisor."""
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
@@ -7,6 +8,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 +20,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 +35,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 +79,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)
@@ -66,7 +93,7 @@ class HassOS(CoreSysAttributes):
_LOGGER.info("OTA update is downloaded on %s", raucb) _LOGGER.info("OTA update is downloaded on %s", raucb)
return raucb return raucb
except aiohttp.ClientError as err: except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Can't fetch versions from %s: %s", url, err) _LOGGER.warning("Can't fetch versions from %s: %s", url, err)
except OSError as err: except OSError as err:
@@ -86,7 +113,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 +122,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 +170,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
_LOGGER.info("Start landingpage")
try:
await self._start() await self._start()
except HomeAssistantError:
_LOGGER.warning("Can't start landingpage")
@process_lock @process_lock
async def install(self): async def install(self):
@@ -205,46 +226,57 @@ 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()
except HomeAssistantError:
_LOGGER.error("Can't start HomeAssistant!")
finally:
await self.instance.cleanup() await self.instance.cleanup()
@process_lock @process_lock
async def update(self, version=None): async def update(self, version=None):
"""Update HomeAssistant version.""" """Update HomeAssistant version."""
version = version or self.last_version version = version or self.last_version
rollback = self.version rollback = self.version if not self.error_state else None
running = await self.instance.is_running() running = await self.instance.is_running()
exists = await self.instance.exists() exists = await self.instance.exists()
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,86 @@ 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,
data=None, timeout=30):
"""Async context manager to make a request with right auth."""
url = f"{self.api_url}/{path}"
headers = {}
# Passthrough content type
if content_type is not None:
headers[hdrs.CONTENT_TYPE] = content_type
# Set old API Password
if 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, data=data, timeout=timeout, json=json, headers=headers
) 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 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 +441,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

@@ -1,4 +1,5 @@
"""HomeAssistant control object.""" """HomeAssistant control object."""
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
@@ -60,7 +61,7 @@ class Supervisor(CoreSysAttributes):
async with self.sys_websession.get(url, timeout=10) as request: async with self.sys_websession.get(url, timeout=10) as request:
data = await request.text() data = await request.text()
except aiohttp.ClientError as err: except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Can't fetch AppArmor profile: %s", err) _LOGGER.warning("Can't fetch AppArmor profile: %s", err)
return return

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!")
try:
await self.sys_homeassistant.restart() await self.sys_homeassistant.restart()
finally:
self._cache[HASS_WATCHDOG_API] = 0 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

@@ -1,4 +1,5 @@
"""Fetch last versions from webserver.""" """Fetch last versions from webserver."""
import asyncio
from contextlib import suppress from contextlib import suppress
from datetime import timedelta from datetime import timedelta
import json import json
@@ -8,7 +9,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 +52,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."""
@@ -76,7 +82,7 @@ class Updater(JsonConfig, CoreSysAttributes):
async with self.sys_websession.get(url, timeout=10) as request: async with self.sys_websession.get(url, timeout=10) as request:
data = await request.json(content_type=None) data = await request.json(content_type=None)
except aiohttp.ClientError as err: except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Can't fetch versions from %s: %s", url, err) _LOGGER.warning("Can't fetch versions from %s: %s", url, err)
raise HassioUpdaterError() from None raise HassioUpdaterError() from None
@@ -99,6 +105,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

@@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone
import logging import logging
import re import re
import aiohttp
import pytz import pytz
UTC = pytz.utc UTC = pytz.utc
@@ -23,22 +22,6 @@ DATETIME_RE = re.compile(
) )
async def fetch_timezone(websession):
"""Read timezone from freegeoip."""
data = {}
try:
async with websession.get(FREEGEOIP_URL, timeout=10) as request:
data = await request.json()
except aiohttp.ClientError as err:
_LOGGER.warning("Can't fetch freegeoip data: %s", err)
except ValueError as err:
_LOGGER.warning("Error on parse freegeoip data: %s", err)
return data.get('time_zone', 'UTC')
# Copyright (c) Django Software Foundation and individual contributors. # Copyright (c) Django Software Foundation and individual contributors.
# All rights reserved. # All rights reserved.
# https://github.com/django/django/blob/master/LICENSE # https://github.com/django/django/blob/master/LICENSE

View File

@@ -106,13 +106,29 @@ class DBus:
_LOGGER.error("Can't parse '%s': %s", raw, err) _LOGGER.error("Can't parse '%s': %s", raw, err)
raise DBusParseError() from None raise DBusParseError() from None
@staticmethod
def gvariant_args(args):
"""Convert args into gvariant."""
gvariant = ""
for arg in args:
if isinstance(arg, bool):
gvariant += " {}".format(str(arg).lower())
elif isinstance(arg, (int, float)):
gvariant += f" {arg}"
elif isinstance(arg, str):
gvariant += f" \"{arg}\""
else:
gvariant += " {}".format(str(arg))
return gvariant.lstrip()
async def call_dbus(self, method, *args): async def call_dbus(self, method, *args):
"""Call a dbus method.""" """Call a dbus method."""
command = shlex.split(CALL.format( command = shlex.split(CALL.format(
bus=self.bus_name, bus=self.bus_name,
object=self.object_path, object=self.object_path,
method=method, method=method,
args=" ".join(map(str, args)) args=self.gvariant_args(args)
)) ))
# Run command # Run command
@@ -231,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