From 5a80be9fd46bcaf1e327fe762a049d71474ff88b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 11 Sep 2017 14:14:26 +0200 Subject: [PATCH] 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 --- API.md | 5 +++- hassio/api/__init__.py | 2 ++ hassio/api/homeassistant.py | 18 ++++++++++++- hassio/config.py | 28 ++++++++++++++++----- hassio/const.py | 1 + hassio/core.py | 8 ++++-- hassio/dock/homeassistant.py | 18 +++++++++++++ hassio/hardware.py | 22 ++++++++++++++++ hassio/homeassistant.py | 24 ++++++++++++++++-- hassio/tasks.py | 5 ++++ hassio/tools.py | 49 +++++++++++++++++++++++++++++++++++- hassio/validate.py | 5 +++- 12 files changed, 171 insertions(+), 14 deletions(-) diff --git a/API.md b/API.md index b2d05a818..e7c315ef0 100644 --- a/API.md +++ b/API.md @@ -283,7 +283,8 @@ Optional: "last_version": "LAST_VERSION", "devices": [""], "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/options` - POST `/homeassistant/check` +- POST `/homeassistant/start` +- POST `/homeassistant/stop` ```json { diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 6323d8785..f012c83af 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -72,6 +72,8 @@ class RestAPI(object): 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/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) def register_addons(self, addons): diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index 59558624a..99a1af060 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -7,14 +7,16 @@ import voluptuous as vol from .util import api_process, api_process_raw, api_validate from ..const import ( ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM, - CONTENT_TYPE_BINARY) + ATTR_BOOT, CONTENT_TYPE_BINARY) from ..validate import HASS_DEVICES _LOGGER = logging.getLogger(__name__) +# pylint: disable=no-value-for-parameter SCHEMA_OPTIONS = vol.Schema({ 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_LAST_VERSION, 'custom_hass'): vol.Any(None, vol.Coerce(str)), @@ -43,6 +45,7 @@ class APIHomeAssistant(object): ATTR_IMAGE: self.homeassistant.image, ATTR_DEVICES: self.homeassistant.devices, ATTR_CUSTOM: self.homeassistant.is_custom_image, + ATTR_BOOT: self.homeassistant.boot, } @api_process @@ -57,6 +60,9 @@ class APIHomeAssistant(object): self.homeassistant.set_custom( body[ATTR_IMAGE], body[ATTR_LAST_VERSION]) + if ATTR_BOOT in body: + self.homeassistant.boot = body[ATTR_BOOT] + return True @api_process @@ -71,6 +77,16 @@ class APIHomeAssistant(object): return await asyncio.shield( 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 def restart(self, request): """Restart homeassistant.""" diff --git a/hassio/config.py b/hassio/config.py index 9386c2ad2..f7604776a 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -7,14 +7,12 @@ from pathlib import Path, PurePath from .const import ( FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_SECURITY, ATTR_SESSIONS, ATTR_PASSWORD, ATTR_TOTP, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST, - ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT) -from .tools import JsonConfig + ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_LAST_BOOT) +from .tools import JsonConfig, parse_datetime from .validate import SCHEMA_HASSIO_CONFIG _LOGGER = logging.getLogger(__name__) -DATETIME_FORMAT = "%Y%m%d %H:%M:%S" - HOMEASSISTANT_CONFIG = PurePath("homeassistant") HASSIO_SSL = PurePath("ssl") @@ -28,6 +26,8 @@ BACKUP_DATA = PurePath("backup") SHARE_DATA = PurePath("share") TMP_DATA = PurePath("tmp") +DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat() + class CoreConfig(JsonConfig): """Hold all core config data.""" @@ -48,6 +48,22 @@ class CoreConfig(JsonConfig): self._data[ATTR_TIMEZONE] = value 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 def path_hassio(self): """Return hassio data path.""" @@ -191,14 +207,14 @@ class CoreConfig(JsonConfig): def security_sessions(self): """Return api sessions.""" return { - session: datetime.strptime(until, DATETIME_FORMAT) for + session: parse_datetime(until) for session, until in self._data[ATTR_SESSIONS].items() } def add_security_session(self, session, valid): """Set the a new session.""" self._data[ATTR_SESSIONS].update( - {session: valid.strftime(DATETIME_FORMAT)} + {session: valid.isoformat()} ) self.save() diff --git a/hassio/const.py b/hassio/const.py index b7f2e83fa..f3e21c179 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -61,6 +61,7 @@ ATTR_SOURCE = 'source' ATTR_FEATURES = 'features' ATTR_ADDONS = 'addons' ATTR_VERSION = 'version' +ATTR_LAST_BOOT = 'last_boot' ATTR_LAST_VERSION = 'last_version' ATTR_BETA_CHANNEL = 'beta_channel' ATTR_NAME = 'name' diff --git a/hassio/core.py b/hassio/core.py index 6eecc158c..6feece8bf 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -142,7 +142,7 @@ class HassIO(object): try: # 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") return @@ -153,11 +153,15 @@ class HassIO(object): await self.addons.auto_boot(STARTUP_SERVICES) # run HomeAssistant - await self.homeassistant.run() + if self.homeassistant.boot: + await self.homeassistant.run() # start addon mark as application await self.addons.auto_boot(STARTUP_APPLICATION) + # store new last boot + self.config.last_boot = self.hardware.last_boot + finally: # schedule homeassistant watchdog self.scheduler.register_task( diff --git a/hassio/dock/homeassistant.py b/hassio/dock/homeassistant.py index 1a19b51a6..1a62e6cc3 100644 --- a/hassio/dock/homeassistant.py +++ b/hassio/dock/homeassistant.py @@ -1,6 +1,8 @@ """Init file for HassIO docker object.""" import logging +import docker + from .interface import DockerInterface _LOGGER = logging.getLogger(__name__) @@ -93,3 +95,19 @@ class DockerHomeAssistant(DockerInterface): {'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 diff --git a/hassio/hardware.py b/hassio/hardware.py index f1af78626..eddfc6f39 100644 --- a/hassio/hardware.py +++ b/hassio/hardware.py @@ -1,4 +1,5 @@ """Read hardware info from system.""" +from datetime import datetime import logging from pathlib import Path import re @@ -15,6 +16,9 @@ RE_CARDS = re.compile(r"(\d+) \[(\w*) *\]: (.*\w)") ASOUND_DEVICES = Path("/proc/asound/devices") RE_DEVICES = re.compile(r"\[.*(\d+)- (\d+).*\]: ([\w ]*)") +PROC_STAT = Path("/proc/stat") +RE_BOOT_TIME = re.compile(r"btime (\d+)") + class Hardware(object): """Represent a interface to procfs, sysfs and udev.""" @@ -85,3 +89,21 @@ class Hardware(object): continue 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))) diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index 0ad03b5e0..b06496c6d 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -6,7 +6,7 @@ import re from .const import ( FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, - ATTR_VERSION) + ATTR_VERSION, ATTR_BOOT) from .dock.homeassistant import DockerHomeAssistant from .tools import JsonConfig, convert_to_ascii from .validate import SCHEMA_HASS_CONFIG @@ -73,6 +73,17 @@ class HomeAssistant(JsonConfig): self._data[ATTR_DEVICES] = value 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): """Set a custom image for homeassistant.""" # reset @@ -119,6 +130,7 @@ class HomeAssistant(JsonConfig): async def update(self, version=None): """Update HomeAssistant version.""" version = version or self.last_version + running = await self.docker.is_running() if version == self.docker.version: _LOGGER.warning("Version %s is already installed", version) @@ -127,7 +139,8 @@ class HomeAssistant(JsonConfig): try: return await self.docker.update(version) finally: - await self.docker.run() + if running: + await self.docker.run() def run(self): """Run HomeAssistant docker. @@ -164,6 +177,13 @@ class HomeAssistant(JsonConfig): """ 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 def in_progress(self): """Return True if a task is in progress.""" diff --git a/hassio/tasks.py b/hassio/tasks.py index 5c39f66a6..a62a9c821 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -66,6 +66,11 @@ 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 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(): return diff --git a/hassio/tools.py b/hassio/tools.py index 8b0b689ad..53a06eb2a 100644 --- a/hassio/tools.py +++ b/hassio/tools.py @@ -1,13 +1,14 @@ """Tools file for HassIO.""" import asyncio from contextlib import suppress -from datetime import datetime +from datetime import datetime, timedelta, timezone import json import logging import re import aiohttp import async_timeout +import pytz import voluptuous as vol from voluptuous.humanize import humanize_error @@ -17,6 +18,16 @@ FREEGEOIP_URL = "https://freegeoip.io/json/" 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\d{4})-(?P\d{1,2})-(?P\d{1,2})' + r'[T ](?P\d{1,2}):(?P\d{1,2})' + r'(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?' + r'(?PZ|[+-]\d{2}(?::?\d{2})?)?$' +) + def write_json_file(jsonfile, data): """Write a json file.""" @@ -53,6 +64,42 @@ def convert_to_ascii(raw): 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): """Hass core object for handle it.""" diff --git a/hassio/validate.py b/hassio/validate.py index 529a23642..6004a989e 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -7,7 +7,7 @@ from .const import ( ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD, ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE, 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)) @@ -55,8 +55,10 @@ DOCKER_PORTS = vol.Schema({ }) +# pylint: disable=no-value-for-parameter SCHEMA_HASS_CONFIG = vol.Schema({ 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_LAST_VERSION, 'custom_hass'): vol.Coerce(str), }) @@ -73,6 +75,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({ # pylint: disable=no-value-for-parameter SCHEMA_HASSIO_CONFIG = vol.Schema({ 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_SECURITY, default=False): vol.Boolean(), vol.Optional(ATTR_TOTP): vol.Coerce(str),