diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..1a205c1 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,16 @@ +name: Validate + +on: + push: + pull_request: + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v2" + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands topics \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f6b817 --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..660f8e6 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Systemd Manager for Home Assistant +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) +[![donate paypal](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/dslonyara) +[![donate tinkoff](https://img.shields.io/badge/Donate-Tinkoff-yellow.svg)](https://www.tinkoff.ru/sl/3FteV5DtBOV) + +The component allows you to manage systemd services via [D-Bus](https://www.freedesktop.org/wiki/Software/dbus/) + +## Table of Contents +- [Prerequisites](#prerequisites) +- [Install](#install) +- [Config](#config) +- [Advanced config](#advanced-config) +- [Services](#services) +- [Performance table](#performance-table) + +## Prerequisites +#### Ubuntu +1. This component uses `D-Bus` to control `systemd`. You need to have D-Bus itself and its python bindings in order to use this component: +```shell +sudo apt install dbus libdbus-glib-1-dev libdbus-1-dev python-dbus +``` + +2. You also need to set up a `polkit` rule so that your user can control systemd via D-Bus. To do this, run the command and paste the content: +```shell +sudo nano /etc/polkit-1/localauthority/50-local.d/systemd-manager.pkla +``` +```ini +[Allow user systemd-manager to execute systemctl commands] +Identity=unix-user: +Action=org.freedesktop.systemd1.manage-units +ResultAny=yes +``` +#### Other +Not yet tested + +## Install +Installed through the custom repository [HACS](https://hacs.xyz/) - `dmamontov/hass-systemd-manager` + +Or by copying the `systemd_manager` folder from [the latest release](https://github.com/dmamontov/hass-systemd-manager/releases/latest) to the custom_components folder (create if necessary) of the configs directory. + +## Config +#### Via GUI (Only) + +`Settings` > `Integrations` > `Plus` > `Systemd Manager` + +All you have to do is select the systemd services you want to manage. + +#### Warnings +1. Only one configuration is allowed; +2. Do not select all services, this increases the load on the processor, especially D-Bus; + +## Advanced config +To get the status of the services, the component requests the status of the services every 10 seconds. This value can be changed in the component's settings. + +## Services +All services support only entity_id. + +[Mode detail](https://www.freedesktop.org/wiki/Software/systemd/dbus/) + +**start** +```yaml +service: systemd_manager.start +data: + mode: REPLACE # One of REPLACE, FAIL, ISOLATE, IGNORE_DEPENDENCIES, IGNORE_REQUIREMENTS +target: + entity_id: switch.systemd_... +``` + +**stop** +```yaml +service: systemd_manager.stop +data: + mode: REPLACE # One of REPLACE, FAIL, IGNORE_DEPENDENCIES, IGNORE_REQUIREMENTS +target: + entity_id: switch.systemd_... +``` + +**restart** +```yaml +service: systemd_manager.restart +data: + mode: REPLACE # One of REPLACE, FAIL, IGNORE_DEPENDENCIES, IGNORE_REQUIREMENTS +target: + entity_id: switch.systemd_... +``` + +**enable** +```yaml +service: systemd_manager.enable +target: + entity_id: switch.systemd_... +``` + +**disable** +```yaml +service: systemd_manager.disable +target: + entity_id: switch.systemd_... +``` + +## Performance table +![](table.png) + +1. Install [Flex Table](https://github.com/custom-cards/flex-table-card) from HACS +2. Add new Lovelace tab with **Panel Mode** +3. Add new Lovelace card: + - [example](https://gist.github.com/dmamontov/e8c52c129fb19fca633d0d2d779676e3) \ No newline at end of file diff --git a/custom_components/systemd_manager/__init__.py b/custom_components/systemd_manager/__init__.py new file mode 100644 index 0000000..e31cb85 --- /dev/null +++ b/custom_components/systemd_manager/__init__.py @@ -0,0 +1,110 @@ +import logging + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry + +from .core.const import ( + DOMAIN, + CONF_MODE, + SERVICE_START, + SERVICE_STOP, + SERVICE_RESTART, + SERVICE_ENABLE, + SERVICE_DISABLE, + ATTR_UNIT_NAME +) +from .core.worker import Worker +from .core.manager import Mode + +_LOGGER = logging.getLogger(__name__) + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + await async_init_services(hass) + + if DOMAIN not in config: + return True + + if DOMAIN in hass.data: + return False + + hass.data.setdefault(DOMAIN, {}) + + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context = {'source': SOURCE_IMPORT}, data = config + )) + + return True + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + if config_entry.data: + hass.config_entries.async_update_entry(config_entry, data = {} , options = config_entry.data) + + worker = Worker(hass, config_entry) + + hass.data.setdefault(DOMAIN, worker) + + if not await worker.async_setup(): + return False + + return True + +async def async_init_services(hass: HomeAssistant) -> None: + async def service_start(service_call: ServiceCall) -> None: + await async_call_action(hass, SERVICE_START, dict(service_call.data)) + + async def service_stop(service_call: ServiceCall) -> None: + await async_call_action(hass, SERVICE_STOP, dict(service_call.data)) + + async def service_restart(service_call: ServiceCall) -> None: + await async_call_action(hass, SERVICE_RESTART, dict(service_call.data)) + + async def service_enable(service_call: ServiceCall) -> None: + await async_call_action(hass, SERVICE_ENABLE, dict(service_call.data)) + + async def service_disable(service_call: ServiceCall) -> None: + await async_call_action(hass, SERVICE_DISABLE, dict(service_call.data)) + + hass.services.async_register(DOMAIN, SERVICE_START, service_start) + hass.services.async_register(DOMAIN, SERVICE_STOP, service_stop) + hass.services.async_register(DOMAIN, SERVICE_RESTART, service_restart) + hass.services.async_register(DOMAIN, SERVICE_ENABLE, service_enable) + hass.services.async_register(DOMAIN, SERVICE_DISABLE, service_disable) + +async def async_call_action(hass: HomeAssistant, action: str, data: dict) -> None: + entities = data.pop('entity_id', None) + + if not entities: + return + + mode = data.pop(CONF_MODE, None) + if mode: + mode = Mode[mode] + + manager = hass.data[DOMAIN].manager + + for entity_id in entities: + state = hass.states.get(entity_id) + if not state: + continue + + unit_name = state.attributes[ATTR_UNIT_NAME] + + if action == SERVICE_START: + manager.start(unit_name, mode) + return + + if action == SERVICE_STOP: + manager.stop(unit_name, mode) + return + + if action == SERVICE_RESTART: + manager.restart(unit_name, mode) + return + + if action == SERVICE_ENABLE: + manager.enable(unit_name) + return + + if action == SERVICE_DISABLE: + manager.disable(unit_name) + return \ No newline at end of file diff --git a/custom_components/systemd_manager/config_flow.py b/custom_components/systemd_manager/config_flow.py new file mode 100644 index 0000000..2179b09 --- /dev/null +++ b/custom_components/systemd_manager/config_flow.py @@ -0,0 +1,69 @@ +import logging +import platform + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.core import callback +from homeassistant import config_entries +from homeassistant.const import CONF_SCAN_INTERVAL +from .core.const import CONF_SERVICES_LIST, SCAN_INTERVAL +from .core.manager import Manager + +_LOGGER = logging.getLogger(__name__) + +@config_entries.HANDLERS.register("systemd_manager") +class SystemdManagerConfigFlow(config_entries.ConfigFlow): + async def async_step_import(self, user_input = None): + if self._async_current_entries(): + return self.async_abort(reason = "single_instance_allowed") + + return self.async_create_entry(title = platform.node(), data = {}) + + async def async_step_user(self, user_input = None): + if self._async_current_entries(): + return self.async_abort(reason = "single_instance_allowed") + + options = list(Manager().list().keys()) + options = sorted(options) + + schema = vol.Schema({ + vol.Required(CONF_SERVICES_LIST, default=[]): cv.multi_select(options), + }) + + if user_input: + return self.async_create_entry(title = platform.node(), data = user_input) + + return self.async_show_form(step_id = "user", data_schema = schema) + + @staticmethod + @callback + def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow: + return OptionsFlowHandler(config_entry) + +class OptionsFlowHandler(config_entries.OptionsFlow): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + self.config_entry = config_entry + + async def async_step_init(self, user_input = None): + return await self.async_step_settings(user_input) + + async def async_step_settings(self, user_input = None): + options = list(Manager().list().keys()) + options = sorted(options) + + schema = vol.Schema({ + vol.Required( + CONF_SERVICES_LIST, + default=self.config_entry.options.get(CONF_SERVICES_LIST, []) + ): cv.multi_select(options), + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + ): cv.positive_int, + }) + + if user_input: + return self.async_create_entry(title = platform.node(), data = user_input) + + return self.async_show_form(step_id = "settings", data_schema = schema) \ No newline at end of file diff --git a/custom_components/systemd_manager/core/const.py b/custom_components/systemd_manager/core/const.py new file mode 100644 index 0000000..873cde7 --- /dev/null +++ b/custom_components/systemd_manager/core/const.py @@ -0,0 +1,23 @@ +DOMAIN = "systemd_manager" + +SCAN_INTERVAL = 10 +DATA_UPDATED = "systemd_manager_data_updated" + +UNIT_INTERFACE = "org.freedesktop.systemd1.Unit" +SERVICE_UNIT_INTERFACE = "org.freedesktop.systemd1.Service" + +CONF_SERVICES_LIST = "services" +CONF_MODE = "mode" + +ATTR_UNIT_NAME = "unit_name" +ATTR_REAL_STATE = "real_state" +ATTR_TYPE = "type" +ATTR_EXIT_CODE = "exit_code" +ATTR_LAST_ACTIVITY = "last_activity" +ATTR_TRIGGERED_BY = "triggered_by" + +SERVICE_START = "start" +SERVICE_STOP = "stop" +SERVICE_RESTART = "restart" +SERVICE_ENABLE = "enable" +SERVICE_DISABLE = "disable" \ No newline at end of file diff --git a/custom_components/systemd_manager/core/manager.py b/custom_components/systemd_manager/core/manager.py new file mode 100644 index 0000000..7693256 --- /dev/null +++ b/custom_components/systemd_manager/core/manager.py @@ -0,0 +1,240 @@ +import logging +import datetime +import dbus +import dbus.mainloop.glib +dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + +from typing import Optional +from enum import Enum +from .const import UNIT_INTERFACE, SERVICE_UNIT_INTERFACE + +_LOGGER = logging.getLogger(__name__) + +class Mode(Enum): + REPLACE = "replace" + FAIL = "fail" + ISOLATE = "isolate" + IGNORE_DEPENDENCIES = "ignore-dependencies" + IGNORE_REQUIREMENTS = "ignore-requirements" + + +class Manager(object): + def __init__(self): + self._bus = dbus.SystemBus() + + def list(self) -> dict: + services = {} + + for unit in self._list_units(): + unit_name = str(unit[0]).strip() + + if not unit_name.endswith('.service') or unit_name in services: + _LOGGER.debug('Systemd Manager {}'.format(unit_name)) + + continue + + services[str(unit[0])] = str(unit[4]) + + return services + + def start(self, unit_name: str, mode: Mode = Mode.REPLACE) -> bool: + interface = self._get_interface() + + if interface is None: + return False + + try: + interface.StartUnit(unit_name, mode.value) + except dbus.exceptions.DBusException as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return False + + return True + + def stop(self, unit_name: str, mode: Mode = Mode.REPLACE) -> bool: + interface = self._get_interface() + + if interface is None: + return False + + try: + interface.StopUnit(unit_name, mode.value) + except dbus.exceptions.DBusException as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return False + + return True + + def restart(self, unit_name: str, mode: Mode = Mode.REPLACE): + interface = self._get_interface() + + if interface is None: + return False + + try: + interface.RestartUnit(unit_name, mode.value) + except dbus.exceptions.DBusException as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return False + + return True + + def enable(self, unit_name: str) -> bool: + interface = self._get_interface() + + if interface is None: + return False + + try: + interface.EnableUnitFiles([unit_name], dbus.Boolean(False), dbus.Boolean(True)) + except dbus.exceptions.DBusException as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return False + + return True + + def disable(self, unit_name: str) -> bool: + interface = self._get_interface() + + if interface is None: + return False + + try: + interface.DisableUnitFiles([unit_name], dbus.Boolean(False)) + except dbus.exceptions.DBusException as error: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return False + + return True + + def _get_state(self, unit_name: str, with_error: bool = True) -> Optional[str]: + interface = self._get_interface() + + if interface is None: + return None + + try: + return interface.GetUnitFileState(unit_name) + except dbus.exceptions.DBusException as e: + if with_error: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None + + def _list_units(self): + interface = self._get_interface() + + if interface is None: + return None + + try: + return interface.ListUnits() + except dbus.exceptions.DBusException as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None + + def _get_interface(self): + try: + obj = self._bus.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") + + return dbus.Interface(obj, "org.freedesktop.systemd1.Manager") + except dbus.exceptions.DBusException as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None + + def get_active_state(self, unit_name: str): + properties = self.get_unit_properties(unit_name, UNIT_INTERFACE) + + if properties is None: + return False + + try: + return properties["ActiveState"].encode("utf-8") + except KeyError as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return False + + def is_active(self, unit_name: str) -> bool: + return self.get_active_state(unit_name) == b"active" + + def is_failed(self, unit_name: str) -> bool: + return self.get_active_state(unit_name) == b"failed" + + def is_available(self, unit_name: str) -> bool: + return self._get_state(unit_name, False) is not None + + def get_error_code(self, unit_name: str) -> Optional[int]: + service_properties = self.get_unit_properties(unit_name, SERVICE_UNIT_INTERFACE) + + if service_properties is None: + return None + + return self.get_exec_status(service_properties) + + def get_exec_status(self, properties: Optional[dict] = None) -> Optional[int]: + try: + return int(properties["ExecMainStatus"]) + except KeyError as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None + + def get_type(self, properties: Optional[dict] = None) -> Optional[str]: + try: + return str(properties["Type"]) + except KeyError as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None + + def get_last_activity(self, properties: Optional[dict] = None) -> Optional[str]: + try: + return datetime.datetime \ + .utcfromtimestamp(int(properties["StateChangeTimestamp"]) / 1000000) \ + .strftime('%Y-%m-%d %H:%M:%S') + except KeyError as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None + + def get_triggered_by(self, properties: Optional[dict] = None) -> Optional[str]: + try: + return ', '.join(list(properties["TriggeredBy"])) + except KeyError as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None + + def get_result(self, properties: Optional[dict] = None) -> Optional[str]: + try: + return properties["Result"].encode("utf-8") + except KeyError as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None + + def get_unit_properties(self, unit_name: str, unit_interface): + interface = self._get_interface() + + if interface is None: + return None + + try: + unit_path = interface.LoadUnit(unit_name) + obj = self._bus.get_object("org.freedesktop.systemd1", unit_path) + + properties_interface = dbus.Interface(obj, "org.freedesktop.DBus.Properties") + + return properties_interface.GetAll(unit_interface) + except dbus.exceptions.DBusException as e: + _LOGGER.error('Systemd Manager (DBus): %r', e) + + return None \ No newline at end of file diff --git a/custom_components/systemd_manager/core/service.py b/custom_components/systemd_manager/core/service.py new file mode 100644 index 0000000..11ac3a9 --- /dev/null +++ b/custom_components/systemd_manager/core/service.py @@ -0,0 +1,115 @@ +import logging + +from typing import Optional +from .manager import Manager, Mode +from .const import ( + UNIT_INTERFACE, + SERVICE_UNIT_INTERFACE, + ATTR_UNIT_NAME, + ATTR_REAL_STATE, + ATTR_TYPE, + ATTR_EXIT_CODE, + ATTR_LAST_ACTIVITY, + ATTR_TRIGGERED_BY, +) + +_LOGGER = logging.getLogger(__name__) + +class Service(object): + def __init__(self, name: str, state: str, manager: Manager) -> None: + self._name: str = name + self._state: str = state + self._manager: Manager = manager + + self._is_added: bool = False + self._is_available: bool = True + self._is_block: bool = False + + self._extra: dict = self.parse_extra() + + @property + def name(self) -> str: + return self._name + + @property + def is_added(self) -> bool: + return self._is_added + + @property + def is_available(self) -> bool: + return self._is_available + + @property + def is_on(self) -> bool: + return self._state in ['running', 'start', 'wait-on'] + + @property + def extra(self) -> dict: + return { + ATTR_UNIT_NAME: self.name, + ATTR_REAL_STATE: self._state + } | self._extra + + def parse_extra(self) -> dict: + extra: dict = {} + + service_properties = self._manager.get_unit_properties(self.name, SERVICE_UNIT_INTERFACE) + if service_properties is not None: + extra |= { + ATTR_TYPE: self._manager.get_type(service_properties), + ATTR_EXIT_CODE: self._manager.get_exec_status(service_properties) + } + + unit_properties = self._manager.get_unit_properties(self.name, UNIT_INTERFACE) + if unit_properties is not None: + extra |= { + ATTR_LAST_ACTIVITY: self._manager.get_last_activity(unit_properties), + ATTR_TRIGGERED_BY: self._manager.get_triggered_by(unit_properties) + } + + return extra + + def add(self) -> None: + self._is_added = True + + async def stop(self, mode: Mode = Mode.REPLACE) -> bool: + return self._manager.stop(self.name, mode) + + async def start(self, mode: Mode = Mode.REPLACE) -> bool: + return self._manager.start(self.name, mode) + + async def update_state(self, state: str, is_block: bool = False) -> None: + if self._is_block and not is_block: + self._is_block = False + + return + + if state != self._state: + self._extra = self.parse_extra() + + self._state = state + self._is_available = True + + if is_block: + self._is_block = True + + async def deactivate(self) -> None: + self._is_available = False + +class Services(object): + def __init__(self) -> None: + self._services = {} + + @property + def list(self) -> dict: + return self._services + + async def async_append(self, service: Service) -> None: + if service.name not in self._services: + self._services[service.name] = service + + def get(self, name: str) -> Optional[Service]: + return self._services[name] if name in self._services else None + + def has(self, name: str) -> bool: + return name in self._services \ No newline at end of file diff --git a/custom_components/systemd_manager/core/worker.py b/custom_components/systemd_manager/core/worker.py new file mode 100644 index 0000000..376952f --- /dev/null +++ b/custom_components/systemd_manager/core/worker.py @@ -0,0 +1,98 @@ +import logging +from datetime import timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.const import CONF_SCAN_INTERVAL + +from .const import DOMAIN, DATA_UPDATED, SCAN_INTERVAL, CONF_SERVICES_LIST +from .manager import Manager +from .service import Service, Services + +_LOGGER = logging.getLogger(__name__) + +class Worker(object): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + self.hass = hass + self.config_entry = config_entry + self.unsub_timer = None + self._is_block = False + + self._manager = Manager() + self._services = Services() + + @property + def manager(self) -> Manager: + return self._manager + + @property + def scan_interval(self) -> int: + return self.config_entry.options.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + + @property + def services(self) -> list: + return self._services + + async def async_update(self) -> None: + if self._is_block: + return + + self._is_block = True + + selected = self.config_entry.options.get(CONF_SERVICES_LIST, []) + + current_services = [] + services = self._manager.list() + for service_name in services: + if service_name not in selected: + if self.services.has(service_name): + await self.services.get(service_name).deactivate() + + continue + + current_services.append(service_name) + + if self.services.has(service_name): + await self.services.get(service_name).update_state(services[service_name]) + + continue + + await self.services.async_append(Service(service_name, services[service_name], self.manager)) + + for service in self.services.list: + if service not in current_services: + await self.services.list[service].deactivate() + + async_dispatcher_send(self.hass, DATA_UPDATED) + + self._is_block = False + + async def async_setup(self) -> bool: + _LOGGER.debug("Systemd Manager async setup") + + self.set_scan_interval() + self.config_entry.add_update_listener(self.async_options_updated) + + for domain in ['switch']: + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup(self.config_entry, domain) + ) + + return True + + def set_scan_interval(self) -> None: + async def refresh(event_time): + await self.async_update() + + if self.unsub_timer is not None: + self.unsub_timer() + + self.unsub_timer = async_track_time_interval( + self.hass, refresh, timedelta(seconds = self.scan_interval) + ) + + @staticmethod + async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + hass.data[DOMAIN].set_scan_interval() \ No newline at end of file diff --git a/custom_components/systemd_manager/manifest.json b/custom_components/systemd_manager/manifest.json new file mode 100644 index 0000000..a792db1 --- /dev/null +++ b/custom_components/systemd_manager/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "systemd_manager", + "name": "Systemd Manager", + "version": "1.0.0", + "documentation": "https://github.com/dmamontov/hass-systemd-manager", + "issue_tracker": "https://github.com/dmamontov/hass-systemd-manager/issues", + "config_flow": true, + "requirements": [ + "dbus-python==1.2.18" + ], + "dependencies": [], + "codeowners": ["@dmamontov"], + "iot_class": "local_polling" +} diff --git a/custom_components/systemd_manager/services.yaml b/custom_components/systemd_manager/services.yaml new file mode 100644 index 0000000..c7188a6 --- /dev/null +++ b/custom_components/systemd_manager/services.yaml @@ -0,0 +1,70 @@ +start: + description: Restart systemd service. + target: + entity: + integration: systemd_manager + domain: switch + fields: + mode: + description: Mode + default: REPLACE + example: REPLACE + required: true + selector: + select: + options: + - "REPLACE" + - "FAIL" + - "ISOLATE" + - "IGNORE_DEPENDENCIES" + - "IGNORE_REQUIREMENTS" +stop: + description: Restart systemd service. + target: + entity: + integration: systemd_manager + domain: switch + fields: + mode: + description: Mode + default: REPLACE + example: REPLACE + required: true + selector: + select: + options: + - "REPLACE" + - "FAIL" + - "IGNORE_DEPENDENCIES" + - "IGNORE_REQUIREMENTS" +restart: + description: Restart systemd service. + target: + entity: + integration: systemd_manager + domain: switch + fields: + mode: + description: Mode + default: REPLACE + example: REPLACE + required: true + selector: + select: + options: + - "REPLACE" + - "FAIL" + - "IGNORE_DEPENDENCIES" + - "IGNORE_REQUIREMENTS" +enable: + description: Enable systemd service. + target: + entity: + integration: systemd_manager + domain: switch +disable: + description: Disable systemd service. + target: + entity: + integration: systemd_manager + domain: switch diff --git a/custom_components/systemd_manager/switch.py b/custom_components/systemd_manager/switch.py new file mode 100644 index 0000000..bcc106b --- /dev/null +++ b/custom_components/systemd_manager/switch.py @@ -0,0 +1,111 @@ +import logging + +import homeassistant.helpers.device_registry as dr + +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .core.const import DOMAIN, DATA_UPDATED +from .core.service import Service + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities) -> None: + worker = hass.data[DOMAIN] + + @callback + def update_services() -> None: + if len(worker.services.list) == 0: + return + + new_services = [] + for name in worker.services.list: + service = worker.services.get(name) + if service is None or service.is_added: + continue + + service.add() + + _LOGGER.debug("Systemd Manager update {}".format(name)) + + new_services.append(SystemdSwitch(hass, service)) + + if len(new_services) > 0: + async_add_entities(new_services) + + async_dispatcher_connect( + hass, DATA_UPDATED, update_services + ) + +class SystemdSwitch(SwitchEntity): + def __init__(self, hass: HomeAssistant, service: Service) -> None: + self.hass = hass + self.service = service + self.unsub_update = None + + self._unique_id = "systemd_ " + ENTITY_ID_FORMAT.format(slugify(service.name.lower())) + self._name = self.service.name + self._is_available = self.service.is_available + self._is_on = self.service.is_on + self._extra = self.service.extra + + self.entity_id = f"{DOMAIN}.{self._unique_id}" + + @property + def name(self) -> str: + return self._name + + @property + def unique_id(self) -> str: + return self._unique_id + + @property + def icon(self) -> str: + return 'mdi:radiobox-marked' if self.is_on else 'mdi:radiobox-blank' + + @property + def available(self) -> bool: + return self._is_available + + @property + def is_on(self) -> bool: + return self._is_on + + @property + def extra_state_attributes(self) -> dict: + return self._extra + + @property + def should_poll(self) -> bool: + return False + + async def async_added_to_hass(self) -> None: + self.unsub_update = async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @callback + def _schedule_immediate_update(self) -> None: + self.async_schedule_update_ha_state(True) + + async def will_remove_from_hass(self) -> None: + if self.unsub_update: + self.unsub_update() + + self.unsub_update = None + + async def async_update(self) -> None: + self._is_available = self.service.is_available + self._is_on = self.service.is_on + self._extra = self.service.extra + + async def async_turn_on(self, **kwargs) -> None: + await self.service.update_state('wait-on', True) + await self.service.start() + + async def async_turn_off(self, **kwargs) -> None: + await self.service.update_state('wait-off', True) + await self.service.stop() diff --git a/custom_components/systemd_manager/translations/en.json b/custom_components/systemd_manager/translations/en.json new file mode 100644 index 0000000..765bac2 --- /dev/null +++ b/custom_components/systemd_manager/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "Systemd Manager", + "abort": { + "single_instance_allowed": "Only one configuration is allowed." + }, + "step": { + "user": { + "description": "Select the services you want to monitor", + "data": { + "services": "Services" + } + } + } + }, + "options": { + "abort": { + "updated": "Data updated, restart Home Assistant" + }, + "step": { + "settings": { + "description": "Select the services you want to monitor", + "data": { + "services": "Services", + "scan_interval": "Update interval in seconds [PRO]" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/systemd_manager/translations/ru.json b/custom_components/systemd_manager/translations/ru.json new file mode 100644 index 0000000..e247125 --- /dev/null +++ b/custom_components/systemd_manager/translations/ru.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "Systemd Manager", + "abort": { + "single_instance_allowed": "Допускается только одна конфигурация." + }, + "step": { + "user": { + "description": "Выберите службы которые требуется отслеживать", + "data": { + "services": "Службы" + } + } + } + }, + "options": { + "abort": { + "updated": "Данные обновлены, перезапустите Home Assistant" + }, + "step": { + "settings": { + "description": "Выберите службы которые требуется отслеживать", + "data": { + "services": "Службы", + "scan_interval": "Интервал обновления в секундах [PRO]" + } + } + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..475d4e2 --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Systemd Manager", + "render_readme": true +} diff --git a/table.png b/table.png new file mode 100644 index 0000000..73148d6 Binary files /dev/null and b/table.png differ