diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index d458a3014..9144cda84 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -74,6 +74,7 @@ class RestAPI(CoreSysAttributes): self.webapp.add_routes([ web.get('/hassos/info', api_hassos.info), + web.post('/hassos/update', api_hassos.update), web.post('/hassos/config/sync', api_hassos.config_sync), ]) diff --git a/hassio/api/hassos.py b/hassio/api/hassos.py index 426b174e8..c5faeefaf 100644 --- a/hassio/api/hassos.py +++ b/hassio/api/hassos.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol -from .utils import api_process +from .utils import api_process, api_validate from ..const import ATTR_VERSION, ATTR_BOARD, ATTR_VERSION_LATEST from ..coresys import CoreSysAttributes @@ -23,10 +23,18 @@ class APIHassOS(CoreSysAttributes): """Return hassos information.""" return { ATTR_VERSION: self.sys_hassos.version, - ATTR_VERSION_LATEST: self.sys_hassos.version, + ATTR_VERSION_LATEST: self.sys_hassos.version_latest, ATTR_BOARD: self.sys_hassos.board, } + @api_process + async def update(self, request): + """Update HassOS.""" + body = await api_validate(SCHEMA_VERSION, request) + version = body.get(ATTR_VERSION, self.sys_hassos.version_latest) + + await asyncio.shield(self.sys_hassos.update(version)) + @api_process def config_sync(self, request): """Trigger config reload on HassOS.""" diff --git a/hassio/api/utils.py b/hassio/api/utils.py index 7d46fccd6..eaedc7dbb 100644 --- a/hassio/api/utils.py +++ b/hassio/api/utils.py @@ -34,7 +34,6 @@ def api_process(method): except RuntimeError as err: return api_return_error(message=str(err)) except HassioError: - _LOGGER.exception("Hassio error") return api_return_error() if isinstance(answer, dict): diff --git a/hassio/const.py b/hassio/const.py index c74b38c8c..5e1925c2a 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -6,9 +6,13 @@ HASSIO_VERSION = '110' URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_VERSION = \ - "https://s3.amazonaws.com/hassio-version/{channel}.json" + "https://s3.amazonaws.com/hassio-version/{channel}.json" URL_HASSIO_APPARMOR = \ - "https://s3.amazonaws.com/hassio-version/apparmor.txt" + "https://s3.amazonaws.com/hassio-version/apparmor.txt" + +URL_HASSOS_OTA = ( + "https://github.com/home-assistant/hassos/releases/download/" + "{version}/hassos_{board}-{version}.raucb") HASSIO_DATA = Path("/data") @@ -169,6 +173,7 @@ ATTR_APPARMOR = 'apparmor' ATTR_DEVICETREE = 'devicetree' ATTR_CPE = 'cpe' ATTR_BOARD = 'board' +ATTR_HASSOS = 'hassos' SERVICE_MQTT = 'mqtt' diff --git a/hassio/dbus/__init__.py b/hassio/dbus/__init__.py index 8aa4250b2..be8f8a3db 100644 --- a/hassio/dbus/__init__.py +++ b/hassio/dbus/__init__.py @@ -2,6 +2,7 @@ from .systemd import Systemd from .hostname import Hostname +from .rauc import Rauc from ..coresys import CoreSysAttributes @@ -11,8 +12,10 @@ class DBusManager(CoreSysAttributes): def __init__(self, coresys): """Initialize DBus Interface.""" self.coresys = coresys + self._systemd = Systemd() self._hostname = Hostname() + self._rauc = Rauc() @property def systemd(self): @@ -24,7 +27,13 @@ class DBusManager(CoreSysAttributes): """Return hostname Interface.""" return self._hostname + @property + def rauc(self): + """Return rauc Interface.""" + return self._rauc + async def load(self): """Connect interfaces to dbus.""" await self.systemd.connect() await self.hostname.connect() + await self.rauc.connect() diff --git a/hassio/dbus/rauc.py b/hassio/dbus/rauc.py new file mode 100644 index 000000000..c656b38f4 --- /dev/null +++ b/hassio/dbus/rauc.py @@ -0,0 +1,55 @@ +"""DBus interface for rauc.""" +import logging + +from .interface import DBusInterface +from .utils import dbus_connected +from ..exceptions import DBusError +from ..utils.gdbus import DBus + +_LOGGER = logging.getLogger(__name__) + +DBUS_NAME = 'de.pengutronix.rauc' +DBUS_OBJECT = '/' + + +class Rauc(DBusInterface): + """Handle DBus interface for rauc.""" + + async def connect(self): + """Connect do bus.""" + try: + self.dbus = await DBus.connect(DBUS_NAME, DBUS_OBJECT) + except DBusError: + _LOGGER.warning("Can't connect to rauc") + + @dbus_connected + def install(self, raucb_file): + """Install rauc bundle file. + + Return a coroutine. + """ + return self.dbus.Installer.Install(raucb_file) + + @dbus_connected + def get_slot_status(self): + """Get slot status. + + Return a coroutine. + """ + return self.dbus.Installer.GetSlotStatus() + + @dbus_connected + def get_properties(self): + """Return rauc informations. + + Return a coroutine. + """ + return self.dbus.get_properties(f"{DBUS_NAME}.Installer") + + @dbus_connected + def signal_completed(self): + """Return a signal wrapper for completed signal. + + Return a coroutine. + """ + return self.dbus.wait_signal(f"{DBUS_NAME}.Installer.Completed") diff --git a/hassio/exceptions.py b/hassio/exceptions.py index 58dc044de..bfa8fb899 100644 --- a/hassio/exceptions.py +++ b/hassio/exceptions.py @@ -11,6 +11,30 @@ class HassioNotSupportedError(HassioError): pass +# HassOS + +class HassOSError(HassioError): + """HassOS exception.""" + pass + + +class HassOSUpdateError(HassOSError): + """Error on update of a HassOS.""" + pass + + +class HassOSNotSupportedError(HassioNotSupportedError): + """Function not supported by HassOS.""" + pass + + +# Updater + +class HassioUpdaterError(HassioError): + """Error on Updater.""" + pass + + # Host class HostError(HassioError): diff --git a/hassio/hassos.py b/hassio/hassos.py index 6141966a7..e646289df 100644 --- a/hassio/hassos.py +++ b/hassio/hassos.py @@ -1,10 +1,13 @@ """HassOS support on supervisor.""" import logging +from pathlib import Path +import aiohttp from cpe import CPE from .coresys import CoreSysAttributes -from .exceptions import HassioNotSupportedError +from .const import URL_HASSOS_OTA +from .exceptions import HassOSNotSupportedError, HassOSUpdateError, DBusError _LOGGER = logging.getLogger(__name__) @@ -29,6 +32,11 @@ class HassOS(CoreSysAttributes): """Return version of HassOS.""" return self._version + @property + def version_latest(self): + """Return version of HassOS.""" + return self.sys_updater.version_hassos + @property def board(self): """Return board name.""" @@ -38,16 +46,47 @@ class HassOS(CoreSysAttributes): """Check if HassOS is availabe.""" if not self.available: _LOGGER.error("No HassOS availabe") - raise HassioNotSupportedError() + raise HassOSNotSupportedError() + + async def _download_raucb(self, version): + """Download rauc bundle (OTA) from github.""" + url = URL_HASSOS_OTA.format(version=version, board=self.board) + raucb = Path(self.sys_config.path_tmp, f"hassos-{version}.raucb") + + try: + _LOGGER.info("Fetch OTA update from %s", url) + async with self.sys_websession.get(url) as request: + with raucb.open('wb') as ota_file: + while True: + chunk = await request.content.read(1048576) + if not chunk: + break + ota_file.write(chunk) + + _LOGGER.info("OTA update is downloaded on %s", raucb) + return raucb + + except aiohttp.ClientError as err: + _LOGGER.warning("Can't fetch versions from %s: %s", url, err) + + except OSError as err: + _LOGGER.error("Can't write ota file: %s", err) + + raise HassOSUpdateError() async def load(self): """Load HassOS data.""" try: + # Check needed host functions + assert self.sys_dbus.rauc.is_connected + assert self.sys_dbus.systemd.is_connected + assert self.sys_dbus.hostname.is_connected + assert self.sys_host.info.cpe is not None cpe = CPE(self.sys_host.info.cpe) assert cpe.get_product()[0] == 'hassos' - except (NotImplementedError, IndexError, AssertionError): - _LOGGER.info("Can't detect HassOS") + except (AssertionError, NotImplementedError): + _LOGGER.debug("Ignore HassOS") return # Store meta data @@ -58,8 +97,48 @@ class HassOS(CoreSysAttributes): _LOGGER.info("Detect HassOS %s on host system", self.version) def config_sync(self): - """Trigger a host config reload from usb.""" + """Trigger a host config reload from usb. + + Return a coroutine. + """ self._check_host() _LOGGER.info("Sync config from USB on HassOS.") return self.sys_host.services.restart('hassos-config.service') + + async def update(self, version=None): + """Update HassOS system.""" + version = version or self.version_latest + + # Check installed version + self._check_host() + if version == self.version: + _LOGGER.warning("Version %s is already installed", version) + raise HassOSUpdateError() + + # Fetch files from internet + int_ota = await self._download_raucb(version) + ext_ota = Path(self.sys_config.path_extern_tmp, int_ota.name) + + try: + await self.sys_dbus.rauc.install(ext_ota) + completed = await self.sys_dbus.rauc.signal_completed() + + except DBusError: + _LOGGER.error("Rauc communication error") + raise HassOSUpdateError() from None + + finally: + int_ota.unlink() + + # Update success + if 0 in completed: + _LOGGER.info("Install HassOS %s success", version) + self.sys_create_task(self.sys_host.control.reboot()) + return + + # Update fails + rauc_status = await self.sys_dbus.get_properties() + _LOGGER.error( + "HassOS update fails with: %s", rauc_status.get('LastError')) + raise HassOSUpdateError() diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index c95b644bd..917f0298b 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -211,7 +211,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): exists = await self.instance.exists() if exists and version == self.instance.version: - _LOGGER.info("Version %s is already installed", version) + _LOGGER.warning("Version %s is already installed", version) return False try: diff --git a/hassio/updater.py b/hassio/updater.py index fbe22af12..78cb3bed6 100644 --- a/hassio/updater.py +++ b/hassio/updater.py @@ -1,5 +1,4 @@ """Fetch last versions from webserver.""" -import asyncio from contextlib import suppress from datetime import timedelta import json @@ -9,11 +8,12 @@ import aiohttp from .const import ( URL_HASSIO_VERSION, FILE_HASSIO_UPDATER, ATTR_HOMEASSISTANT, ATTR_HASSIO, - ATTR_CHANNEL) + ATTR_CHANNEL, ATTR_HASSOS) from .coresys import CoreSysAttributes from .utils import AsyncThrottle from .utils.json import JsonConfig from .validate import SCHEMA_UPDATER_CONFIG +from .exceptions import HassioUpdaterError _LOGGER = logging.getLogger(__name__) @@ -26,12 +26,15 @@ class Updater(JsonConfig, CoreSysAttributes): super().__init__(FILE_HASSIO_UPDATER, SCHEMA_UPDATER_CONFIG) self.coresys = coresys - def load(self): - """Update internal data. + async def load(self): + """Update internal data.""" + with suppress(HassioUpdaterError): + await self.fetch_data() - Return a coroutine. - """ - return self.reload() + async def reload(self): + """Update internal data.""" + with suppress(HassioUpdaterError): + await self.fetch_data() @property def version_homeassistant(self): @@ -43,6 +46,11 @@ class Updater(JsonConfig, CoreSysAttributes): """Return last version of hassio.""" return self._data.get(ATTR_HASSIO) + @property + def version_hassos(self): + """Return last version of hassos.""" + return self._data.get(ATTR_HASSOS) + @property def channel(self): """Return upstream channel of hassio instance.""" @@ -54,38 +62,47 @@ class Updater(JsonConfig, CoreSysAttributes): self._data[ATTR_CHANNEL] = value @AsyncThrottle(timedelta(seconds=60)) - async def reload(self): + async def fetch_data(self): """Fetch current versions from github. Is a coroutine. """ url = URL_HASSIO_VERSION.format(channel=self.channel) + machine = self.sys_machine or 'default' + board = self.sys_hassos.board + try: _LOGGER.info("Fetch update data from %s", url) async with self.sys_websession.get(url, timeout=10) as request: data = await request.json(content_type=None) - except (aiohttp.ClientError, asyncio.TimeoutError, KeyError) as err: + except aiohttp.ClientError as err: _LOGGER.warning("Can't fetch versions from %s: %s", url, err) - return + raise HassioUpdaterError() from None except json.JSONDecodeError as err: _LOGGER.warning("Can't parse versions from %s: %s", url, err) - return + raise HassioUpdaterError() from None # data valid? if not data or data.get(ATTR_CHANNEL) != self.channel: _LOGGER.warning("Invalid data from %s", url) - return + raise HassioUpdaterError() from None - # update supervisor versions - with suppress(KeyError): + try: + # update supervisor version self._data[ATTR_HASSIO] = data['supervisor'] - # update Home Assistant version - machine = self.sys_machine or 'default' - with suppress(KeyError): - self._data[ATTR_HOMEASSISTANT] = \ - data['homeassistant'][machine] + # update Home Assistant version + self._data[ATTR_HOMEASSISTANT] = data['homeassistant'][machine] - self.save_data() + # update hassos version + if self.sys_hassos.available and board: + self._data[ATTR_HASSOS] = data['hassos'][board] + + except KeyError as err: + _LOGGER.warning("Can't process version data: %s", err) + raise HassioUpdaterError() from None + + else: + self.save_data() diff --git a/hassio/utils/dt.py b/hassio/utils/dt.py index c529c07fc..66fa176bc 100644 --- a/hassio/utils/dt.py +++ b/hassio/utils/dt.py @@ -1,11 +1,9 @@ """Tools file for HassIO.""" -import asyncio from datetime import datetime, timedelta, timezone import logging import re import aiohttp -import async_timeout import pytz UTC = pytz.utc @@ -29,11 +27,10 @@ async def fetch_timezone(websession): """Read timezone from freegeoip.""" data = {} try: - with async_timeout.timeout(10): - async with websession.get(FREEGEOIP_URL) as request: - data = await request.json() + async with websession.get(FREEGEOIP_URL, timeout=10) as request: + data = await request.json() - except (aiohttp.ClientError, asyncio.TimeoutError, KeyError) as err: + except aiohttp.ClientError as err: _LOGGER.warning("Can't fetch freegeoip data: %s", err) except ValueError as err: diff --git a/hassio/utils/gdbus.py b/hassio/utils/gdbus.py index d9886390d..f893ea62b 100644 --- a/hassio/utils/gdbus.py +++ b/hassio/utils/gdbus.py @@ -4,6 +4,7 @@ import logging import json import shlex import re +from signal import SIGINT import xml.etree.ElementTree as ET from ..exceptions import DBusFatalError, DBusParseError @@ -20,11 +21,14 @@ RE_GVARIANT_STRING = re.compile(r"(?<=(?: |{|\[|\())'(.*?)'(?=(?:|]|}|,|\)))") RE_GVARIANT_TUPLE_O = re.compile(r"\"[^\"]*?\"|(\()") RE_GVARIANT_TUPLE_C = re.compile(r"\"[^\"]*?\"|(,?\))") +RE_MONITOR_OUTPUT = re.compile(r".+?: (?P[^ ].+) (?P.*)") + # Commands for dbus INTROSPECT = ("gdbus introspect --system --dest {bus} " "--object-path {object} --xml") CALL = ("gdbus call --system --dest {bus} --object-path {object} " "--method {method} {args}") +MONITOR = ("gdbus monitor --system --dest {bus}") DBUS_METHOD_GETALL = 'org.freedesktop.DBus.Properties.GetAll' @@ -37,6 +41,7 @@ class DBus: self.bus_name = bus_name self.object_path = object_path self.methods = set() + self.signals = set() @staticmethod async def connect(bus_name, object_path): @@ -69,12 +74,19 @@ class DBus: _LOGGER.debug("data: %s", data) for interface in xml.findall("./interface"): interface_name = interface.get('name') + + # Methods for method in interface.findall("./method"): method_name = method.get('name') self.methods.add(f"{interface_name}.{method_name}") + # Signals + for signal in interface.findall("./signal"): + signal_name = signal.get('name') + self.signals.add(f"{interface_name}.{signal_name}") + @staticmethod - def _gvariant(raw): + def parse_gvariant(raw): """Parse GVariant input to python.""" raw = RE_GVARIANT_TYPE.sub("", raw) raw = RE_GVARIANT_VARIANT.sub(r"\1", raw) @@ -108,7 +120,7 @@ class DBus: data = await self._send(command) # Parse and return data - return self._gvariant(data) + return self.parse_gvariant(data) async def get_properties(self, interface): """Read all properties from interface.""" @@ -143,6 +155,17 @@ class DBus: # End return data.decode() + def attach_signals(self, filters=None): + """Generate a signals wrapper.""" + return DBusSignalWrapper(self, filters) + + async def wait_signal(self, signal): + """Wait for single event.""" + monitor = DBusSignalWrapper(self, [signal]) + async with monitor as signals: + async for signal in signals: + return signal + def __getattr__(self, name): """Mapping to dbus method.""" return getattr(DBusCallWrapper(self, self.bus_name), name) @@ -176,3 +199,71 @@ class DBusCallWrapper: return self.dbus.call_dbus(interface, *args) return _method_wrapper + + +class DBusSignalWrapper: + """Process Signals.""" + + def __init__(self, dbus, signals=None): + """Initialize dbus signal wrapper.""" + self.dbus = dbus + self._signals = signals + self._proc = None + + async def __aenter__(self): + """Start monitor events.""" + _LOGGER.info("Start dbus monitor on %s", self.dbus.bus_name) + command = shlex.split(MONITOR.format( + bus=self.dbus.bus_name + )) + self._proc = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + return self + + async def __aexit__(self, exception_type, exception_value, traceback): + """Stop monitor events.""" + _LOGGER.info("Stop dbus monitor on %s", self.dbus.bus_name) + self._proc.send_signal(SIGINT) + await self._proc.communicate() + + async def __aiter__(self): + """Start Iteratation.""" + return self + + async def __anext__(self): + """Get next data.""" + if not self._proc: + raise StopAsyncIteration() + + # Read signals + while True: + try: + data = await self._proc.stdout.readline() + except asyncio.TimeoutError: + raise StopAsyncIteration() from None + + # Program close + if not data: + raise StopAsyncIteration() + + # Extract metadata + match = RE_MONITOR_OUTPUT.match(data.decode()) + if not match: + continue + signal = match.group('signal') + data = match.group('data') + + # Filter signals? + if self._signals and signal not in self._signals: + _LOGGER.debug("Skip event %s - %s", signal, data) + continue + + try: + return self.dbus.parse_gvariant(data) + except DBusParseError: + raise StopAsyncIteration() from None diff --git a/hassio/validate.py b/hassio/validate.py index 61e864343..e31164d5a 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -6,7 +6,7 @@ import voluptuous as vol import pytz from .const import ( - ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE, + ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE, ATTR_HASSOS, ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, ATTR_WAIT_BOOT, ATTR_UUID, CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV) @@ -99,6 +99,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({ vol.Optional(ATTR_CHANNEL, default=CHANNEL_STABLE): CHANNELS, vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str), vol.Optional(ATTR_HASSIO): vol.Coerce(str), + vol.Optional(ATTR_HASSOS): vol.Coerce(str), }, extra=vol.REMOVE_EXTRA)