Allow stop/start home-assistant & flow of startup (#182)

* Allow config boot

* Read boot settings

* Use internal boot time for detect reboot

* Check if Home-Assistant need to watch

* Make datetime string and parse_datetime

* Add api calls

* fix lint p1

* Use new datetime parser for sessions and make a real default boot time

* fix lint p2

* only start docker if they is running

* convert to int (timestamp)

* add boot flag
This commit is contained in:
Pascal Vizeli 2017-09-11 14:14:26 +02:00 committed by GitHub
parent a733886803
commit 5a80be9fd4
12 changed files with 171 additions and 14 deletions

5
API.md
View File

@ -283,7 +283,8 @@ Optional:
"last_version": "LAST_VERSION", "last_version": "LAST_VERSION",
"devices": [""], "devices": [""],
"image": "str", "image": "str",
"custom": "bool -> if custom image" "custom": "bool -> if custom image",
"boot": "bool"
} }
``` ```
@ -304,6 +305,8 @@ Output is the raw Docker log.
- POST `/homeassistant/restart` - POST `/homeassistant/restart`
- POST `/homeassistant/options` - POST `/homeassistant/options`
- POST `/homeassistant/check` - POST `/homeassistant/check`
- POST `/homeassistant/start`
- POST `/homeassistant/stop`
```json ```json
{ {

View File

@ -72,6 +72,8 @@ class RestAPI(object):
self.webapp.router.add_post('/homeassistant/options', api_hass.options) self.webapp.router.add_post('/homeassistant/options', api_hass.options)
self.webapp.router.add_post('/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_post('/homeassistant/restart', api_hass.restart)
self.webapp.router.add_post('/homeassistant/stop', api_hass.stop)
self.webapp.router.add_post('/homeassistant/start', api_hass.start)
self.webapp.router.add_post('/homeassistant/check', api_hass.check) self.webapp.router.add_post('/homeassistant/check', api_hass.check)
def register_addons(self, addons): def register_addons(self, addons):

View File

@ -7,14 +7,16 @@ import voluptuous as vol
from .util import api_process, api_process_raw, api_validate from .util import api_process, api_process_raw, api_validate
from ..const import ( from ..const import (
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM, ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM,
CONTENT_TYPE_BINARY) ATTR_BOOT, CONTENT_TYPE_BINARY)
from ..validate import HASS_DEVICES from ..validate import HASS_DEVICES
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema({ SCHEMA_OPTIONS = vol.Schema({
vol.Optional(ATTR_DEVICES): HASS_DEVICES, vol.Optional(ATTR_DEVICES): HASS_DEVICES,
vol.Optional(ATTR_BOOT): vol.Boolean(),
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Any(None, vol.Coerce(str)), vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Any(None, vol.Coerce(str)),
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'):
vol.Any(None, vol.Coerce(str)), vol.Any(None, vol.Coerce(str)),
@ -43,6 +45,7 @@ class APIHomeAssistant(object):
ATTR_IMAGE: self.homeassistant.image, ATTR_IMAGE: self.homeassistant.image,
ATTR_DEVICES: self.homeassistant.devices, ATTR_DEVICES: self.homeassistant.devices,
ATTR_CUSTOM: self.homeassistant.is_custom_image, ATTR_CUSTOM: self.homeassistant.is_custom_image,
ATTR_BOOT: self.homeassistant.boot,
} }
@api_process @api_process
@ -57,6 +60,9 @@ class APIHomeAssistant(object):
self.homeassistant.set_custom( self.homeassistant.set_custom(
body[ATTR_IMAGE], body[ATTR_LAST_VERSION]) body[ATTR_IMAGE], body[ATTR_LAST_VERSION])
if ATTR_BOOT in body:
self.homeassistant.boot = body[ATTR_BOOT]
return True return True
@api_process @api_process
@ -71,6 +77,16 @@ class APIHomeAssistant(object):
return await asyncio.shield( return await asyncio.shield(
self.homeassistant.update(version), loop=self.loop) self.homeassistant.update(version), loop=self.loop)
@api_process
def stop(self, request):
"""Stop homeassistant."""
return asyncio.shield(self.homeassistant.stop(), loop=self.loop)
@api_process
def start(self, request):
"""Start homeassistant."""
return asyncio.shield(self.homeassistant.run(), loop=self.loop)
@api_process @api_process
def restart(self, request): def restart(self, request):
"""Restart homeassistant.""" """Restart homeassistant."""

View File

@ -7,14 +7,12 @@ from pathlib import Path, PurePath
from .const import ( from .const import (
FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_SECURITY, ATTR_SESSIONS, FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_SECURITY, ATTR_SESSIONS,
ATTR_PASSWORD, ATTR_TOTP, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_TOTP, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST,
ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT) ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_LAST_BOOT)
from .tools import JsonConfig from .tools import JsonConfig, parse_datetime
from .validate import SCHEMA_HASSIO_CONFIG from .validate import SCHEMA_HASSIO_CONFIG
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DATETIME_FORMAT = "%Y%m%d %H:%M:%S"
HOMEASSISTANT_CONFIG = PurePath("homeassistant") HOMEASSISTANT_CONFIG = PurePath("homeassistant")
HASSIO_SSL = PurePath("ssl") HASSIO_SSL = PurePath("ssl")
@ -28,6 +26,8 @@ BACKUP_DATA = PurePath("backup")
SHARE_DATA = PurePath("share") SHARE_DATA = PurePath("share")
TMP_DATA = PurePath("tmp") TMP_DATA = PurePath("tmp")
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
class CoreConfig(JsonConfig): class CoreConfig(JsonConfig):
"""Hold all core config data.""" """Hold all core config data."""
@ -48,6 +48,22 @@ class CoreConfig(JsonConfig):
self._data[ATTR_TIMEZONE] = value self._data[ATTR_TIMEZONE] = value
self.save() self.save()
@property
def last_boot(self):
"""Return last boot datetime."""
boot_str = self._data.get(ATTR_LAST_BOOT, DEFAULT_BOOT_TIME)
boot_time = parse_datetime(boot_str)
if not boot_time:
return datetime.utcfromtimestamp(1)
return boot_time
@last_boot.setter
def last_boot(self, value):
"""Set last boot datetime."""
self._data[ATTR_LAST_BOOT] = value.isoformat()
self.save()
@property @property
def path_hassio(self): def path_hassio(self):
"""Return hassio data path.""" """Return hassio data path."""
@ -191,14 +207,14 @@ class CoreConfig(JsonConfig):
def security_sessions(self): def security_sessions(self):
"""Return api sessions.""" """Return api sessions."""
return { return {
session: datetime.strptime(until, DATETIME_FORMAT) for session: parse_datetime(until) for
session, until in self._data[ATTR_SESSIONS].items() session, until in self._data[ATTR_SESSIONS].items()
} }
def add_security_session(self, session, valid): def add_security_session(self, session, valid):
"""Set the a new session.""" """Set the a new session."""
self._data[ATTR_SESSIONS].update( self._data[ATTR_SESSIONS].update(
{session: valid.strftime(DATETIME_FORMAT)} {session: valid.isoformat()}
) )
self.save() self.save()

View File

@ -61,6 +61,7 @@ ATTR_SOURCE = 'source'
ATTR_FEATURES = 'features' ATTR_FEATURES = 'features'
ATTR_ADDONS = 'addons' ATTR_ADDONS = 'addons'
ATTR_VERSION = 'version' ATTR_VERSION = 'version'
ATTR_LAST_BOOT = 'last_boot'
ATTR_LAST_VERSION = 'last_version' ATTR_LAST_VERSION = 'last_version'
ATTR_BETA_CHANNEL = 'beta_channel' ATTR_BETA_CHANNEL = 'beta_channel'
ATTR_NAME = 'name' ATTR_NAME = 'name'

View File

@ -142,7 +142,7 @@ class HassIO(object):
try: try:
# HomeAssistant is already running / supervisor have only reboot # HomeAssistant is already running / supervisor have only reboot
if await self.homeassistant.is_running(): if self.hardware.last_boot == self.config.last_boot:
_LOGGER.info("HassIO reboot detected") _LOGGER.info("HassIO reboot detected")
return return
@ -153,11 +153,15 @@ class HassIO(object):
await self.addons.auto_boot(STARTUP_SERVICES) await self.addons.auto_boot(STARTUP_SERVICES)
# run HomeAssistant # run HomeAssistant
await self.homeassistant.run() if self.homeassistant.boot:
await self.homeassistant.run()
# start addon mark as application # start addon mark as application
await self.addons.auto_boot(STARTUP_APPLICATION) await self.addons.auto_boot(STARTUP_APPLICATION)
# store new last boot
self.config.last_boot = self.hardware.last_boot
finally: finally:
# schedule homeassistant watchdog # schedule homeassistant watchdog
self.scheduler.register_task( self.scheduler.register_task(

View File

@ -1,6 +1,8 @@
"""Init file for HassIO docker object.""" """Init file for HassIO docker object."""
import logging import logging
import docker
from .interface import DockerInterface from .interface import DockerInterface
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -93,3 +95,19 @@ class DockerHomeAssistant(DockerInterface):
{'bind': '/ssl', 'mode': 'ro'}, {'bind': '/ssl', 'mode': 'ro'},
} }
) )
def is_initialize(self):
"""Return True if docker container exists."""
return self.loop.run_in_executor(None, self._is_initialize)
def _is_initialize(self):
"""Return True if docker container exists.
Need run inside executor.
"""
try:
self.docker.containers.get(self.name)
except docker.errors.DockerException:
return False
return True

View File

@ -1,4 +1,5 @@
"""Read hardware info from system.""" """Read hardware info from system."""
from datetime import datetime
import logging import logging
from pathlib import Path from pathlib import Path
import re import re
@ -15,6 +16,9 @@ RE_CARDS = re.compile(r"(\d+) \[(\w*) *\]: (.*\w)")
ASOUND_DEVICES = Path("/proc/asound/devices") ASOUND_DEVICES = Path("/proc/asound/devices")
RE_DEVICES = re.compile(r"\[.*(\d+)- (\d+).*\]: ([\w ]*)") RE_DEVICES = re.compile(r"\[.*(\d+)- (\d+).*\]: ([\w ]*)")
PROC_STAT = Path("/proc/stat")
RE_BOOT_TIME = re.compile(r"btime (\d+)")
class Hardware(object): class Hardware(object):
"""Represent a interface to procfs, sysfs and udev.""" """Represent a interface to procfs, sysfs and udev."""
@ -85,3 +89,21 @@ class Hardware(object):
continue continue
return audio_list return audio_list
@property
def last_boot(self):
"""Return last boot time."""
try:
with PROC_STAT.open("r") as stat_file:
stats = stat_file.read()
except OSError as err:
_LOGGER.error("Can't read stat data -> %s", err)
return
# parse stat file
found = RE_BOOT_TIME.search(stats)
if not found:
_LOGGER.error("Can't found last boot time!")
return
return datetime.utcfromtimestamp(int(found.group(1)))

View File

@ -6,7 +6,7 @@ import re
from .const import ( from .const import (
FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION,
ATTR_VERSION) ATTR_VERSION, ATTR_BOOT)
from .dock.homeassistant import DockerHomeAssistant from .dock.homeassistant import DockerHomeAssistant
from .tools import JsonConfig, convert_to_ascii from .tools import JsonConfig, convert_to_ascii
from .validate import SCHEMA_HASS_CONFIG from .validate import SCHEMA_HASS_CONFIG
@ -73,6 +73,17 @@ class HomeAssistant(JsonConfig):
self._data[ATTR_DEVICES] = value self._data[ATTR_DEVICES] = value
self.save() self.save()
@property
def boot(self):
"""Return True if home-assistant boot is enabled."""
return self._data[ATTR_BOOT]
@boot.setter
def boot(self, value):
"""Set home-assistant boot options."""
self._data[ATTR_BOOT] = value
self.save()
def set_custom(self, image, version): def set_custom(self, image, version):
"""Set a custom image for homeassistant.""" """Set a custom image for homeassistant."""
# reset # reset
@ -119,6 +130,7 @@ class HomeAssistant(JsonConfig):
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
running = await self.docker.is_running()
if version == self.docker.version: if version == self.docker.version:
_LOGGER.warning("Version %s is already installed", version) _LOGGER.warning("Version %s is already installed", version)
@ -127,7 +139,8 @@ class HomeAssistant(JsonConfig):
try: try:
return await self.docker.update(version) return await self.docker.update(version)
finally: finally:
await self.docker.run() if running:
await self.docker.run()
def run(self): def run(self):
"""Run HomeAssistant docker. """Run HomeAssistant docker.
@ -164,6 +177,13 @@ class HomeAssistant(JsonConfig):
""" """
return self.docker.is_running() return self.docker.is_running()
def is_initialize(self):
"""Return True if a docker container is exists.
Return a coroutine.
"""
return self.docker.is_initialize()
@property @property
def in_progress(self): def in_progress(self):
"""Return True if a task is in progress.""" """Return True if a task is in progress."""

View File

@ -66,6 +66,11 @@ def homeassistant_watchdog(loop, homeassistant):
"""Create scheduler task for montoring running state.""" """Create scheduler task for montoring running state."""
async def _homeassistant_watchdog(): async def _homeassistant_watchdog():
"""Check running state and start if they is close.""" """Check running state and start if they is close."""
# if Home-Assistant is active
if not await homeassistant.is_initialize():
return
# If Home-Assistant is running
if homeassistant.in_progress or await homeassistant.is_running(): if homeassistant.in_progress or await homeassistant.is_running():
return return

View File

@ -1,13 +1,14 @@
"""Tools file for HassIO.""" """Tools file for HassIO."""
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime, timedelta, timezone
import json import json
import logging import logging
import re import re
import aiohttp import aiohttp
import async_timeout import async_timeout
import pytz
import voluptuous as vol import voluptuous as vol
from voluptuous.humanize import humanize_error from voluptuous.humanize import humanize_error
@ -17,6 +18,16 @@ FREEGEOIP_URL = "https://freegeoip.io/json/"
RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))")
# Copyright (c) Django Software Foundation and individual contributors.
# All rights reserved.
# https://github.com/django/django/blob/master/LICENSE
DATETIME_RE = re.compile(
r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})'
r'[T ](?P<hour>\d{1,2}):(?P<minute>\d{1,2})'
r'(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?'
r'(?P<tzinfo>Z|[+-]\d{2}(?::?\d{2})?)?$'
)
def write_json_file(jsonfile, data): def write_json_file(jsonfile, data):
"""Write a json file.""" """Write a json file."""
@ -53,6 +64,42 @@ def convert_to_ascii(raw):
return RE_STRING.sub("", raw.decode()) return RE_STRING.sub("", raw.decode())
# Copyright (c) Django Software Foundation and individual contributors.
# All rights reserved.
# https://github.com/django/django/blob/master/LICENSE
def parse_datetime(dt_str):
"""Parse a string and return a datetime.datetime.
This function supports time zone offsets. When the input contains one,
the output uses a timezone with a fixed offset from UTC.
Raises ValueError if the input is well formatted but not a valid datetime.
Returns None if the input isn't well formatted.
"""
match = DATETIME_RE.match(dt_str)
if not match:
return None
kws = match.groupdict() # type: Dict[str, Any]
if kws['microsecond']:
kws['microsecond'] = kws['microsecond'].ljust(6, '0')
tzinfo_str = kws.pop('tzinfo')
tzinfo = None # type: Optional[dt.tzinfo]
if tzinfo_str == 'Z':
tzinfo = pytz.utc
elif tzinfo_str is not None:
offset_mins = int(tzinfo_str[-2:]) if len(tzinfo_str) > 3 else 0
offset_hours = int(tzinfo_str[1:3])
offset = timedelta(hours=offset_hours, minutes=offset_mins)
if tzinfo_str[0] == '-':
offset = -offset
tzinfo = timezone(offset)
else:
tzinfo = None
kws = {k: int(v) for k, v in kws.items() if v is not None}
kws['tzinfo'] = tzinfo
return datetime(**kws)
class JsonConfig(object): class JsonConfig(object):
"""Hass core object for handle it.""" """Hass core object for handle it."""

