mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-31 03:40:21 +00:00
Compare commits
114 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c67d57cef4 | ||
![]() |
b5cca7d341 | ||
![]() |
8919f13911 | ||
![]() |
990ae49608 | ||
![]() |
c2ba02722c | ||
![]() |
5bd1957337 | ||
![]() |
f59f0793bc | ||
![]() |
63b96700e0 | ||
![]() |
dffbcc2c7e | ||
![]() |
0dbe1ecc2a | ||
![]() |
da8526fcec | ||
![]() |
933b6f4d1e | ||
![]() |
16f2dfeebd | ||
![]() |
bc6eb5cab4 | ||
![]() |
8833845b2e | ||
![]() |
391be6afac | ||
![]() |
a4f74676b6 | ||
![]() |
600b32f75b | ||
![]() |
f199a5cf95 | ||
![]() |
5896fde441 | ||
![]() |
9998f9720f | ||
![]() |
f37589daa6 | ||
![]() |
ce2513f175 | ||
![]() |
1a4c5d24a4 | ||
![]() |
886d202f39 | ||
![]() |
5a42019ed7 | ||
![]() |
354093c121 | ||
![]() |
aa9c300d7c | ||
![]() |
d9ad5daae3 | ||
![]() |
4680ba6d0d | ||
![]() |
1423062ac3 | ||
![]() |
a036096684 | ||
![]() |
a1c443a6f2 | ||
![]() |
e6e1367cd6 | ||
![]() |
303e741289 | ||
![]() |
79cc23e273 | ||
![]() |
046ce0230a | ||
![]() |
33a66bee01 | ||
![]() |
f76749a933 | ||
![]() |
385af5bef5 | ||
![]() |
19b72b1a79 | ||
![]() |
f6048467ad | ||
![]() |
dbe6b860c7 | ||
![]() |
0a1e6b3e2d | ||
![]() |
f178dde589 | ||
![]() |
545d45ecf0 | ||
![]() |
c333f94cfa | ||
![]() |
76a999f650 | ||
![]() |
d2f8e35622 | ||
![]() |
1e4ed9c9d1 | ||
![]() |
57c21b4eb5 | ||
![]() |
088cc3ef15 | ||
![]() |
9e326f6324 | ||
![]() |
5840290df7 | ||
![]() |
58fdabb8ff | ||
![]() |
b8fc002fbc | ||
![]() |
d5e349266b | ||
![]() |
4c135dd617 | ||
![]() |
a98a0d1a1a | ||
![]() |
768b4d2b1a | ||
![]() |
ff640c598d | ||
![]() |
c76408e4e8 | ||
![]() |
2e168d089c | ||
![]() |
a61311e928 | ||
![]() |
85ffba90b2 | ||
![]() |
6623ec9bbc | ||
![]() |
7a4ed4029c | ||
![]() |
d4c52ce298 | ||
![]() |
c0aab8497f | ||
![]() |
cabe5b143f | ||
![]() |
3a791bace6 | ||
![]() |
19dfdb094b | ||
![]() |
ec5caa483e | ||
![]() |
e6967f8db5 | ||
![]() |
759356ff2e | ||
![]() |
4d8dbdb486 | ||
![]() |
cddb40a104 | ||
![]() |
36128d119a | ||
![]() |
dadb72aca8 | ||
![]() |
390d4fa6c7 | ||
![]() |
9559c39351 | ||
![]() |
9d95b70534 | ||
![]() |
3bad896978 | ||
![]() |
9f406df129 | ||
![]() |
a9b4174590 | ||
![]() |
9109e3803b | ||
![]() |
422dd78489 | ||
![]() |
9e1d6c9d2b | ||
![]() |
c8e3f2b48a | ||
![]() |
a287f52e47 | ||
![]() |
76952db3eb | ||
![]() |
871721f04b | ||
![]() |
3c0ebdf643 | ||
![]() |
0e258a4ae0 | ||
![]() |
645a8e2372 | ||
![]() |
c6cc8adbb7 | ||
![]() |
dd38c73b85 | ||
![]() |
c916314704 | ||
![]() |
e0dcce5895 | ||
![]() |
906616e224 | ||
![]() |
db20ea95d9 | ||
![]() |
d142ea5d23 | ||
![]() |
5d52404dab | ||
![]() |
43f4b36cfe | ||
![]() |
0393db19e6 | ||
![]() |
b197578df4 | ||
![]() |
ed428c0df4 | ||
![]() |
d38707821c | ||
![]() |
cfb392054e | ||
![]() |
0ea65efeb3 | ||
![]() |
c4ce7d1a74 | ||
![]() |
7ac95b98bc | ||
![]() |
f8413d8d63 | ||
![]() |
709b80b864 |
102
API.md
102
API.md
@@ -22,29 +22,67 @@ On success
|
||||
|
||||
### HassIO
|
||||
|
||||
- `/supervisor/ping`
|
||||
- GET `/supervisor/ping`
|
||||
|
||||
- `/supervisor/info`
|
||||
- GET `/supervisor/info`
|
||||
|
||||
The addons from `addons` are only installed one.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "INSTALL_VERSION",
|
||||
"last_version": "LAST_VERSION",
|
||||
"arch": "armhf|aarch64|i386|amd64",
|
||||
"beta_channel": "true|false",
|
||||
"addons": [
|
||||
{
|
||||
"name": "xy bla",
|
||||
"slug": "xy",
|
||||
"description": "description",
|
||||
"arch": ["armhf", "aarch64", "i386", "amd64"],
|
||||
"repository": "12345678|null",
|
||||
"version": "LAST_VERSION",
|
||||
"installed": "INSTALL_VERSION",
|
||||
"detached": "bool"
|
||||
}
|
||||
],
|
||||
"addons_repositories": [
|
||||
"REPO_URL"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- GET `/supervisor/addons`
|
||||
|
||||
Get all available addons
|
||||
|
||||
```json
|
||||
{
|
||||
"addons": [
|
||||
{
|
||||
"name": "xy bla",
|
||||
"slug": "xy",
|
||||
"description": "description",
|
||||
"arch": ["armhf", "aarch64", "i386", "amd64"],
|
||||
"repository": "core|local|REP_ID",
|
||||
"version": "LAST_VERSION",
|
||||
"installed": "none|INSTALL_VERSION",
|
||||
"dedicated": "bool",
|
||||
"description": "description"
|
||||
"detached": "bool"
|
||||
}
|
||||
],
|
||||
"repositories": [
|
||||
{
|
||||
"slug": "12345678",
|
||||
"name": "Repitory Name",
|
||||
"source": "URL_OF_REPOSITORY",
|
||||
"url": "null|WEBSITE",
|
||||
"maintainer": "null|BLA BLU <fla@dld.ch>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `/supervisor/update`
|
||||
- POST `/supervisor/update`
|
||||
Optional:
|
||||
```json
|
||||
{
|
||||
@@ -52,28 +90,31 @@ Optional:
|
||||
}
|
||||
```
|
||||
|
||||
- `/supervisor/option`
|
||||
- POST `/supervisor/options`
|
||||
```json
|
||||
{
|
||||
"beta_channel": "true|false"
|
||||
"beta_channel": "true|false",
|
||||
"addons_repositories": [
|
||||
"REPO_URL"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `/supervisor/reload`
|
||||
- POST `/supervisor/reload`
|
||||
|
||||
Reload addons/version.
|
||||
|
||||
- `/supervisor/logs`
|
||||
- GET `/supervisor/logs`
|
||||
|
||||
Output the raw docker log
|
||||
|
||||
### Host
|
||||
|
||||
- `/host/shutdown`
|
||||
- POST `/host/shutdown`
|
||||
|
||||
- `/host/reboot`
|
||||
- POST `/host/reboot`
|
||||
|
||||
- `/host/info`
|
||||
- GET `/host/info`
|
||||
See HostControl info command.
|
||||
```json
|
||||
{
|
||||
@@ -86,7 +127,7 @@ See HostControl info command.
|
||||
}
|
||||
```
|
||||
|
||||
- `/host/update`
|
||||
- POST `/host/update`
|
||||
Optional:
|
||||
```json
|
||||
{
|
||||
@@ -96,9 +137,9 @@ Optional:
|
||||
|
||||
### Network
|
||||
|
||||
- `/network/info`
|
||||
- GET `/network/info`
|
||||
|
||||
- `/network/options`
|
||||
- POST `/network/options`
|
||||
```json
|
||||
{
|
||||
"hostname": "",
|
||||
@@ -112,7 +153,7 @@ Optional:
|
||||
|
||||
### HomeAssistant
|
||||
|
||||
- `/homeassistant/info`
|
||||
- GET `/homeassistant/info`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -121,7 +162,7 @@ Optional:
|
||||
}
|
||||
```
|
||||
|
||||
- `/homeassistant/update`
|
||||
- POST `/homeassistant/update`
|
||||
Optional:
|
||||
```json
|
||||
{
|
||||
@@ -129,15 +170,22 @@ Optional:
|
||||
}
|
||||
```
|
||||
|
||||
- `/homeassistant/logs`
|
||||
- GET `/homeassistant/logs`
|
||||
|
||||
Output the raw docker log
|
||||
|
||||
- POST `/homeassistant/restart`
|
||||
|
||||
### REST API addons
|
||||
|
||||
- `/addons/{addon}/info`
|
||||
- GET `/addons/{addon}/info`
|
||||
```json
|
||||
{
|
||||
"name": "xy bla",
|
||||
"description": "description",
|
||||
"url": "null|url of addon",
|
||||
"detached": "bool",
|
||||
"repository": "12345678|null",
|
||||
"version": "VERSION",
|
||||
"last_version": "LAST_VERSION",
|
||||
"state": "started|stopped",
|
||||
@@ -146,7 +194,7 @@ Output the raw docker log
|
||||
}
|
||||
```
|
||||
|
||||
- `/addons/{addon}/options`
|
||||
- POST `/addons/{addon}/options`
|
||||
```json
|
||||
{
|
||||
"boot": "auto|manual",
|
||||
@@ -154,11 +202,11 @@ Output the raw docker log
|
||||
}
|
||||
```
|
||||
|
||||
- `/addons/{addon}/start`
|
||||
- POST `/addons/{addon}/start`
|
||||
|
||||
- `/addons/{addon}/stop`
|
||||
- POST `/addons/{addon}/stop`
|
||||
|
||||
- `/addons/{addon}/install`
|
||||
- POST `/addons/{addon}/install`
|
||||
Optional:
|
||||
```json
|
||||
{
|
||||
@@ -166,9 +214,9 @@ Optional:
|
||||
}
|
||||
```
|
||||
|
||||
- `/addons/{addon}/uninstall`
|
||||
- POST `/addons/{addon}/uninstall`
|
||||
|
||||
- `/addons/{addon}/update`
|
||||
- POST `/addons/{addon}/update`
|
||||
Optional:
|
||||
```json
|
||||
{
|
||||
@@ -176,10 +224,12 @@ Optional:
|
||||
}
|
||||
```
|
||||
|
||||
- `/addons/{addon}/logs`
|
||||
- GET `/addons/{addon}/logs`
|
||||
|
||||
Output the raw docker log
|
||||
|
||||
- POST `/addons/{addon}/restart`
|
||||
|
||||
## Host Control
|
||||
|
||||
Communicate over unix socket with a host daemon.
|
||||
|
3
MANIFEST.in
Normal file
3
MANIFEST.in
Normal file
@@ -0,0 +1,3 @@
|
||||
include LICENSE.md
|
||||
graft hassio
|
||||
recursive-exclude * *.py[co]
|
52
README.md
52
README.md
@@ -1,56 +1,26 @@
|
||||
# HassIO
|
||||
First private cloud solution for home automation.
|
||||
### First private cloud solution for home automation.
|
||||
|
||||
It is a docker image (supervisor) they manage HomeAssistant docker and give a interface to control itself over UI. It have a own eco system with addons to extend the functionality in a easy way.
|
||||
Hass.io is a Docker based system for managing your Home Assistant installation and related applications. The system is controlled via Home Assistant which communicates with the supervisor. The supervisor provides an API to manage the installation. This includes changing network settings or installing and updating software.
|
||||
|
||||
[HassIO-Addons](https://github.com/pvizeli/hassio-addons) | [HassIO-Build](https://github.com/pvizeli/hassio-build)
|
||||

|
||||
|
||||
**HassIO is at the moment on development and not ready to use productive!**
|
||||
[HassIO-Addons](https://github.com/home-assistant/hassio-addons) | [HassIO-Build](https://github.com/home-assistant/hassio-build)
|
||||
|
||||
## Feature in progress
|
||||
- Backup/Restore
|
||||
- MQTT addon
|
||||
- DHCP-Server addon
|
||||
**HassIO is under active development and is not ready yet for production use.**
|
||||
|
||||
## Installing Hass.io
|
||||
|
||||
Looks to our [website](https://home-assistant.io/hassio).
|
||||
|
||||
# HomeAssistant
|
||||
|
||||
## SSL
|
||||
|
||||
All addons they can create SSL certs do that in same schema. So you can put follow lines to your `configuration.yaml`.
|
||||
All addons that create SSL certs follow the same file structure. If you use one, put follow lines in your `configuration.yaml`.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
ssl_certificate: /ssl/fullchain.pem
|
||||
ssl_key: /ssl/privkey.pem
|
||||
```
|
||||
|
||||
# Hardware Image
|
||||
The image is based on ResinOS and Yocto Linux. It comes with the HassIO supervisor pre-installed. This includes support to update the supervisor over the air. After flashing your host OS will not require any more maintenance! The image does not include Home Assistant, instead it will downloaded when the image boots up for the first time.
|
||||
|
||||
Download can be found here: https://drive.google.com/drive/folders/0B2o1Uz6l1wVNbFJnb2gwNXJja28?usp=sharing
|
||||
|
||||
After extracting the archive, flash it to a drive using [Etcher](https://etcher.io/).
|
||||
|
||||
## History
|
||||
- **0.1**: First techpreview with dumy supervisor (ResinOS 2.0.0-RC5)
|
||||
- **0.2**: Fix some bugs and update it to HassIO 0.2
|
||||
- **0.3**: Update HostControl and feature for HassIO 0.3 (ResinOS 2.0.0 / need reflash)
|
||||
- **0.4**: Update HostControl and bring resinos OTA (resinhub) back (ResinOS 2.0.0-rev3)
|
||||
|
||||
## Configuring the image
|
||||
You can configure the WiFi network that the image should connect to after flashing using [`resin-device-toolbox`](https://resinos.io/docs/raspberrypi3/gettingstarted/#install-resin-device-toolbox).
|
||||
|
||||
## Developer access to ResinOS host
|
||||
Create an `authorized_keys` file in the boot partition of your SD card with your public key. After a boot it, you can acces your device as root over ssh on port 22222.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Read logoutput from supervisor:
|
||||
```bash
|
||||
journalctl -f -u resin-supervisor.service
|
||||
docker logs homeassistant
|
||||
```
|
||||
|
||||
## Install on a own System
|
||||
|
||||
We have a installer to install HassIO on own linux device without our hardware image:
|
||||
https://github.com/pvizeli/hassio-build/tree/master/install
|
||||
|
@@ -1,11 +1,10 @@
|
||||
"""Init file for HassIO addons."""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from .data import AddonsData
|
||||
from .git import AddonsRepo
|
||||
from .git import AddonsRepoHassIO, AddonsRepoCustom
|
||||
from ..const import STATE_STOPPED, STATE_STARTED
|
||||
from ..dock.addon import DockerAddon
|
||||
|
||||
@@ -21,16 +20,29 @@ class AddonManager(AddonsData):
|
||||
|
||||
self.loop = loop
|
||||
self.dock = dock
|
||||
self.repo = AddonsRepo(config, loop)
|
||||
self.repositories = []
|
||||
self.dockers = {}
|
||||
|
||||
async def prepare(self, arch):
|
||||
"""Startup addon management."""
|
||||
self.arch = arch
|
||||
|
||||
# init hassio repository
|
||||
self.repositories.append(AddonsRepoHassIO(self.config, self.loop))
|
||||
|
||||
# init custom repositories
|
||||
for url in self.config.addons_repositories:
|
||||
self.repositories.append(
|
||||
AddonsRepoCustom(self.config, self.loop, url))
|
||||
|
||||
# load addon repository
|
||||
if await self.repo.load():
|
||||
self.read_addons_repo()
|
||||
tasks = [addon.load() for addon in self.repositories]
|
||||
if tasks:
|
||||
await asyncio.wait(tasks, loop=self.loop)
|
||||
|
||||
# read data from repositories
|
||||
self.read_data_from_repositories()
|
||||
self.merge_update_config()
|
||||
|
||||
# load installed addons
|
||||
for addon in self.list_installed:
|
||||
@@ -38,23 +50,53 @@ class AddonManager(AddonsData):
|
||||
self.config, self.loop, self.dock, self, addon)
|
||||
await self.dockers[addon].attach()
|
||||
|
||||
async def add_git_repository(self, url):
|
||||
"""Add a new custom repository."""
|
||||
if url in self.config.addons_repositories:
|
||||
_LOGGER.warning("Repository already exists %s", url)
|
||||
return False
|
||||
|
||||
repo = AddonsRepoCustom(self.config, self.loop, url)
|
||||
|
||||
if not await repo.load():
|
||||
_LOGGER.error("Can't load from repository %s", url)
|
||||
return False
|
||||
|
||||
self.config.addons_repositories = url
|
||||
self.repositories.append(repo)
|
||||
return True
|
||||
|
||||
def drop_git_repository(self, url):
|
||||
"""Remove a custom repository."""
|
||||
for repo in self.repositories:
|
||||
if repo.url == url:
|
||||
self.repositories.remove(repo)
|
||||
self.config.drop_addon_repository(url)
|
||||
repo.remove()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def reload(self):
|
||||
"""Update addons from repo and reload list."""
|
||||
if not await self.repo.pull():
|
||||
tasks = [addon.pull() for addon in self.repositories]
|
||||
if not tasks:
|
||||
return
|
||||
self.read_addons_repo()
|
||||
|
||||
await asyncio.wait(tasks, loop=self.loop)
|
||||
|
||||
# read data from repositories
|
||||
self.read_data_from_repositories()
|
||||
self.merge_update_config()
|
||||
|
||||
# remove stalled addons
|
||||
for addon in self.list_removed:
|
||||
for addon in self.list_detached:
|
||||
_LOGGER.warning("Dedicated addon '%s' found!", addon)
|
||||
|
||||
async def auto_boot(self, start_type):
|
||||
"""Boot addons with mode auto."""
|
||||
boot_list = self.list_startup(start_type)
|
||||
tasks = []
|
||||
|
||||
for addon in boot_list:
|
||||
tasks.append(self.loop.create_task(self.start(addon)))
|
||||
tasks = [self.start(addon) for addon in boot_list]
|
||||
|
||||
_LOGGER.info("Startup %s run %d addons", start_type, len(tasks))
|
||||
if tasks:
|
||||
@@ -66,14 +108,18 @@ class AddonManager(AddonsData):
|
||||
_LOGGER.error("Addon %s not exists for install", addon)
|
||||
return False
|
||||
|
||||
if self.arch not in self.get_arch(addon):
|
||||
_LOGGER.error("Addon %s not supported on %s", addon, self.arch)
|
||||
return False
|
||||
|
||||
if self.is_installed(addon):
|
||||
_LOGGER.error("Addon %s is already installed", addon)
|
||||
return False
|
||||
|
||||
if not os.path.isdir(self.path_data(addon)):
|
||||
if not self.path_data(addon).is_dir():
|
||||
_LOGGER.info("Create Home-Assistant addon data folder %s",
|
||||
self.path_data(addon))
|
||||
os.mkdir(self.path_data(addon))
|
||||
self.path_data(addon).mkdir()
|
||||
|
||||
addon_docker = DockerAddon(
|
||||
self.config, self.loop, self.dock, self, addon)
|
||||
@@ -99,10 +145,10 @@ class AddonManager(AddonsData):
|
||||
if not await self.dockers[addon].remove():
|
||||
return False
|
||||
|
||||
if os.path.isdir(self.path_data(addon)):
|
||||
if self.path_data(addon).is_dir():
|
||||
_LOGGER.info("Remove Home-Assistant addon data folder %s",
|
||||
self.path_data(addon))
|
||||
shutil.rmtree(self.path_data(addon))
|
||||
shutil.rmtree(str(self.path_data(addon)))
|
||||
|
||||
self.dockers.pop(addon)
|
||||
self.set_addon_uninstall(addon)
|
||||
@@ -155,6 +201,14 @@ class AddonManager(AddonsData):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def restart(self, addon):
|
||||
"""Restart addon."""
|
||||
if addon not in self.dockers:
|
||||
_LOGGER.error("No docker found for addon %s", addon)
|
||||
return False
|
||||
|
||||
return await self.dockers[addon].restart()
|
||||
|
||||
async def logs(self, addon):
|
||||
"""Return addons log output."""
|
||||
if addon not in self.dockers:
|
||||
|
14
hassio/addons/built-in.json
Normal file
14
hassio/addons/built-in.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"local": {
|
||||
"slug": "local",
|
||||
"name": "Local Add-Ons",
|
||||
"url": "https://home-assistant.io/hassio",
|
||||
"maintainer": "By our self"
|
||||
},
|
||||
"core": {
|
||||
"slug": "core",
|
||||
"name": "Built-in Add-Ons",
|
||||
"url": "https://home-assistant.io/addons",
|
||||
"maintainer": "Home Assistant authors"
|
||||
}
|
||||
}
|
@@ -1,24 +1,30 @@
|
||||
"""Init file for HassIO addons."""
|
||||
import copy
|
||||
import logging
|
||||
import glob
|
||||
import json
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from .validate import validate_options, SCHEMA_ADDON_CONFIG
|
||||
from .util import extract_hash_from_path
|
||||
from .validate import (
|
||||
validate_options, SCHEMA_ADDON_CONFIG, SCHEMA_REPOSITORY_CONFIG)
|
||||
from ..const import (
|
||||
FILE_HASSIO_ADDONS, ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON,
|
||||
ATTR_STARTUP, ATTR_BOOT, ATTR_MAP_SSL, ATTR_MAP_CONFIG, ATTR_OPTIONS,
|
||||
ATTR_PORTS, BOOT_AUTO, DOCKER_REPO, ATTR_INSTALLED, ATTR_SCHEMA,
|
||||
ATTR_IMAGE, ATTR_DEDICATED)
|
||||
ATTR_STARTUP, ATTR_BOOT, ATTR_MAP, ATTR_OPTIONS, ATTR_PORTS, BOOT_AUTO,
|
||||
DOCKER_REPO, ATTR_SCHEMA, ATTR_IMAGE, MAP_CONFIG, MAP_SSL, MAP_ADDONS,
|
||||
MAP_BACKUP, ATTR_REPOSITORY, ATTR_URL, ATTR_ARCH)
|
||||
from ..config import Config
|
||||
from ..tools import read_json_file, write_json_file
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ADDONS_REPO_PATTERN = "{}/*/config.json"
|
||||
SYSTEM = "system"
|
||||
USER = "user"
|
||||
SYSTEM = 'system'
|
||||
USER = 'user'
|
||||
|
||||
REPOSITORY_CORE = 'core'
|
||||
REPOSITORY_LOCAL = 'local'
|
||||
|
||||
|
||||
class AddonsData(Config):
|
||||
@@ -30,7 +36,8 @@ class AddonsData(Config):
|
||||
self.config = config
|
||||
self._system_data = self._data.get(SYSTEM, {})
|
||||
self._user_data = self._data.get(USER, {})
|
||||
self._current_data = {}
|
||||
self._addons_cache = {}
|
||||
self._repositories_data = {}
|
||||
self.arch = None
|
||||
|
||||
def save(self):
|
||||
@@ -41,56 +48,137 @@ class AddonsData(Config):
|
||||
}
|
||||
super().save()
|
||||
|
||||
def read_addons_repo(self):
|
||||
def read_data_from_repositories(self):
|
||||
"""Read data from addons repository."""
|
||||
self._current_data = {}
|
||||
self._addons_cache = {}
|
||||
self._repositories_data = {}
|
||||
|
||||
self._read_addons_folder(self.config.path_addons_repo)
|
||||
self._read_addons_folder(self.config.path_addons_custom)
|
||||
# read core repository
|
||||
self._read_addons_folder(
|
||||
self.config.path_addons_core, REPOSITORY_CORE)
|
||||
|
||||
def _read_addons_folder(self, folder):
|
||||
# read local repository
|
||||
self._read_addons_folder(
|
||||
self.config.path_addons_local, REPOSITORY_LOCAL)
|
||||
|
||||
# add built-in repositories information
|
||||
self._set_builtin_repositories()
|
||||
|
||||
# read custom git repositories
|
||||
for repository_element in self.config.path_addons_git.iterdir():
|
||||
if repository_element.is_dir():
|
||||
self._read_git_repository(repository_element)
|
||||
|
||||
def _read_git_repository(self, path):
|
||||
"""Process a custom repository folder."""
|
||||
slug = extract_hash_from_path(path)
|
||||
repository_info = {ATTR_SLUG: slug}
|
||||
|
||||
# exists repository json
|
||||
repository_file = Path(path, "repository.json")
|
||||
try:
|
||||
repository_info.update(SCHEMA_REPOSITORY_CONFIG(
|
||||
read_json_file(repository_file)
|
||||
))
|
||||
|
||||
except OSError:
|
||||
_LOGGER.warning("Can't read repository information from %s",
|
||||
repository_file)
|
||||
return
|
||||
|
||||
except vol.Invalid:
|
||||
_LOGGER.warning("Repository parse error %s", repository_file)
|
||||
return
|
||||
|
||||
# process data
|
||||
self._repositories_data[slug] = repository_info
|
||||
self._read_addons_folder(path, slug)
|
||||
|
||||
def _read_addons_folder(self, path, repository):
|
||||
"""Read data from addons folder."""
|
||||
pattern = ADDONS_REPO_PATTERN.format(folder)
|
||||
|
||||
for addon in glob.iglob(pattern):
|
||||
for addon in path.glob("**/config.json"):
|
||||
try:
|
||||
addon_config = read_json_file(addon)
|
||||
|
||||
# validate
|
||||
addon_config = SCHEMA_ADDON_CONFIG(addon_config)
|
||||
self._current_data[addon_config[ATTR_SLUG]] = addon_config
|
||||
|
||||
except (OSError, KeyError):
|
||||
# Generate slug
|
||||
addon_slug = "{}_{}".format(
|
||||
repository, addon_config[ATTR_SLUG])
|
||||
|
||||
# store
|
||||
addon_config[ATTR_REPOSITORY] = repository
|
||||
self._addons_cache[addon_slug] = addon_config
|
||||
|
||||
except OSError:
|
||||
_LOGGER.warning("Can't read %s", addon)
|
||||
|
||||
except vol.Invalid as ex:
|
||||
_LOGGER.warning("Can't read %s -> %s", addon,
|
||||
humanize_error(addon_config, ex))
|
||||
|
||||
def _set_builtin_repositories(self):
|
||||
"""Add local built-in repository into dataset."""
|
||||
try:
|
||||
builtin_file = Path(__file__).parent.joinpath('built-in.json')
|
||||
builtin_data = read_json_file(builtin_file)
|
||||
except (OSError, json.JSONDecodeError) as err:
|
||||
_LOGGER.warning("Can't read built-in.json -> %s", err)
|
||||
return
|
||||
|
||||
# if core addons are available
|
||||
for data in self._addons_cache.values():
|
||||
if data[ATTR_REPOSITORY] == REPOSITORY_CORE:
|
||||
self._repositories_data[REPOSITORY_CORE] = \
|
||||
builtin_data[REPOSITORY_CORE]
|
||||
break
|
||||
|
||||
# if local addons are available
|
||||
for data in self._addons_cache.values():
|
||||
if data[ATTR_REPOSITORY] == REPOSITORY_LOCAL:
|
||||
self._repositories_data[REPOSITORY_LOCAL] = \
|
||||
builtin_data[REPOSITORY_LOCAL]
|
||||
break
|
||||
|
||||
def merge_update_config(self):
|
||||
"""Update local config if they have update.
|
||||
|
||||
It need to be the same version as the local version is.
|
||||
"""
|
||||
have_change = False
|
||||
|
||||
for addon, data in self._system_data.items():
|
||||
# detached
|
||||
if addon not in self._addons_cache:
|
||||
continue
|
||||
|
||||
cache = self._addons_cache[addon]
|
||||
if data[ATTR_VERSION] == cache[ATTR_VERSION]:
|
||||
if data != cache:
|
||||
self._system_data[addon] = copy.deepcopy(cache)
|
||||
have_change = True
|
||||
|
||||
if have_change:
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def list_installed(self):
|
||||
"""Return a list of installed addons."""
|
||||
return set(self._system_data.keys())
|
||||
|
||||
@property
|
||||
def list_api(self):
|
||||
"""Return a list of available addons for api."""
|
||||
data = []
|
||||
all_addons = {**self._system_data, **self._current_data}
|
||||
dedicated = self.list_removed
|
||||
def data_all(self):
|
||||
"""Return a dict of all addons."""
|
||||
return {
|
||||
**self._system_data,
|
||||
**self._addons_cache
|
||||
}
|
||||
|
||||
for addon, values in all_addons.items():
|
||||
i_version = self._user_data.get(addon, {}).get(ATTR_VERSION)
|
||||
|
||||
data.append({
|
||||
ATTR_NAME: values[ATTR_NAME],
|
||||
ATTR_SLUG: values[ATTR_SLUG],
|
||||
ATTR_DESCRIPTON: values[ATTR_DESCRIPTON],
|
||||
ATTR_VERSION: values[ATTR_VERSION],
|
||||
ATTR_INSTALLED: i_version,
|
||||
ATTR_DEDICATED: addon in dedicated,
|
||||
})
|
||||
|
||||
return data
|
||||
@property
|
||||
def data_installed(self):
|
||||
"""Return a dict of installed addons."""
|
||||
return self._system_data.copy()
|
||||
|
||||
def list_startup(self, start_type):
|
||||
"""Get list of installed addon with need start by type."""
|
||||
@@ -109,18 +197,23 @@ class AddonsData(Config):
|
||||
return addon_list
|
||||
|
||||
@property
|
||||
def list_removed(self):
|
||||
def list_detached(self):
|
||||
"""Return local addons they not support from repo."""
|
||||
addon_list = set()
|
||||
for addon in self._system_data.keys():
|
||||
if addon not in self._current_data:
|
||||
if addon not in self._addons_cache:
|
||||
addon_list.add(addon)
|
||||
|
||||
return addon_list
|
||||
|
||||
@property
|
||||
def list_repositories(self):
|
||||
"""Return list of addon repositories."""
|
||||
return list(self._repositories_data.values())
|
||||
|
||||
def exists_addon(self, addon):
|
||||
"""Return True if a addon exists."""
|
||||
return addon in self._current_data or addon in self._system_data
|
||||
return addon in self._addons_cache or addon in self._system_data
|
||||
|
||||
def is_installed(self, addon):
|
||||
"""Return True if a addon is installed."""
|
||||
@@ -128,11 +221,11 @@ class AddonsData(Config):
|
||||
|
||||
def version_installed(self, addon):
|
||||
"""Return installed version."""
|
||||
return self._user_data[addon][ATTR_VERSION]
|
||||
return self._user_data.get(addon, {}).get(ATTR_VERSION)
|
||||
|
||||
def set_addon_install(self, addon, version):
|
||||
"""Set addon as installed."""
|
||||
self._system_data[addon] = self._current_data[addon]
|
||||
self._system_data[addon] = copy.deepcopy(self._addons_cache[addon])
|
||||
self._user_data[addon] = {
|
||||
ATTR_OPTIONS: {},
|
||||
ATTR_VERSION: version,
|
||||
@@ -147,13 +240,13 @@ class AddonsData(Config):
|
||||
|
||||
def set_addon_update(self, addon, version):
|
||||
"""Update version of addon."""
|
||||
self._system_data[addon] = self._current_data[addon]
|
||||
self._system_data[addon] = copy.deepcopy(self._addons_cache[addon])
|
||||
self._user_data[addon][ATTR_VERSION] = version
|
||||
self.save()
|
||||
|
||||
def set_options(self, addon, options):
|
||||
"""Store user addon options."""
|
||||
self._user_data[addon][ATTR_OPTIONS] = options
|
||||
self._user_data[addon][ATTR_OPTIONS] = copy.deepcopy(options)
|
||||
self.save()
|
||||
|
||||
def set_boot(self, addon, boot):
|
||||
@@ -183,44 +276,68 @@ class AddonsData(Config):
|
||||
"""Return description of addon."""
|
||||
return self._system_data[addon][ATTR_DESCRIPTON]
|
||||
|
||||
def get_repository(self, addon):
|
||||
"""Return repository of addon."""
|
||||
return self._system_data[addon][ATTR_REPOSITORY]
|
||||
|
||||
def get_last_version(self, addon):
|
||||
"""Return version of addon."""
|
||||
if addon not in self._current_data:
|
||||
if addon not in self._addons_cache:
|
||||
return self.version_installed(addon)
|
||||
return self._current_data[addon][ATTR_VERSION]
|
||||
return self._addons_cache[addon][ATTR_VERSION]
|
||||
|
||||
def get_ports(self, addon):
|
||||
"""Return ports of addon."""
|
||||
return self._system_data[addon].get(ATTR_PORTS)
|
||||
|
||||
def get_url(self, addon):
|
||||
"""Return url of addon."""
|
||||
return self._system_data[addon].get(ATTR_URL)
|
||||
|
||||
def get_arch(self, addon):
|
||||
"""Return list of supported arch."""
|
||||
if addon not in self._addons_cache:
|
||||
return self._system_data[addon][ATTR_ARCH]
|
||||
return self._addons_cache[addon][ATTR_ARCH]
|
||||
|
||||
def get_image(self, addon):
|
||||
"""Return image name of addon."""
|
||||
addon_data = self._system_data.get(addon, self._current_data[addon])
|
||||
addon_data = self._system_data.get(
|
||||
addon, self._addons_cache.get(addon))
|
||||
|
||||
if ATTR_IMAGE not in addon_data:
|
||||
return "{}/{}-addon-{}".format(DOCKER_REPO, self.arch, addon)
|
||||
return "{}/{}-addon-{}".format(
|
||||
DOCKER_REPO, self.arch, addon_data[ATTR_SLUG])
|
||||
|
||||
return addon_data[ATTR_IMAGE]
|
||||
return addon_data[ATTR_IMAGE].format(arch=self.arch)
|
||||
|
||||
def need_config(self, addon):
|
||||
def map_config(self, addon):
|
||||
"""Return True if config map is needed."""
|
||||
return self._system_data[addon][ATTR_MAP_CONFIG]
|
||||
return MAP_CONFIG in self._system_data[addon][ATTR_MAP]
|
||||
|
||||
def need_ssl(self, addon):
|
||||
def map_ssl(self, addon):
|
||||
"""Return True if ssl map is needed."""
|
||||
return self._system_data[addon][ATTR_MAP_SSL]
|
||||
return MAP_SSL in self._system_data[addon][ATTR_MAP]
|
||||
|
||||
def map_addons(self, addon):
|
||||
"""Return True if addons map is needed."""
|
||||
return MAP_ADDONS in self._system_data[addon][ATTR_MAP]
|
||||
|
||||
def map_backup(self, addon):
|
||||
"""Return True if backup map is needed."""
|
||||
return MAP_BACKUP in self._system_data[addon][ATTR_MAP]
|
||||
|
||||
def path_data(self, addon):
|
||||
"""Return addon data path inside supervisor."""
|
||||
return "{}/{}".format(self.config.path_addons_data, addon)
|
||||
return Path(self.config.path_addons_data, addon)
|
||||
|
||||
def path_data_docker(self, addon):
|
||||
def path_extern_data(self, addon):
|
||||
"""Return addon data path external for docker."""
|
||||
return "{}/{}".format(self.config.path_addons_data_docker, addon)
|
||||
return str(PurePath(self.config.path_extern_addons_data, addon))
|
||||
|
||||
def path_addon_options(self, addon):
|
||||
"""Return path to addons options."""
|
||||
return "{}/options.json".format(self.path_data(addon))
|
||||
return Path(self.path_data(addon), "options.json")
|
||||
|
||||
def write_addon_options(self, addon):
|
||||
"""Return True if addon options is written to data."""
|
||||
|
@@ -1,10 +1,12 @@
|
||||
"""Init file for HassIO addons git."""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
import git
|
||||
|
||||
from .util import get_hash_from_repository
|
||||
from ..const import URL_HASSIO_ADDONS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -13,26 +15,29 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AddonsRepo(object):
|
||||
"""Manage addons git repo."""
|
||||
|
||||
def __init__(self, config, loop):
|
||||
"""Initialize docker base wrapper."""
|
||||
def __init__(self, config, loop, path, url):
|
||||
"""Initialize git base wrapper."""
|
||||
self.config = config
|
||||
self.loop = loop
|
||||
self.repo = None
|
||||
self.path = path
|
||||
self.url = url
|
||||
self._lock = asyncio.Lock(loop=loop)
|
||||
|
||||
async def load(self):
|
||||
"""Init git addon repo."""
|
||||
if not os.path.isdir(self.config.path_addons_repo):
|
||||
if not self.path.is_dir():
|
||||
return await self.clone()
|
||||
|
||||
async with self._lock:
|
||||
try:
|
||||
_LOGGER.info("Load addons repository")
|
||||
_LOGGER.info("Load addon %s repository", self.path)
|
||||
self.repo = await self.loop.run_in_executor(
|
||||
None, git.Repo, self.config.path_addons_repo)
|
||||
None, git.Repo, str(self.path))
|
||||
|
||||
except (git.InvalidGitRepositoryError, git.NoSuchPathError) as err:
|
||||
_LOGGER.error("Can't load addons repo: %s.", err)
|
||||
except (git.InvalidGitRepositoryError, git.NoSuchPathError,
|
||||
git.GitCommandError) as err:
|
||||
_LOGGER.error("Can't load %s repo: %s.", self.path, err)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -41,13 +46,13 @@ class AddonsRepo(object):
|
||||
"""Clone git addon repo."""
|
||||
async with self._lock:
|
||||
try:
|
||||
_LOGGER.info("Clone addons repository")
|
||||
_LOGGER.info("Clone addon %s repository", self.url)
|
||||
self.repo = await self.loop.run_in_executor(
|
||||
None, git.Repo.clone_from, URL_HASSIO_ADDONS,
|
||||
self.config.path_addons_repo)
|
||||
None, git.Repo.clone_from, self.url, str(self.path))
|
||||
|
||||
except (git.InvalidGitRepositoryError, git.NoSuchPathError) as err:
|
||||
_LOGGER.error("Can't clone addons repo: %s.", err)
|
||||
except (git.InvalidGitRepositoryError, git.NoSuchPathError,
|
||||
git.GitCommandError) as err:
|
||||
_LOGGER.error("Can't clone %s repo: %s.", self.url, err)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -60,12 +65,43 @@ class AddonsRepo(object):
|
||||
|
||||
async with self._lock:
|
||||
try:
|
||||
_LOGGER.info("Pull addons repository")
|
||||
_LOGGER.info("Pull addon %s repository", self.url)
|
||||
await self.loop.run_in_executor(
|
||||
None, self.repo.remotes.origin.pull)
|
||||
|
||||
except (git.InvalidGitRepositoryError, git.NoSuchPathError) as err:
|
||||
_LOGGER.error("Can't pull addons repo: %s.", err)
|
||||
except (git.InvalidGitRepositoryError, git.NoSuchPathError,
|
||||
git.exc.GitCommandError) as err:
|
||||
_LOGGER.error("Can't pull %s repo: %s.", self.url, err)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AddonsRepoHassIO(AddonsRepo):
|
||||
"""HassIO addons repository."""
|
||||
|
||||
def __init__(self, config, loop):
|
||||
"""Initialize git hassio addon repository."""
|
||||
super().__init__(
|
||||
config, loop, config.path_addons_core, URL_HASSIO_ADDONS)
|
||||
|
||||
|
||||
class AddonsRepoCustom(AddonsRepo):
|
||||
"""Custom addons repository."""
|
||||
|
||||
def __init__(self, config, loop, url):
|
||||
"""Initialize git hassio addon repository."""
|
||||
path = Path(config.path_addons_git, get_hash_from_repository(url))
|
||||
|
||||
super().__init__(config, loop, path, url)
|
||||
|
||||
def remove(self):
|
||||
"""Remove a custom addon."""
|
||||
if self.path.is_dir():
|
||||
_LOGGER.info("Remove custom addon repository %s", self.url)
|
||||
|
||||
def log_err(funct, path, _):
|
||||
"""Log error."""
|
||||
_LOGGER.warning("Can't remove %s", path)
|
||||
|
||||
shutil.rmtree(str(self.path), onerror=log_err)
|
||||
|
26
hassio/addons/util.py
Normal file
26
hassio/addons/util.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Util addons functions."""
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
RE_SLUGIFY = re.compile(r'[^a-z0-9_]+')
|
||||
RE_SHA1 = re.compile(r"[a-f0-9]{8}")
|
||||
|
||||
|
||||
def get_hash_from_repository(name):
|
||||
"""Generate a hash from repository."""
|
||||
key = name.lower().encode()
|
||||
return hashlib.sha1(key).hexdigest()[:8]
|
||||
|
||||
|
||||
def extract_hash_from_path(path):
|
||||
"""Extract repo id from path."""
|
||||
repo_dir = path.parts[-1]
|
||||
|
||||
if not RE_SHA1.match(repo_dir):
|
||||
return get_hash_from_repository(repo_dir)
|
||||
return repo_dir
|
||||
|
||||
|
||||
def create_hash_index_list(name_list):
|
||||
"""Create a dict with hash from repositories list."""
|
||||
return {get_hash_from_repository(repo): repo for repo in name_list}
|
@@ -3,9 +3,10 @@ import voluptuous as vol
|
||||
|
||||
from ..const import (
|
||||
ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_STARTUP,
|
||||
ATTR_BOOT, ATTR_MAP_SSL, ATTR_MAP_CONFIG, ATTR_OPTIONS,
|
||||
ATTR_PORTS, STARTUP_ONCE, STARTUP_AFTER, STARTUP_BEFORE, BOOT_AUTO,
|
||||
BOOT_MANUAL, ATTR_SCHEMA, ATTR_IMAGE)
|
||||
ATTR_BOOT, ATTR_MAP, ATTR_OPTIONS, ATTR_PORTS, STARTUP_ONCE, STARTUP_AFTER,
|
||||
STARTUP_BEFORE, BOOT_AUTO, BOOT_MANUAL, ATTR_SCHEMA, ATTR_IMAGE, MAP_SSL,
|
||||
MAP_CONFIG, MAP_ADDONS, MAP_BACKUP, ATTR_URL, ATTR_MAINTAINER, ATTR_ARCH,
|
||||
ARCH_ARMHF, ARCH_AARCH64, ARCH_AMD64, ARCH_I386)
|
||||
|
||||
V_STR = 'str'
|
||||
V_INT = 'int'
|
||||
@@ -16,19 +17,26 @@ V_URL = 'url'
|
||||
|
||||
ADDON_ELEMENT = vol.In([V_STR, V_INT, V_FLOAT, V_BOOL, V_EMAIL, V_URL])
|
||||
|
||||
ARCH_ALL = [
|
||||
ARCH_ARMHF, ARCH_AARCH64, ARCH_AMD64, ARCH_I386
|
||||
]
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_ADDON_CONFIG = vol.Schema({
|
||||
vol.Required(ATTR_NAME): vol.Coerce(str),
|
||||
vol.Required(ATTR_VERSION): vol.Coerce(str),
|
||||
vol.Required(ATTR_SLUG): vol.Coerce(str),
|
||||
vol.Required(ATTR_DESCRIPTON): vol.Coerce(str),
|
||||
vol.Optional(ATTR_URL): vol.Url(),
|
||||
vol.Optional(ATTR_ARCH, default=ARCH_ALL): [vol.In(ARCH_ALL)],
|
||||
vol.Required(ATTR_STARTUP):
|
||||
vol.In([STARTUP_BEFORE, STARTUP_AFTER, STARTUP_ONCE]),
|
||||
vol.Required(ATTR_BOOT):
|
||||
vol.In([BOOT_AUTO, BOOT_MANUAL]),
|
||||
vol.Optional(ATTR_PORTS): dict,
|
||||
vol.Optional(ATTR_MAP_CONFIG, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_MAP_SSL, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_MAP, default=[]): [
|
||||
vol.In([MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP])
|
||||
],
|
||||
vol.Required(ATTR_OPTIONS): dict,
|
||||
vol.Required(ATTR_SCHEMA): {
|
||||
vol.Coerce(str): vol.Any(ADDON_ELEMENT, [
|
||||
@@ -36,7 +44,15 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
|
||||
])
|
||||
},
|
||||
vol.Optional(ATTR_IMAGE): vol.Match(r"\w*/\w*"),
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_REPOSITORY_CONFIG = vol.Schema({
|
||||
vol.Required(ATTR_NAME): vol.Coerce(str),
|
||||
vol.Optional(ATTR_URL): vol.Url(),
|
||||
vol.Optional(ATTR_MAINTAINER): vol.Coerce(str),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def validate_options(raw_schema):
|
||||
@@ -71,6 +87,10 @@ def validate_options(raw_schema):
|
||||
def _single_validate(typ, value):
|
||||
"""Validate a single element."""
|
||||
try:
|
||||
# if required argument
|
||||
if value is None:
|
||||
raise vol.Invalid("A required argument is not set!")
|
||||
|
||||
if typ == V_STR:
|
||||
return str(value)
|
||||
elif typ == V_INT:
|
||||
@@ -85,7 +105,7 @@ def _single_validate(typ, value):
|
||||
return vol.Url()(value)
|
||||
|
||||
raise vol.Invalid("Fatal error for {}.".format(value))
|
||||
except TypeError:
|
||||
except ValueError:
|
||||
raise vol.Invalid(
|
||||
"Type {} error for {}.".format(typ, value)) from None
|
||||
|
||||
|
@@ -30,16 +30,16 @@ class RestAPI(object):
|
||||
api_host = APIHost(self.config, self.loop, host_control)
|
||||
|
||||
self.webapp.router.add_get('/host/info', api_host.info)
|
||||
self.webapp.router.add_get('/host/reboot', api_host.reboot)
|
||||
self.webapp.router.add_get('/host/shutdown', api_host.shutdown)
|
||||
self.webapp.router.add_get('/host/update', api_host.update)
|
||||
self.webapp.router.add_post('/host/reboot', api_host.reboot)
|
||||
self.webapp.router.add_post('/host/shutdown', api_host.shutdown)
|
||||
self.webapp.router.add_post('/host/update', api_host.update)
|
||||
|
||||
def register_network(self, host_control):
|
||||
"""Register network function."""
|
||||
api_net = APINetwork(self.config, self.loop, host_control)
|
||||
|
||||
self.webapp.router.add_get('/network/info', api_net.info)
|
||||
self.webapp.router.add_get('/network/options', api_net.options)
|
||||
self.webapp.router.add_post('/network/options', api_net.options)
|
||||
|
||||
def register_supervisor(self, supervisor, addons, host_control):
|
||||
"""Register supervisor function."""
|
||||
@@ -48,9 +48,13 @@ class RestAPI(object):
|
||||
|
||||
self.webapp.router.add_get('/supervisor/ping', api_supervisor.ping)
|
||||
self.webapp.router.add_get('/supervisor/info', api_supervisor.info)
|
||||
self.webapp.router.add_get('/supervisor/update', api_supervisor.update)
|
||||
self.webapp.router.add_get('/supervisor/reload', api_supervisor.reload)
|
||||
self.webapp.router.add_get(
|
||||
'/supervisor/addons', api_supervisor.available_addons)
|
||||
self.webapp.router.add_post(
|
||||
'/supervisor/update', api_supervisor.update)
|
||||
self.webapp.router.add_post(
|
||||
'/supervisor/reload', api_supervisor.reload)
|
||||
self.webapp.router.add_post(
|
||||
'/supervisor/options', api_supervisor.options)
|
||||
self.webapp.router.add_get('/supervisor/logs', api_supervisor.logs)
|
||||
|
||||
@@ -59,7 +63,8 @@ class RestAPI(object):
|
||||
api_hass = APIHomeAssistant(self.config, self.loop, dock_homeassistant)
|
||||
|
||||
self.webapp.router.add_get('/homeassistant/info', api_hass.info)
|
||||
self.webapp.router.add_get('/homeassistant/update', api_hass.update)
|
||||
self.webapp.router.add_post('/homeassistant/update', api_hass.update)
|
||||
self.webapp.router.add_post('/homeassistant/restart', api_hass.restart)
|
||||
self.webapp.router.add_get('/homeassistant/logs', api_hass.logs)
|
||||
|
||||
def register_addons(self, addons):
|
||||
@@ -67,14 +72,17 @@ class RestAPI(object):
|
||||
api_addons = APIAddons(self.config, self.loop, addons)
|
||||
|
||||
self.webapp.router.add_get('/addons/{addon}/info', api_addons.info)
|
||||
self.webapp.router.add_get(
|
||||
self.webapp.router.add_post(
|
||||
'/addons/{addon}/install', api_addons.install)
|
||||
self.webapp.router.add_get(
|
||||
self.webapp.router.add_post(
|
||||
'/addons/{addon}/uninstall', api_addons.uninstall)
|
||||
self.webapp.router.add_get('/addons/{addon}/start', api_addons.start)
|
||||
self.webapp.router.add_get('/addons/{addon}/stop', api_addons.stop)
|
||||
self.webapp.router.add_get('/addons/{addon}/update', api_addons.update)
|
||||
self.webapp.router.add_get(
|
||||
self.webapp.router.add_post('/addons/{addon}/start', api_addons.start)
|
||||
self.webapp.router.add_post('/addons/{addon}/stop', api_addons.stop)
|
||||
self.webapp.router.add_post(
|
||||
'/addons/{addon}/restart', api_addons.restart)
|
||||
self.webapp.router.add_post(
|
||||
'/addons/{addon}/update', api_addons.update)
|
||||
self.webapp.router.add_post(
|
||||
'/addons/{addon}/options', api_addons.options)
|
||||
self.webapp.router.add_get('/addons/{addon}/logs', api_addons.logs)
|
||||
|
||||
|
@@ -8,6 +8,7 @@ from voluptuous.humanize import humanize_error
|
||||
from .util import api_process, api_process_raw, api_validate
|
||||
from ..const import (
|
||||
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS,
|
||||
ATTR_URL, ATTR_DESCRIPTON, ATTR_DETACHED, ATTR_NAME, ATTR_REPOSITORY,
|
||||
STATE_STOPPED, STATE_STARTED, BOOT_AUTO, BOOT_MANUAL)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -48,11 +49,16 @@ class APIAddons(object):
|
||||
addon = self._extract_addon(request)
|
||||
|
||||
return {
|
||||
ATTR_NAME: self.addons.get_name(addon),
|
||||
ATTR_DESCRIPTON: self.addons.get_description(addon),
|
||||
ATTR_VERSION: self.addons.version_installed(addon),
|
||||
ATTR_REPOSITORY: self.addons.get_repository(addon),
|
||||
ATTR_LAST_VERSION: self.addons.get_last_version(addon),
|
||||
ATTR_STATE: await self.addons.state(addon),
|
||||
ATTR_BOOT: self.addons.get_boot(addon),
|
||||
ATTR_OPTIONS: self.addons.get_options(addon),
|
||||
ATTR_URL: self.addons.get_url(addon),
|
||||
ATTR_DETACHED: addon in self.addons.list_detached,
|
||||
}
|
||||
|
||||
@api_process
|
||||
@@ -82,6 +88,11 @@ class APIAddons(object):
|
||||
version = body.get(
|
||||
ATTR_VERSION, self.addons.get_last_version(addon))
|
||||
|
||||
# check if arch supported
|
||||
if self.addons.arch not in self.addons.get_arch(addon):
|
||||
raise RuntimeError(
|
||||
"Addon is not supported on {}".format(self.addons.arch))
|
||||
|
||||
return await asyncio.shield(
|
||||
self.addons.install(addon, version), loop=self.loop)
|
||||
|
||||
@@ -137,6 +148,12 @@ class APIAddons(object):
|
||||
return await asyncio.shield(
|
||||
self.addons.update(addon, version), loop=self.loop)
|
||||
|
||||
@api_process
|
||||
async def restart(self, request):
|
||||
"""Restart addon."""
|
||||
addon = self._extract_addon(request)
|
||||
return await asyncio.shield(self.addons.restart(addon), loop=self.loop)
|
||||
|
||||
@api_process_raw
|
||||
def logs(self, request):
|
||||
"""Return logs from addon."""
|
||||
|
@@ -35,7 +35,7 @@ class APIHomeAssistant(object):
|
||||
|
||||
@api_process
|
||||
async def update(self, request):
|
||||
"""Update host OS."""
|
||||
"""Update homeassistant."""
|
||||
body = await api_validate(SCHEMA_VERSION, request)
|
||||
version = body.get(ATTR_VERSION, self.config.last_homeassistant)
|
||||
|
||||
@@ -48,6 +48,15 @@ class APIHomeAssistant(object):
|
||||
return await asyncio.shield(
|
||||
self.homeassistant.update(version), loop=self.loop)
|
||||
|
||||
@api_process
|
||||
async def restart(self, request):
|
||||
"""Restart homeassistant."""
|
||||
if self.homeassistant.in_progress:
|
||||
raise RuntimeError("Other task is in progress")
|
||||
|
||||
return await asyncio.shield(
|
||||
self.homeassistant.restart(), loop=self.loop)
|
||||
|
||||
@api_process_raw
|
||||
def logs(self, request):
|
||||
"""Return homeassistant docker logs.
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Init file for HassIO host rest api."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -30,7 +31,7 @@ class APIHost(object):
|
||||
return {
|
||||
ATTR_TYPE: self.host_control.type,
|
||||
ATTR_VERSION: self.host_control.version,
|
||||
ATTR_LAST_VERSION: self.host_control.last,
|
||||
ATTR_LAST_VERSION: self.host_control.last_version,
|
||||
ATTR_FEATURES: self.host_control.features,
|
||||
ATTR_HOSTNAME: self.host_control.hostname,
|
||||
ATTR_OS: self.host_control.os_info,
|
||||
@@ -50,9 +51,10 @@ class APIHost(object):
|
||||
async def update(self, request):
|
||||
"""Update host OS."""
|
||||
body = await api_validate(SCHEMA_VERSION, request)
|
||||
version = body.get(ATTR_VERSION)
|
||||
version = body.get(ATTR_VERSION, self.host_control.last_version)
|
||||
|
||||
if version == self.host_control.version:
|
||||
raise RuntimeError("Version is already in use")
|
||||
|
||||
return await self.host_control.update(version=version)
|
||||
return await asyncio.shield(
|
||||
self.host_control.update(version=version), loop=self.loop)
|
||||
|
@@ -5,15 +5,19 @@ import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from .util import api_process, api_process_raw, api_validate
|
||||
from ..addons.util import create_hash_index_list
|
||||
from ..const import (
|
||||
ATTR_ADDONS, ATTR_VERSION, ATTR_LAST_VERSION, ATTR_BETA_CHANNEL,
|
||||
HASSIO_VERSION)
|
||||
HASSIO_VERSION, ATTR_ADDONS_REPOSITORIES, ATTR_REPOSITORIES,
|
||||
ATTR_REPOSITORY, ATTR_DESCRIPTON, ATTR_NAME, ATTR_SLUG, ATTR_INSTALLED,
|
||||
ATTR_DETACHED, ATTR_SOURCE, ATTR_MAINTAINER, ATTR_URL, ATTR_ARCH)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_OPTIONS = vol.Schema({
|
||||
# pylint: disable=no-value-for-parameter
|
||||
vol.Optional(ATTR_BETA_CHANNEL): vol.Boolean(),
|
||||
vol.Optional(ATTR_ADDONS_REPOSITORIES): [vol.Url()],
|
||||
})
|
||||
|
||||
SCHEMA_VERSION = vol.Schema({
|
||||
@@ -32,6 +36,48 @@ class APISupervisor(object):
|
||||
self.addons = addons
|
||||
self.host_control = host_control
|
||||
|
||||
def _addons_list(self, only_installed=False):
|
||||
"""Return a list of addons."""
|
||||
detached = self.addons.list_detached
|
||||
|
||||
if only_installed:
|
||||
addons = self.addons.data_installed
|
||||
else:
|
||||
addons = self.addons.data_all
|
||||
|
||||
data = []
|
||||
for addon, values in addons.items():
|
||||
i_version = self.addons.version_installed(addon)
|
||||
|
||||
data.append({
|
||||
ATTR_NAME: values[ATTR_NAME],
|
||||
ATTR_SLUG: addon,
|
||||
ATTR_DESCRIPTON: values[ATTR_DESCRIPTON],
|
||||
ATTR_VERSION: values[ATTR_VERSION],
|
||||
ATTR_INSTALLED: i_version,
|
||||
ATTR_ARCH: values[ATTR_ARCH],
|
||||
ATTR_DETACHED: addon in detached,
|
||||
ATTR_REPOSITORY: values[ATTR_REPOSITORY],
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def _repositories_list(self):
|
||||
"""Return a list of addons repositories."""
|
||||
data = []
|
||||
list_id = create_hash_index_list(self.config.addons_repositories)
|
||||
|
||||
for repository in self.addons.list_repositories:
|
||||
data.append({
|
||||
ATTR_SLUG: repository[ATTR_SLUG],
|
||||
ATTR_NAME: repository[ATTR_NAME],
|
||||
ATTR_SOURCE: list_id.get(repository[ATTR_SLUG]),
|
||||
ATTR_URL: repository.get(ATTR_URL),
|
||||
ATTR_MAINTAINER: repository.get(ATTR_MAINTAINER),
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
@api_process
|
||||
async def ping(self, request):
|
||||
"""Return ok for signal that the api is ready."""
|
||||
@@ -44,7 +90,17 @@ class APISupervisor(object):
|
||||
ATTR_VERSION: HASSIO_VERSION,
|
||||
ATTR_LAST_VERSION: self.config.last_hassio,
|
||||
ATTR_BETA_CHANNEL: self.config.upstream_beta,
|
||||
ATTR_ADDONS: self.addons.list_api,
|
||||
ATTR_ARCH: self.addons.arch,
|
||||
ATTR_ADDONS: self._addons_list(only_installed=True),
|
||||
ATTR_ADDONS_REPOSITORIES: self.config.addons_repositories,
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def available_addons(self, request):
|
||||
"""Return information for all available addons."""
|
||||
return {
|
||||
ATTR_ADDONS: self._addons_list(),
|
||||
ATTR_REPOSITORIES: self._repositories_list(),
|
||||
}
|
||||
|
||||
@api_process
|
||||
@@ -55,7 +111,25 @@ class APISupervisor(object):
|
||||
if ATTR_BETA_CHANNEL in body:
|
||||
self.config.upstream_beta = body[ATTR_BETA_CHANNEL]
|
||||
|
||||
return self.config.save()
|
||||
if ATTR_ADDONS_REPOSITORIES in body:
|
||||
new = set(body[ATTR_ADDONS_REPOSITORIES])
|
||||
old = set(self.config.addons_repositories)
|
||||
|
||||
# add new repositories
|
||||
tasks = [self.addons.add_git_repository(url) for url in
|
||||
set(new - old)]
|
||||
if tasks:
|
||||
await asyncio.shield(
|
||||
asyncio.wait(tasks, loop=self.loop), loop=self.loop)
|
||||
|
||||
# remove old repositories
|
||||
for url in set(old - new):
|
||||
self.addons.drop_git_repository(url)
|
||||
|
||||
# read repository
|
||||
self.addons.read_data_from_repositories()
|
||||
|
||||
return True
|
||||
|
||||
@api_process
|
||||
async def update(self, request):
|
||||
|
@@ -81,7 +81,7 @@ def api_return_error(message=None):
|
||||
return web.json_response({
|
||||
JSON_RESULT: RESULT_ERROR,
|
||||
JSON_MESSAGE: message,
|
||||
})
|
||||
}, status=400)
|
||||
|
||||
|
||||
def api_return_ok(data=None):
|
||||
|
@@ -1,7 +1,6 @@
|
||||
"""Bootstrap HassIO."""
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import signal
|
||||
|
||||
from colorlog import ColoredFormatter
|
||||
@@ -17,26 +16,37 @@ def initialize_system_data(websession):
|
||||
config = CoreConfig(websession)
|
||||
|
||||
# homeassistant config folder
|
||||
if not os.path.isdir(config.path_config):
|
||||
if not config.path_config.is_dir():
|
||||
_LOGGER.info(
|
||||
"Create Home-Assistant config folder %s", config.path_config)
|
||||
os.mkdir(config.path_config)
|
||||
config.path_config.mkdir()
|
||||
|
||||
# homeassistant ssl folder
|
||||
if not os.path.isdir(config.path_ssl):
|
||||
if not config.path_ssl.is_dir():
|
||||
_LOGGER.info("Create Home-Assistant ssl folder %s", config.path_ssl)
|
||||
os.mkdir(config.path_ssl)
|
||||
config.path_ssl.mkdir()
|
||||
|
||||
# homeassistant addon data folder
|
||||
if not os.path.isdir(config.path_addons_data):
|
||||
if not config.path_addons_data.is_dir():
|
||||
_LOGGER.info("Create Home-Assistant addon data folder %s",
|
||||
config.path_addons_data)
|
||||
os.mkdir(config.path_addons_data)
|
||||
config.path_addons_data.mkdir(parents=True)
|
||||
|
||||
if not os.path.isdir(config.path_addons_custom):
|
||||
_LOGGER.info("Create Home-Assistant addon custom folder %s",
|
||||
config.path_addons_custom)
|
||||
os.mkdir(config.path_addons_custom)
|
||||
if not config.path_addons_local.is_dir():
|
||||
_LOGGER.info("Create Home-Assistant addon local repository folder %s",
|
||||
config.path_addons_local)
|
||||
config.path_addons_local.mkdir(parents=True)
|
||||
|
||||
if not config.path_addons_git.is_dir():
|
||||
_LOGGER.info("Create Home-Assistant addon git repositories folder %s",
|
||||
config.path_addons_git)
|
||||
config.path_addons_git.mkdir(parents=True)
|
||||
|
||||
# homeassistant backup folder
|
||||
if not config.path_backup.is_dir():
|
||||
_LOGGER.info("Create Home-Assistant backup folder %s",
|
||||
config.path_backup)
|
||||
config.path_backup.mkdir()
|
||||
|
||||
return config
|
||||
|
||||
@@ -76,8 +86,7 @@ def check_environment():
|
||||
_LOGGER.fatal("Can't find %s in env!", key)
|
||||
return False
|
||||
|
||||
mode = os.stat(SOCKET_DOCKER)[stat.ST_MODE]
|
||||
if not stat.S_ISSOCK(mode):
|
||||
if not SOCKET_DOCKER.is_socket():
|
||||
_LOGGER.fatal("Can't find docker socket!")
|
||||
return False
|
||||
|
||||
|
140
hassio/config.py
140
hassio/config.py
@@ -1,49 +1,69 @@
|
||||
"""Bootstrap HassIO."""
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from .const import FILE_HASSIO_CONFIG, HASSIO_SHARE
|
||||
from .tools import (
|
||||
fetch_current_versions, write_json_file, read_json_file)
|
||||
fetch_last_versions, write_json_file, read_json_file)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HOMEASSISTANT_CONFIG = "{}/homeassistant"
|
||||
HOMEASSISTANT_IMAGE = 'homeassistant_image'
|
||||
HOMEASSISTANT_CONFIG = PurePath("homeassistant")
|
||||
HOMEASSISTANT_LAST = 'homeassistant_last'
|
||||
|
||||
HASSIO_SSL = "{}/ssl"
|
||||
HASSIO_SSL = PurePath("ssl")
|
||||
HASSIO_LAST = 'hassio_last'
|
||||
HASSIO_CLEANUP = 'hassio_cleanup'
|
||||
|
||||
ADDONS_REPO = "{}/addons"
|
||||
ADDONS_DATA = "{}/addons_data"
|
||||
ADDONS_CUSTOM = "{}/addons_custom"
|
||||
ADDONS_CORE = PurePath("addons/core")
|
||||
ADDONS_LOCAL = PurePath("addons/local")
|
||||
ADDONS_GIT = PurePath("addons/git")
|
||||
ADDONS_DATA = PurePath("addons/data")
|
||||
ADDONS_CUSTOM_LIST = 'addons_custom_list'
|
||||
|
||||
BACKUP_DATA = PurePath("backup")
|
||||
|
||||
UPSTREAM_BETA = 'upstream_beta'
|
||||
|
||||
API_ENDPOINT = 'api_endpoint'
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_CONFIG = vol.Schema({
|
||||
vol.Optional(UPSTREAM_BETA, default=False): vol.Boolean(),
|
||||
vol.Optional(API_ENDPOINT): vol.Coerce(str),
|
||||
vol.Optional(HOMEASSISTANT_LAST): vol.Coerce(str),
|
||||
vol.Optional(HASSIO_LAST): vol.Coerce(str),
|
||||
vol.Optional(HASSIO_CLEANUP): vol.Coerce(str),
|
||||
vol.Optional(ADDONS_CUSTOM_LIST, default=[]): [vol.Url()],
|
||||
}, extra=vol.REMOVE_EXTRA)
|
||||
|
||||
|
||||
class Config(object):
|
||||
"""Hold all config data."""
|
||||
|
||||
def __init__(self, config_file):
|
||||
"""Initialize config object."""
|
||||
self._filename = config_file
|
||||
self._file = config_file
|
||||
self._data = {}
|
||||
|
||||
# init or load data
|
||||
if os.path.isfile(self._filename):
|
||||
if self._file.is_file():
|
||||
try:
|
||||
self._data = read_json_file(self._filename)
|
||||
except OSError:
|
||||
_LOGGER.warning("Can't read %s", self._filename)
|
||||
self._data = read_json_file(self._file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
_LOGGER.warning("Can't read %s", self._file)
|
||||
self._data = {}
|
||||
|
||||
def save(self):
|
||||
"""Store data to config file."""
|
||||
if not write_json_file(self._filename, self._data):
|
||||
_LOGGER.exception("Can't store config in %s", self._filename)
|
||||
if not write_json_file(self._file, self._data):
|
||||
_LOGGER.error("Can't store config in %s", self._file)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -57,17 +77,17 @@ class CoreConfig(Config):
|
||||
|
||||
super().__init__(FILE_HASSIO_CONFIG)
|
||||
|
||||
# init data
|
||||
if not self._data:
|
||||
self._data.update({
|
||||
HOMEASSISTANT_IMAGE: os.environ['HOMEASSISTANT_REPOSITORY'],
|
||||
UPSTREAM_BETA: False,
|
||||
})
|
||||
# validate data
|
||||
try:
|
||||
self._data = SCHEMA_CONFIG(self._data)
|
||||
self.save()
|
||||
except vol.Invalid as ex:
|
||||
_LOGGER.warning(
|
||||
"Invalid config %s", humanize_error(self._data, ex))
|
||||
|
||||
async def fetch_update_infos(self):
|
||||
"""Read current versions from web."""
|
||||
last = await fetch_current_versions(
|
||||
last = await fetch_last_versions(
|
||||
self.websession, beta=self.upstream_beta)
|
||||
|
||||
if last:
|
||||
@@ -93,7 +113,7 @@ class CoreConfig(Config):
|
||||
@property
|
||||
def upstream_beta(self):
|
||||
"""Return True if we run in beta upstream."""
|
||||
return self._data.get(UPSTREAM_BETA, False)
|
||||
return self._data[UPSTREAM_BETA]
|
||||
|
||||
@upstream_beta.setter
|
||||
def upstream_beta(self, value):
|
||||
@@ -117,7 +137,7 @@ class CoreConfig(Config):
|
||||
@property
|
||||
def homeassistant_image(self):
|
||||
"""Return docker homeassistant repository."""
|
||||
return self._data.get(HOMEASSISTANT_IMAGE)
|
||||
return os.environ['HOMEASSISTANT_REPOSITORY']
|
||||
|
||||
@property
|
||||
def last_homeassistant(self):
|
||||
@@ -130,46 +150,88 @@ class CoreConfig(Config):
|
||||
return self._data.get(HASSIO_LAST)
|
||||
|
||||
@property
|
||||
def path_hassio_docker(self):
|
||||
def path_extern_hassio(self):
|
||||
"""Return hassio data path extern for docker."""
|
||||
return os.environ['SUPERVISOR_SHARE']
|
||||
return PurePath(os.environ['SUPERVISOR_SHARE'])
|
||||
|
||||
@property
|
||||
def path_config_docker(self):
|
||||
def path_extern_config(self):
|
||||
"""Return config path extern for docker."""
|
||||
return HOMEASSISTANT_CONFIG.format(self.path_hassio_docker)
|
||||
return str(PurePath(self.path_extern_hassio, HOMEASSISTANT_CONFIG))
|
||||
|
||||
@property
|
||||
def path_config(self):
|
||||
"""Return config path inside supervisor."""
|
||||
return HOMEASSISTANT_CONFIG.format(HASSIO_SHARE)
|
||||
return Path(HASSIO_SHARE, HOMEASSISTANT_CONFIG)
|
||||
|
||||
@property
|
||||
def path_ssl_docker(self):
|
||||
def path_extern_ssl(self):
|
||||
"""Return SSL path extern for docker."""
|
||||
return HASSIO_SSL.format(self.path_hassio_docker)
|
||||
return str(PurePath(self.path_extern_hassio, HASSIO_SSL))
|
||||
|
||||
@property
|
||||
def path_ssl(self):
|
||||
"""Return SSL path inside supervisor."""
|
||||
return HASSIO_SSL.format(HASSIO_SHARE)
|
||||
return Path(HASSIO_SHARE, HASSIO_SSL)
|
||||
|
||||
@property
|
||||
def path_addons_repo(self):
|
||||
"""Return git repo path for addons."""
|
||||
return ADDONS_REPO.format(HASSIO_SHARE)
|
||||
def path_addons_core(self):
|
||||
"""Return git path for core addons."""
|
||||
return Path(HASSIO_SHARE, ADDONS_CORE)
|
||||
|
||||
@property
|
||||
def path_addons_custom(self):
|
||||
def path_addons_git(self):
|
||||
"""Return path for git addons."""
|
||||
return Path(HASSIO_SHARE, ADDONS_GIT)
|
||||
|
||||
@property
|
||||
def path_addons_local(self):
|
||||
"""Return path for customs addons."""
|
||||
return ADDONS_CUSTOM.format(HASSIO_SHARE)
|
||||
return Path(HASSIO_SHARE, ADDONS_LOCAL)
|
||||
|
||||
@property
|
||||
def path_extern_addons_local(self):
|
||||
"""Return path for customs addons."""
|
||||
return str(PurePath(self.path_extern_hassio, ADDONS_LOCAL))
|
||||
|
||||
@property
|
||||
def path_addons_data(self):
|
||||
"""Return root addon data folder."""
|
||||
return ADDONS_DATA.format(HASSIO_SHARE)
|
||||
return Path(HASSIO_SHARE, ADDONS_DATA)
|
||||
|
||||
@property
|
||||
def path_addons_data_docker(self):
|
||||
def path_extern_addons_data(self):
|
||||
"""Return root addon data folder extern for docker."""
|
||||
return ADDONS_DATA.format(self.path_hassio_docker)
|
||||
return str(PurePath(self.path_extern_hassio, ADDONS_DATA))
|
||||
|
||||
@property
|
||||
def path_backup(self):
|
||||
"""Return root backup data folder."""
|
||||
return Path(HASSIO_SHARE, BACKUP_DATA)
|
||||
|
||||
@property
|
||||
def path_extern_backup(self):
|
||||
"""Return root backup data folder extern for docker."""
|
||||
return str(PurePath(self.path_extern_hassio, BACKUP_DATA))
|
||||
|
||||
@property
|
||||
def addons_repositories(self):
|
||||
"""Return list of addons custom repositories."""
|
||||
return self._data[ADDONS_CUSTOM_LIST]
|
||||
|
||||
@addons_repositories.setter
|
||||
def addons_repositories(self, repo):
|
||||
"""Add a custom repository to list."""
|
||||
if repo in self._data[ADDONS_CUSTOM_LIST]:
|
||||
return
|
||||
|
||||
self._data[ADDONS_CUSTOM_LIST].append(repo)
|
||||
self.save()
|
||||
|
||||
def drop_addon_repository(self, repo):
|
||||
"""Remove a custom repository from list."""
|
||||
if repo not in self._data[ADDONS_CUSTOM_LIST]:
|
||||
return
|
||||
|
||||
self._data[ADDONS_CUSTOM_LIST].remove(repo)
|
||||
self.save()
|
||||
|
@@ -1,28 +1,31 @@
|
||||
"""Const file for HassIO."""
|
||||
HASSIO_VERSION = '0.16'
|
||||
from pathlib import Path
|
||||
|
||||
HASSIO_VERSION = '0.23'
|
||||
|
||||
URL_HASSIO_VERSION = ('https://raw.githubusercontent.com/home-assistant/'
|
||||
'hassio/master/version.json')
|
||||
URL_HASSIO_VERSION_BETA = ('https://raw.githubusercontent.com/home-assistant/'
|
||||
'hassio/master/version_beta.json')
|
||||
'hassio/dev/version.json')
|
||||
|
||||
URL_HASSIO_ADDONS = 'https://github.com/home-assistant/hassio-addons'
|
||||
|
||||
DOCKER_REPO = "pvizeli"
|
||||
DOCKER_REPO = "homeassistant"
|
||||
|
||||
HASSIO_SHARE = "/data"
|
||||
HASSIO_SHARE = Path("/data")
|
||||
|
||||
RUN_UPDATE_INFO_TASKS = 28800
|
||||
RUN_UPDATE_SUPERVISOR_TASKS = 29100
|
||||
RUN_RELOAD_ADDONS_TASKS = 28800
|
||||
RUN_WATCHDOG_HOMEASSISTANT = 15
|
||||
|
||||
RESTART_EXIT_CODE = 100
|
||||
|
||||
FILE_HASSIO_ADDONS = "{}/addons.json".format(HASSIO_SHARE)
|
||||
FILE_HASSIO_CONFIG = "{}/config.json".format(HASSIO_SHARE)
|
||||
FILE_HASSIO_ADDONS = Path(HASSIO_SHARE, "addons.json")
|
||||
FILE_HASSIO_CONFIG = Path(HASSIO_SHARE, "config.json")
|
||||
|
||||
SOCKET_DOCKER = "/var/run/docker.sock"
|
||||
SOCKET_HC = "/var/run/hassio-hc.sock"
|
||||
SOCKET_DOCKER = Path("/var/run/docker.sock")
|
||||
SOCKET_HC = Path("/var/run/hassio-hc.sock")
|
||||
|
||||
JSON_RESULT = 'result'
|
||||
JSON_DATA = 'data'
|
||||
@@ -31,9 +34,11 @@ JSON_MESSAGE = 'message'
|
||||
RESULT_ERROR = 'error'
|
||||
RESULT_OK = 'ok'
|
||||
|
||||
ATTR_ARCH = 'arch'
|
||||
ATTR_HOSTNAME = 'hostname'
|
||||
ATTR_OS = 'os'
|
||||
ATTR_TYPE = 'type'
|
||||
ATTR_SOURCE = 'source'
|
||||
ATTR_FEATURES = 'features'
|
||||
ATTR_ADDONS = 'addons'
|
||||
ATTR_VERSION = 'version'
|
||||
@@ -45,14 +50,18 @@ ATTR_DESCRIPTON = 'description'
|
||||
ATTR_STARTUP = 'startup'
|
||||
ATTR_BOOT = 'boot'
|
||||
ATTR_PORTS = 'ports'
|
||||
ATTR_MAP_CONFIG = 'map_config'
|
||||
ATTR_MAP_SSL = 'map_ssl'
|
||||
ATTR_MAP = 'map'
|
||||
ATTR_OPTIONS = 'options'
|
||||
ATTR_INSTALLED = 'installed'
|
||||
ATTR_DEDICATED = 'dedicated'
|
||||
ATTR_DETACHED = 'detached'
|
||||
ATTR_STATE = 'state'
|
||||
ATTR_SCHEMA = 'schema'
|
||||
ATTR_IMAGE = 'image'
|
||||
ATTR_ADDONS_REPOSITORIES = 'addons_repositories'
|
||||
ATTR_REPOSITORY = 'repository'
|
||||
ATTR_REPOSITORIES = 'repositories'
|
||||
ATTR_URL = 'url'
|
||||
ATTR_MAINTAINER = 'maintainer'
|
||||
|
||||
STARTUP_BEFORE = 'before'
|
||||
STARTUP_AFTER = 'after'
|
||||
@@ -63,3 +72,13 @@ BOOT_MANUAL = 'manual'
|
||||
|
||||
STATE_STARTED = 'started'
|
||||
STATE_STOPPED = 'stopped'
|
||||
|
||||
MAP_CONFIG = 'config'
|
||||
MAP_SSL = 'ssl'
|
||||
MAP_ADDONS = 'addons'
|
||||
MAP_BACKUP = 'backup'
|
||||
|
||||
ARCH_ARMHF = 'armhf'
|
||||
ARCH_AARCH64 = 'aarch64'
|
||||
ARCH_AMD64 = 'amd64'
|
||||
ARCH_I386 = 'i386'
|
||||
|
@@ -11,10 +11,12 @@ from .api import RestAPI
|
||||
from .host_control import HostControl
|
||||
from .const import (
|
||||
SOCKET_DOCKER, RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS,
|
||||
RUN_UPDATE_SUPERVISOR_TASKS, STARTUP_AFTER, STARTUP_BEFORE)
|
||||
RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT, STARTUP_AFTER,
|
||||
STARTUP_BEFORE)
|
||||
from .scheduler import Scheduler
|
||||
from .dock.homeassistant import DockerHomeAssistant
|
||||
from .dock.supervisor import DockerSupervisor
|
||||
from .tasks import hassio_update, homeassistant_watchdog, homeassistant_setup
|
||||
from .tools import get_arch_from_image, get_local_ip
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -32,7 +34,7 @@ class HassIO(object):
|
||||
self.scheduler = Scheduler(self.loop)
|
||||
self.api = RestAPI(self.config, self.loop)
|
||||
self.dock = docker.DockerClient(
|
||||
base_url="unix:/{}".format(SOCKET_DOCKER), version='auto')
|
||||
base_url="unix:/{}".format(str(SOCKET_DOCKER)), version='auto')
|
||||
|
||||
# init basic docker container
|
||||
self.supervisor = DockerSupervisor(
|
||||
@@ -57,11 +59,10 @@ class HassIO(object):
|
||||
|
||||
# hostcontrol
|
||||
await self.host_control.load()
|
||||
_LOGGER.info(
|
||||
"Connected to HostControl. Type: %s Version: %s Hostname: %s "
|
||||
"Features: %s", self.host_control.type,
|
||||
self.host_control.version, self.host_control.hostname,
|
||||
self.host_control.features)
|
||||
|
||||
# schedule update info tasks
|
||||
self.scheduler.register_task(
|
||||
self.host_control.load, RUN_UPDATE_INFO_TASKS)
|
||||
|
||||
# rest api views
|
||||
self.api.register_host(self.host_control)
|
||||
@@ -79,7 +80,8 @@ class HassIO(object):
|
||||
# first start of supervisor?
|
||||
if not await self.homeassistant.exists():
|
||||
_LOGGER.info("No HomeAssistant docker found.")
|
||||
await self._setup_homeassistant()
|
||||
await homeassistant_setup(
|
||||
self.config, self.loop, self.homeassistant)
|
||||
|
||||
# Load addons
|
||||
arch = get_arch_from_image(self.supervisor.image)
|
||||
@@ -91,7 +93,8 @@ class HassIO(object):
|
||||
|
||||
# schedule self update task
|
||||
self.scheduler.register_task(
|
||||
self._hassio_update, RUN_UPDATE_SUPERVISOR_TASKS)
|
||||
hassio_update(self.config, self.supervisor),
|
||||
RUN_UPDATE_SUPERVISOR_TASKS)
|
||||
|
||||
async def start(self):
|
||||
"""Start HassIO orchestration."""
|
||||
@@ -110,6 +113,11 @@ class HassIO(object):
|
||||
# run HomeAssistant
|
||||
await self.homeassistant.run()
|
||||
|
||||
# schedule homeassistant watchdog
|
||||
self.scheduler.register_task(
|
||||
homeassistant_watchdog(self.loop, self.homeassistant),
|
||||
RUN_WATCHDOG_HOMEASSISTANT)
|
||||
|
||||
# start addon mark as after
|
||||
await self.addons.auto_boot(STARTUP_AFTER)
|
||||
|
||||
@@ -124,28 +132,3 @@ class HassIO(object):
|
||||
|
||||
self.exit_code = exit_code
|
||||
self.loop.stop()
|
||||
|
||||
async def _setup_homeassistant(self):
|
||||
"""Install a homeassistant docker container."""
|
||||
while True:
|
||||
# read homeassistant tag and install it
|
||||
if not self.config.last_homeassistant:
|
||||
await self.config.fetch_update_infos()
|
||||
|
||||
tag = self.config.last_homeassistant
|
||||
if tag and await self.homeassistant.install(tag):
|
||||
break
|
||||
_LOGGER.warning("Error on setup HomeAssistant. Retry in 60.")
|
||||
await asyncio.sleep(60, loop=self.loop)
|
||||
|
||||
# store version
|
||||
_LOGGER.info("HomeAssistant docker now installed.")
|
||||
|
||||
async def _hassio_update(self):
|
||||
"""Check and run update of supervisor hassio."""
|
||||
if self.config.last_hassio == self.supervisor.version:
|
||||
return
|
||||
|
||||
_LOGGER.info(
|
||||
"Found new HassIO version %s.", self.config.last_hassio)
|
||||
await self.supervisor.update(self.config.last_hassio)
|
||||
|
@@ -203,6 +203,8 @@ class DockerBase(object):
|
||||
image="{}:latest".format(self.image), force=True)
|
||||
self.dock.images.remove(
|
||||
image="{}:{}".format(self.image, self.version), force=True)
|
||||
except docker.errors.ImageNotFound:
|
||||
return True
|
||||
except docker.errors.DockerException as err:
|
||||
_LOGGER.warning("Can't remove image %s -> %s", self.image, err)
|
||||
return False
|
||||
@@ -262,3 +264,30 @@ class DockerBase(object):
|
||||
return self.container.logs(tail=100, stdout=True, stderr=True)
|
||||
except docker.errors.DockerException as err:
|
||||
_LOGGER.warning("Can't grap logs from %s -> %s", self.image, err)
|
||||
|
||||
async def restart(self):
|
||||
"""Restart docker container."""
|
||||
if self._lock.locked():
|
||||
_LOGGER.error("Can't excute restart while a task is in progress")
|
||||
return False
|
||||
|
||||
async with self._lock:
|
||||
return await self.loop.run_in_executor(None, self._restart)
|
||||
|
||||
def _restart(self):
|
||||
"""Restart docker container.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
if not self.container:
|
||||
return False
|
||||
|
||||
_LOGGER.info("Restart %s", self.image)
|
||||
|
||||
try:
|
||||
self.container.restart(timeout=30)
|
||||
except docker.errors.DockerException as err:
|
||||
_LOGGER.warning("Can't restart %s -> %s", self.image, err)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
@@ -26,6 +26,40 @@ class DockerAddon(DockerBase):
|
||||
"""Return name of docker container."""
|
||||
return "addon_{}".format(self.addon)
|
||||
|
||||
@property
|
||||
def volumes(self):
|
||||
"""Generate volumes for mappings."""
|
||||
volumes = {
|
||||
self.addons_data.path_extern_data(self.addon): {
|
||||
'bind': '/data', 'mode': 'rw'
|
||||
}}
|
||||
|
||||
if self.addons_data.map_config(self.addon):
|
||||
volumes.update({
|
||||
self.config.path_extern_config: {
|
||||
'bind': '/config', 'mode': 'rw'
|
||||
}})
|
||||
|
||||
if self.addons_data.map_ssl(self.addon):
|
||||
volumes.update({
|
||||
self.config.path_extern_ssl: {
|
||||
'bind': '/ssl', 'mode': 'rw'
|
||||
}})
|
||||
|
||||
if self.addons_data.map_addons(self.addon):
|
||||
volumes.update({
|
||||
self.config.path_extern_addons_local: {
|
||||
'bind': '/addons', 'mode': 'rw'
|
||||
}})
|
||||
|
||||
if self.addons_data.map_backup(self.addon):
|
||||
volumes.update({
|
||||
self.config.path_extern_backup: {
|
||||
'bind': '/backup', 'mode': 'rw'
|
||||
}})
|
||||
|
||||
return volumes
|
||||
|
||||
def _run(self):
|
||||
"""Run docker image.
|
||||
|
||||
@@ -37,22 +71,6 @@ class DockerAddon(DockerBase):
|
||||
# cleanup old container
|
||||
self._stop()
|
||||
|
||||
# volumes
|
||||
volumes = {
|
||||
self.addons_data.path_data_docker(self.addon): {
|
||||
'bind': '/data', 'mode': 'rw'
|
||||
}}
|
||||
if self.addons_data.need_config(self.addon):
|
||||
volumes.update({
|
||||
self.config.path_config_docker: {
|
||||
'bind': '/config', 'mode': 'rw'
|
||||
}})
|
||||
if self.addons_data.need_ssl(self.addon):
|
||||
volumes.update({
|
||||
self.config.path_ssl_docker: {
|
||||
'bind': '/ssl', 'mode': 'rw'
|
||||
}})
|
||||
|
||||
try:
|
||||
self.container = self.dock.containers.run(
|
||||
self.image,
|
||||
@@ -60,7 +78,7 @@ class DockerAddon(DockerBase):
|
||||
detach=True,
|
||||
network_mode='bridge',
|
||||
ports=self.addons_data.get_ports(self.addon),
|
||||
volumes=volumes,
|
||||
volumes=self.volumes,
|
||||
)
|
||||
|
||||
self.version = get_version_from_env(
|
||||
|
@@ -45,9 +45,9 @@ class DockerHomeAssistant(DockerBase):
|
||||
'HASSIO': self.config.api_endpoint,
|
||||
},
|
||||
volumes={
|
||||
self.config.path_config_docker:
|
||||
self.config.path_extern_config:
|
||||
{'bind': '/config', 'mode': 'rw'},
|
||||
self.config.path_ssl_docker:
|
||||
self.config.path_extern_ssl:
|
||||
{'bind': '/ssl', 'mode': 'rw'},
|
||||
})
|
||||
|
||||
|
@@ -81,3 +81,7 @@ class DockerSupervisor(DockerBase):
|
||||
async def remove(self):
|
||||
"""Remove docker image."""
|
||||
raise RuntimeError("Not support on supervisor docker container!")
|
||||
|
||||
async def restart(self):
|
||||
"""Restart docker container."""
|
||||
raise RuntimeError("Not support on supervisor docker container!")
|
||||
|
@@ -2,8 +2,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
|
||||
import async_timeout
|
||||
|
||||
@@ -31,14 +29,13 @@ class HostControl(object):
|
||||
self.loop = loop
|
||||
self.active = False
|
||||
self.version = UNKNOWN
|
||||
self.last = UNKNOWN
|
||||
self.last_version = UNKNOWN
|
||||
self.type = UNKNOWN
|
||||
self.features = []
|
||||
self.hostname = UNKNOWN
|
||||
self.os_info = UNKNOWN
|
||||
|
||||
mode = os.stat(SOCKET_HC)[stat.ST_MODE]
|
||||
if stat.S_ISSOCK(mode):
|
||||
if SOCKET_HC.is_socket():
|
||||
self.active = True
|
||||
|
||||
async def _send_command(self, command):
|
||||
@@ -50,7 +47,7 @@ class HostControl(object):
|
||||
return
|
||||
|
||||
reader, writer = await asyncio.open_unix_connection(
|
||||
SOCKET_HC, loop=self.loop)
|
||||
str(SOCKET_HC), loop=self.loop)
|
||||
|
||||
try:
|
||||
# send
|
||||
@@ -60,8 +57,8 @@ class HostControl(object):
|
||||
writer.write("{}\n".format(command).encode())
|
||||
data = await reader.readline()
|
||||
|
||||
response = data.decode()
|
||||
_LOGGER.debug("Receive from HostControl: %s.", response)
|
||||
response = data.decode().rstrip()
|
||||
_LOGGER.info("Receive from HostControl: %s.", response)
|
||||
|
||||
if response == "OK":
|
||||
return True
|
||||
@@ -73,7 +70,8 @@ class HostControl(object):
|
||||
try:
|
||||
return json.loads(response)
|
||||
except json.JSONDecodeError:
|
||||
_LOGGER.warning("Json parse error from HostControl.")
|
||||
_LOGGER.warning("Json parse error from HostControl '%s'.",
|
||||
response)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("Timeout from HostControl!")
|
||||
@@ -91,7 +89,7 @@ class HostControl(object):
|
||||
return
|
||||
|
||||
self.version = info.get(ATTR_VERSION, UNKNOWN)
|
||||
self.last = info.get(ATTR_LAST_VERSION, UNKNOWN)
|
||||
self.last_version = info.get(ATTR_LAST_VERSION, UNKNOWN)
|
||||
self.type = info.get(ATTR_TYPE, UNKNOWN)
|
||||
self.features = info.get(ATTR_FEATURES, [])
|
||||
self.hostname = info.get(ATTR_HOSTNAME, UNKNOWN)
|
||||
|
47
hassio/tasks.py
Normal file
47
hassio/tasks.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Multible tasks."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def hassio_update(config, supervisor):
|
||||
"""Create scheduler task for update of supervisor hassio."""
|
||||
async def _hassio_update():
|
||||
"""Check and run update of supervisor hassio."""
|
||||
if config.last_hassio == supervisor.version:
|
||||
return
|
||||
|
||||
_LOGGER.info("Found new HassIO version %s.", config.last_hassio)
|
||||
await supervisor.update(config.last_hassio)
|
||||
|
||||
return _hassio_update
|
||||
|
||||
|
||||
def homeassistant_watchdog(loop, homeassistant):
|
||||
"""Create scheduler task for montoring running state."""
|
||||
async def _homeassistant_watchdog():
|
||||
"""Check running state and start if they is close."""
|
||||
if homeassistant.in_progress or await homeassistant.is_running():
|
||||
return
|
||||
|
||||
loop.create_task(homeassistant.run())
|
||||
|
||||
return _homeassistant_watchdog
|
||||
|
||||
|
||||
async def homeassistant_setup(config, loop, homeassistant):
|
||||
"""Install a homeassistant docker container."""
|
||||
while True:
|
||||
# read homeassistant tag and install it
|
||||
if not config.last_homeassistant:
|
||||
await config.fetch_update_infos()
|
||||
|
||||
tag = config.last_homeassistant
|
||||
if tag and await homeassistant.install(tag):
|
||||
break
|
||||
_LOGGER.warning("Error on setup HomeAssistant. Retry in 60.")
|
||||
await asyncio.sleep(60, loop=loop)
|
||||
|
||||
# store version
|
||||
_LOGGER.info("HomeAssistant docker now installed.")
|
@@ -16,7 +16,7 @@ _RE_VERSION = re.compile(r"VERSION=(.*)")
|
||||
_IMAGE_ARCH = re.compile(r".*/([a-z0-9]*)-hassio-supervisor")
|
||||
|
||||
|
||||
async def fetch_current_versions(websession, beta=False):
|
||||
async def fetch_last_versions(websession, beta=False):
|
||||
"""Fetch current versions from github.
|
||||
|
||||
Is a coroutine.
|
||||
@@ -77,9 +77,10 @@ def get_local_ip(loop):
|
||||
def write_json_file(jsonfile, data):
|
||||
"""Write a json file."""
|
||||
try:
|
||||
with open(jsonfile, 'w') as conf_file:
|
||||
conf_file.write(json.dumps(data))
|
||||
except OSError:
|
||||
json_str = json.dumps(data, indent=2)
|
||||
with jsonfile.open('w') as conf_file:
|
||||
conf_file.write(json_str)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -87,5 +88,5 @@ def write_json_file(jsonfile, data):
|
||||
|
||||
def read_json_file(jsonfile):
|
||||
"""Read a json file and return a dict."""
|
||||
with open(jsonfile, 'r') as cfile:
|
||||
with jsonfile.open('r') as cfile:
|
||||
return json.loads(cfile.read())
|
||||
|
BIN
misc/hassio.png
Normal file
BIN
misc/hassio.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
1
misc/hassio.xml
Normal file
1
misc/hassio.xml
Normal file
@@ -0,0 +1 @@
|
||||
<mxfile userAgent="Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.81 Safari/537.36" version="6.5.6" editor="www.draw.io" type="device"><diagram name="Page-1">5Vptc6M2EP41/ng3gHj9mPiSy820c5n6Q3sfsVBsNTJyhYid/voKkABZkOBY+KYtmYnR6pVn99ld1l6A5e74laX77a80Q2ThOdlxAb4sPC8OY/G/Erw2At9xG8GG4awR9QQr/DeSQkdKS5yhQhvIKSUc73UhpHmOINdkKWP0oA97okTfdZ9ukCFYwZSY0t9xxrdS6oZJ1/GA8GYrt469sOlYp/B5w2iZy/0WHniqr6Z7l6q15IMW2zSjh54I3C3AklHKm7vdcYlIBa2CrZl3P9LbnpuhnE+Z4DUTXlJSInXikIipt09UrCAOyF8lKOFfJVUdn4paZTdigNjtKD5ERw206DtIYKrenLJdSrrJ4m5TfX5fqX3E2Zqtmg4JS7urd9hijlb7FFbtg7A2MWjLd0S03Oo0mJAlJZTVowXYKIRQyAvO6DPq9Tj1Jc+/kutLvF4Q4+g4CqHbKkbYO6I7xNmrGKImJKCZIm09SKRuD53l+Arobc9oQjkulca6aZfuFCZupM6G9QcM/X3LcaW31WvB0e5CNGGG1vF6CE0QggRkrb7sAhhNBNCzAKBvAPiFwmfELkUOokCQ/trI+SZy3hBywAJyoYHcw9JArXaFqJpRUe9MLscQDXN5HQd+4NjB0A8DHcPQxDBwTAgDCxAmBl4oE3FINinjW7qheUruOumtjmgPPXTE/I9K/DkKZPOH6srFwZq+QDV/yBX+RJy/ygiclpwKUbfxL5Tu5RrNUavzvQ20eBxaMihHRTJ4p2yDeM9uTHUwRFKOX/TVLwFX5RK20fXeQDcB3im+deMRMSweALGfBbp/JdCj0Xxi3UX48xIMN6wSjNMEYlXuEXvBhXAJagOm+h7Sovj2fTTBaMXr0aSjMwP3fbdluKflMgybVEN3aFmA4sy347ZAoLstMJB1uPGA33JtRE3Xm4Nbbo9Yyou13NJ4VbuxeUnkqveOHouiK7EIzOO6NHh1dE/iQtc89VyFwIPfVK9YQgCJYBqGSnyPidpzqm5QnpmLCWFvqcFMfrm0qlgvvlZQUm8cvaxJrPLpRjy6wLByU9dxRSmKn6CtLFR3Rd5A/t56HS1/9224ovDKXHE/O3qQ/+zG8aWBfiKtPmjxwLR4d0Sn1i3enyVUSJ30srCJCPYcTk5zpHmb8xQ2Vl+AJXtp+WpPYdeKPa5ZUrjJMpoXhhqLbbqvbveMQlQU73sn3ZVN9lX34qr9fZMTCt07XhiBxANhEHtx7PhgpqRqyJN5bmB6ssSCI1O1nDmJ0rVOHdWlqYAkU59uc7zoXEAAOfWR4vq9Q5WqneE0Wq3Q0FJO6hdSz1ynobKxTm0U7dNMs5PYJCjk1KxYKX6WO9IMALcVOzAUyKdrRB5pgTmmuRiyppzTnRhAqo7btoitVVbrMna3xg3Bm2oup+fRvCvEnpZu5QYWiHxS0wEDNR0wkJBYqciaNJ5AUifSWOq/x1LX5OgUOk5Ity8PgO97LQshEng/L0SqvXsMPBwOpvcmBO+LWg2SiZDQMrs4Tl6FQInuz3xnIKeP5iovgLcLo9K4P5DEn8mRmTLEXqzt3hyaQ3qj0faDNPFNmjTmaz+S+icmc+pN7YVAMP6tjfNQrkcjIUzZ5fQL62uAfkH1Z4d+CThJJ4boN1TdsxLBopnY17f7yGaWOT9lP8i+YAb2TVZjYJDkK+bbuekxFp2QmwUomocevnppvQo94v9LcEpCnaOR5dgU/idjk/m9+G9oX71qUYbReBXl30s+Vf6dgXyi2f0WqlFG93szcPcP</diagram></mxfile>
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"hassio": "0.15",
|
||||
"homeassistant": "0.43.1",
|
||||
"resinos": "0.4",
|
||||
"hassio": "0.23",
|
||||
"homeassistant": "0.44.2",
|
||||
"resinos": "0.7",
|
||||
"resinhup": "0.1",
|
||||
"generic": "0.2"
|
||||
"generic": "0.3"
|
||||
}
|
||||
|
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"hassio": "0.15",
|
||||
"homeassistant": "0.43.1",
|
||||
"resinos": "0.4",
|
||||
"resinhup": "0.1",
|
||||
"generic": "0.2"
|
||||
}
|
Reference in New Issue
Block a user