diff --git a/API.md b/API.md index 6f8f7b07f..98748b4dc 100644 --- a/API.md +++ b/API.md @@ -227,7 +227,7 @@ return: ```json { "hostname": "hostname|null", - "features": ["shutdown", "reboot", "update", "hostname"], + "features": ["shutdown", "reboot", "update", "hostname", "services"], "operating_system": "Hass.io-OS XY|Ubuntu 16.4|null", "kernel": "4.15.7|null", "chassis": "specific|null", @@ -259,6 +259,27 @@ Optional: - POST `/host/reload` +#### Services + +- GET `/host/services` +```json +{ + "services": [ + { + "name": "xy.service", + "description": "XY ...", + "state": "active|" + } + ] +} +``` + +- POST `/host/service/{unit}/stop` + +- POST `/host/service/{unit}/start` + +- POST `/host/service/{unit}/reload` + ### Hardware - GET `/hardware/info` @@ -569,14 +590,6 @@ return: } ``` -- GET `/services/xy` -```json -{ - "available": "bool", - "xy": {} -} -``` - #### MQTT This service performs an auto discovery to Home-Assistant. diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index f139dbaf6..2393320bb 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -57,6 +57,13 @@ class RestAPI(CoreSysAttributes): web.post('/host/shutdown', api_host.shutdown), web.post('/host/update', api_host.update), web.post('/host/reload', api_host.reload), + web.get('/host/services', api_host.services), + web.post('/host/services/{service}/stop', api_host.service_stop), + web.post('/host/services/{service}/start', api_host.service_start), + web.post( + '/host/services/{service}/restart', api_host.service_restart), + web.post( + '/host/services/{service}/reload', api_host.service_reload), ]) def _register_hardware(self): diff --git a/hassio/api/host.py b/hassio/api/host.py index 94991a46b..e5ce6de03 100644 --- a/hassio/api/host.py +++ b/hassio/api/host.py @@ -7,11 +7,14 @@ import voluptuous as vol from .utils import api_process, api_validate from ..const import ( ATTR_VERSION, ATTR_LAST_VERSION, ATTR_HOSTNAME, ATTR_FEATURES, ATTR_KERNEL, - ATTR_TYPE, ATTR_OPERATING_SYSTEM, ATTR_CHASSIS, ATTR_DEPLOYMENT) + ATTR_TYPE, ATTR_OPERATING_SYSTEM, ATTR_CHASSIS, ATTR_DEPLOYMENT, + ATTR_STATE, ATTR_NAME, ATTR_DESCRIPTON, ATTR_SERVICES) from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) +SERVICE = 'service' + SCHEMA_VERSION = vol.Schema({ vol.Optional(ATTR_VERSION): vol.Coerce(str), }) @@ -70,3 +73,42 @@ class APIHost(CoreSysAttributes): pass # body = await api_validate(SCHEMA_VERSION, request) # version = body.get(ATTR_VERSION, self.sys_host.last_version) + + @api_process + async def services(self, request): + """Return list of available services.""" + services = [] + for unit in self.sys_host.services: + services.append({ + ATTR_NAME: unit.name, + ATTR_DESCRIPTON: unit.description, + ATTR_STATE: unit.state, + }) + + return { + ATTR_SERVICES: services + } + + @api_process + def service_start(self, request): + """Start a service.""" + unit = request.match_info.get(SERVICE) + return asyncio.shield(self.sys_host.services.start(unit)) + + @api_process + def service_stop(self, request): + """Stop a service.""" + unit = request.match_info.get(SERVICE) + return asyncio.shield(self.sys_host.services.stop(unit)) + + @api_process + def service_reload(self, request): + """Reload a service.""" + unit = request.match_info.get(SERVICE) + return asyncio.shield(self.sys_host.services.reload(unit)) + + @api_process + def service_restart(self, request): + """Restart a service.""" + unit = request.match_info.get(SERVICE) + return asyncio.shield(self.sys_host.services.restart(unit)) diff --git a/hassio/const.py b/hassio/const.py index 023c0e1ad..74415f29e 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -216,3 +216,4 @@ FEATURES_SHUTDOWN = 'shutdown' FEATURES_REBOOT = 'reboot' FEATURES_UPDATE = 'update' FEATURES_HOSTNAME = 'hostname' +FEATURES_SERVICES = 'services' diff --git a/hassio/dbus/systemd.py b/hassio/dbus/systemd.py index d1aff0268..87740caa1 100644 --- a/hassio/dbus/systemd.py +++ b/hassio/dbus/systemd.py @@ -37,3 +37,43 @@ class Systemd(DBusInterface): Return a coroutine. """ return self.dbus.Manager.PowerOff() + + @dbus_connected + def start_unit(self, unit, mode): + """Start a systemd service unit. + + Return a coroutine. + """ + return self.dbus.Manager.StartUnit(unit, mode) + + @dbus_connected + def stop_unit(self, unit, mode): + """Stop a systemd service unit. + + Return a coroutine. + """ + return self.dbus.Manager.StopUnit(unit, mode) + + @dbus_connected + def reload_unit(self, unit, mode): + """Reload a systemd service unit. + + Return a coroutine. + """ + return self.dbus.Manager.ReloadOrRestartUnit(unit, mode) + + @dbus_connected + def restart_unit(self, unit, mode): + """Restart a systemd service unit. + + Return a coroutine. + """ + return self.dbus.Manager.RestartUnit(unit, mode) + + @dbus_connected + def list_units(self): + """Return a list of available systemd services. + + Return a coroutine. + """ + return self.dbus.Manager.ListUnits() diff --git a/hassio/exceptions.py b/hassio/exceptions.py index 09e06bf00..e3342ecf1 100644 --- a/hassio/exceptions.py +++ b/hassio/exceptions.py @@ -28,6 +28,11 @@ class HostNotSupportedError(HassioNotSupportedError): pass +class HostServiceError(HostError): + """Host service functions fails.""" + pass + + # utils/gdbus class DBusError(HassioError): diff --git a/hassio/host/__init__.py b/hassio/host/__init__.py index af72f448d..a65e46f32 100644 --- a/hassio/host/__init__.py +++ b/hassio/host/__init__.py @@ -3,7 +3,9 @@ from .alsa import AlsaAudio from .control import SystemControl from .info import InfoCenter -from ..const import FEATURES_REBOOT, FEATURES_SHUTDOWN, FEATURES_HOSTNAME +from .service import ServiceManager +from ..const import ( + FEATURES_REBOOT, FEATURES_SHUTDOWN, FEATURES_HOSTNAME, FEATURES_SERVICES) from ..coresys import CoreSysAttributes @@ -16,6 +18,7 @@ class HostManager(CoreSysAttributes): self._alsa = AlsaAudio(coresys) self._control = SystemControl(coresys) self._info = InfoCenter(coresys) + self._services = ServiceManager(coresys) @property def alsa(self): @@ -32,6 +35,11 @@ class HostManager(CoreSysAttributes): """Return host info handler.""" return self._info + @property + def services(self): + """Return host services handler.""" + return self._services + @property def supperted_features(self): """Return a list of supported host features.""" @@ -41,6 +49,7 @@ class HostManager(CoreSysAttributes): features.extend([ FEATURES_REBOOT, FEATURES_SHUTDOWN, + FEATURES_SERVICES, ]) if self.sys_dbus.hostname.is_connected: @@ -53,6 +62,9 @@ class HostManager(CoreSysAttributes): if self.sys_dbus.hostname.is_connected: await self.info.update() + if self.sys_dbus.systemd.is_connected: + await self.services.update() + def reload(self): """Reload host information.""" return self.load() diff --git a/hassio/host/alsa.py b/hassio/host/alsa.py index c34a64f79..3de03f29f 100644 --- a/hassio/host/alsa.py +++ b/hassio/host/alsa.py @@ -81,7 +81,7 @@ class AlsaAudio(CoreSysAttributes): @staticmethod def _audio_database(): """Read local json audio data into dict.""" - json_file = Path(__file__).parent.joinpath('audiodb.json') + json_file = Path(__file__).parent.joinpath("data/audiodb.json") try: # pylint: disable=no-member @@ -121,7 +121,7 @@ class AlsaAudio(CoreSysAttributes): alsa_output = alsa_output or self.default.output # Read Template - asound_file = Path(__file__).parent.joinpath('asound.tmpl') + asound_file = Path(__file__).parent.joinpath("data/asound.tmpl") try: # pylint: disable=no-member with asound_file.open('r') as asound: diff --git a/hassio/host/control.py b/hassio/host/control.py index 9704f6289..ab4d994ff 100644 --- a/hassio/host/control.py +++ b/hassio/host/control.py @@ -6,6 +6,9 @@ from ..exceptions import HostNotSupportedError _LOGGER = logging.getLogger(__name__) +MANAGER = 'manager' +HOSTNAME = 'hostname' + class SystemControl(CoreSysAttributes): """Handle host power controls.""" @@ -14,15 +17,19 @@ class SystemControl(CoreSysAttributes): """Initialize host power handling.""" self.coresys = coresys - def _check_systemd(self): + def _check_dbus(self, flag): """Check if systemd is connect or raise error.""" - if not self.sys_dbus.systemd.is_connected: - _LOGGER.error("No systemd dbus connection available") - raise HostNotSupportedError() + if flag == MANAGER and self.sys_dbus.systemd.is_connected: + return + if flag == HOSTNAME and self.sys_dbus.hostname.is_connected: + return + + _LOGGER.error("No %s dbus connection available", flag) + raise HostNotSupportedError() async def reboot(self): """Reboot host system.""" - self._check_systemd() + self._check_dbus(MANAGER) _LOGGER.info("Initialize host reboot over systemd") try: @@ -32,7 +39,7 @@ class SystemControl(CoreSysAttributes): async def shutdown(self): """Shutdown host system.""" - self._check_systemd() + self._check_dbus(MANAGER) _LOGGER.info("Initialize host power off over systemd") try: @@ -42,9 +49,7 @@ class SystemControl(CoreSysAttributes): async def set_hostname(self, hostname): """Set local a new Hostname.""" - if not self.sys_dbus.systemd.is_connected: - _LOGGER.error("No hostname dbus connection available") - raise HostNotSupportedError() + self._check_dbus(HOSTNAME) _LOGGER.info("Set Hostname %s", hostname) await self.sys_dbus.hostname.set_static_hostname(hostname) diff --git a/hassio/host/asound.tmpl b/hassio/host/data/asound.tmpl similarity index 100% rename from hassio/host/asound.tmpl rename to hassio/host/data/asound.tmpl diff --git a/hassio/host/audiodb.json b/hassio/host/data/audiodb.json similarity index 100% rename from hassio/host/audiodb.json rename to hassio/host/data/audiodb.json diff --git a/hassio/host/info.py b/hassio/host/info.py index 81db98dc9..b21eb67cc 100644 --- a/hassio/host/info.py +++ b/hassio/host/info.py @@ -1,4 +1,4 @@ -"""Power control for host.""" +"""Info control for host.""" import logging from ..coresys import CoreSysAttributes @@ -47,7 +47,7 @@ class InfoCenter(CoreSysAttributes): async def update(self): """Update properties over dbus.""" - if not self.sys_dbus.systemd.is_connected: + if not self.sys_dbus.hostname.is_connected: _LOGGER.error("No hostname dbus connection available") raise HostNotSupportedError() diff --git a/hassio/host/service.py b/hassio/host/service.py new file mode 100644 index 000000000..d200ce510 --- /dev/null +++ b/hassio/host/service.py @@ -0,0 +1,99 @@ +"""Service control for host.""" +import logging + +import attr + +from ..coresys import CoreSysAttributes +from ..exceptions import HassioError, HostNotSupportedError, HostServiceError + +_LOGGER = logging.getLogger(__name__) + +MOD_REPLACE = 'replace' + + +class ServiceManager(CoreSysAttributes): + """Handle local service information controls.""" + + def __init__(self, coresys): + """Initialize system center handling.""" + self.coresys = coresys + self._services = set() + + def __iter__(self): + """Iterator trought services.""" + return iter(self._services) + + def _check_dbus(self, unit=None): + """Check available dbus connection.""" + if not self.sys_dbus.systemd.is_connected: + _LOGGER.error("No systemd dbus connection available") + raise HostNotSupportedError() + + if unit and not self.exists(unit): + _LOGGER.error("Unit '%s' not found", unit) + raise HostServiceError() + + def start(self, unit): + """Start a service on host.""" + self._check_dbus(unit) + + _LOGGER.info("Start local service %s", unit) + return self.sys_dbus.systemd.start_unit(unit, MOD_REPLACE) + + def stop(self, unit): + """Stop a service on host.""" + self._check_dbus(unit) + + _LOGGER.info("Stop local service %s", unit) + return self.sys_dbus.systemd.stop_unit(unit, MOD_REPLACE) + + def reload(self, unit): + """Reload a service on host.""" + self._check_dbus(unit) + + _LOGGER.info("Reload local service %s", unit) + return self.sys_dbus.systemd.reload_unit(unit, MOD_REPLACE) + + def restart(self, unit): + """Restart a service on host.""" + self._check_dbus(unit) + + _LOGGER.info("Restart local service %s", unit) + return self.sys_dbus.systemd.restart_unit(unit, MOD_REPLACE) + + def exists(self, unit): + """Check if a unit exists and return True.""" + for service in self._services: + if unit == service.name: + return True + return False + + async def update(self): + """Update properties over dbus.""" + self._check_dbus() + + _LOGGER.info("Update service information") + self._services.clear() + try: + systemd_units = await self.sys_dbus.systemd.list_units() + for service_data in systemd_units[0]: + if not service_data[0].endswith(".service") or \ + service_data[2] != 'loaded': + continue + self._services.add(ServiceInfo.read_from(service_data)) + except (HassioError, IndexError): + _LOGGER.warning("Can't update host service information!") + + +@attr.s(frozen=True) +class ServiceInfo: + """Represent a single Service.""" + + name = attr.ib(type=str) + description = attr.ib(type=str) + state = attr.ib(type=str) + + @staticmethod + def read_from(unit): + """Parse data from dbus into this object.""" + return ServiceInfo(unit[0], unit[1], unit[3]) diff --git a/hassio/utils/gdbus.py b/hassio/utils/gdbus.py index cedae51a4..d9886390d 100644 --- a/hassio/utils/gdbus.py +++ b/hassio/utils/gdbus.py @@ -14,10 +14,11 @@ _LOGGER = logging.getLogger(__name__) RE_GVARIANT_TYPE = re.compile( r"(?:boolean|byte|int16|uint16|int32|uint32|handle|int64|uint64|double|" r"string|objectpath|signature) ") -RE_GVARIANT_TULPE = re.compile(r"^\((.*),\)$") RE_GVARIANT_VARIANT = re.compile( r"(?<=(?: |{|\[))<((?:'|\").*?(?:'|\")|\d+(?:\.\d+)?)>(?=(?:|]|}|,))") -RE_GVARIANT_STRING = re.compile(r"(?<=(?: |{|\[))'(.*?)'(?=(?:|]|}|,))") +RE_GVARIANT_STRING = re.compile(r"(?<=(?: |{|\[|\())'(.*?)'(?=(?:|]|}|,|\)))") +RE_GVARIANT_TUPLE_O = re.compile(r"\"[^\"]*?\"|(\()") +RE_GVARIANT_TUPLE_C = re.compile(r"\"[^\"]*?\"|(,?\))") # Commands for dbus INTROSPECT = ("gdbus introspect --system --dest {bus} " @@ -76,13 +77,16 @@ class DBus: def _gvariant(raw): """Parse GVariant input to python.""" raw = RE_GVARIANT_TYPE.sub("", raw) - raw = RE_GVARIANT_TULPE.sub(r"[\1]", raw) raw = RE_GVARIANT_VARIANT.sub(r"\1", raw) raw = RE_GVARIANT_STRING.sub(r'"\1"', raw) + raw = RE_GVARIANT_TUPLE_O.sub( + lambda x: x.group(0) if not x.group(1) else"[", raw) + raw = RE_GVARIANT_TUPLE_C.sub( + lambda x: x.group(0) if not x.group(1) else"]", raw) # No data - if raw.startswith("()"): - return {} + if raw.startswith("[]"): + return [] try: return json.loads(raw)