View File

@ -7,7 +7,7 @@ from .const import (
ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD,
ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE, ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE,
ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT, ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT,
ATTR_HOMEASSISTANT, ATTR_HASSIO) ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT)
NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
@ -55,8 +55,10 @@ DOCKER_PORTS = vol.Schema({
}) })
# pylint: disable=no-value-for-parameter
SCHEMA_HASS_CONFIG = vol.Schema({ SCHEMA_HASS_CONFIG = vol.Schema({
vol.Optional(ATTR_DEVICES, default=[]): HASS_DEVICES, vol.Optional(ATTR_DEVICES, default=[]): HASS_DEVICES,
vol.Optional(ATTR_BOOT, default=True): vol.Boolean(),
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Coerce(str), vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Coerce(str),
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str), vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),
}) })
@ -73,6 +75,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_HASSIO_CONFIG = vol.Schema({ SCHEMA_HASSIO_CONFIG = vol.Schema({
vol.Optional(ATTR_TIMEZONE, default='UTC'): validate_timezone, vol.Optional(ATTR_TIMEZONE, default='UTC'): validate_timezone,
vol.Optional(ATTR_LAST_BOOT): vol.Coerce(str),
vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[]): [vol.Url()], vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[]): [vol.Url()],
vol.Optional(ATTR_SECURITY, default=False): vol.Boolean(), vol.Optional(ATTR_SECURITY, default=False): vol.Boolean(),
vol.Optional(ATTR_TOTP): vol.Coerce(str), vol.Optional(ATTR_TOTP): vol.Coerce(str),