Browse Source

mqtt publish logind's PreparingForShutdown

https://github.com/fphammerle/systemctl-mqtt/pull/2
Fabian Peter Hammerle 3 years ago
parent
commit
9f346fb87c
12 changed files with 382 additions and 72 deletions
  1. 7 3
      .github/workflows/python.yml
  2. 8 0
      CHANGELOG.md
  3. 4 0
      Dockerfile
  4. 2 1
      Pipfile
  5. 16 4
      Pipfile.lock
  6. 1 1
      README.md
  7. 1 1
      setup.py
  8. 117 11
      systemctl_mqtt/__init__.py
  9. 64 2
      tests/test_dbus.py
  10. 86 21
      tests/test_mqtt.py
  11. 0 28
      tests/test_settings.py
  12. 76 0
      tests/test_state_dbus.py

+ 7 - 3
.github/workflows/python.yml

@@ -26,8 +26,10 @@ jobs:
         python-version: ${{ matrix.python-version }}
     - run: pip install --upgrade pipenv>=2018.10.9
     - run: sudo apt-get update
-    # TODO exclude dbus-python from pipenv install
-    - run: sudo apt-get install --yes --no-install-recommends libdbus-1-dev
+    # TODO exclude dbus-python & PyGObject from pipenv install
+    - run: sudo apt-get install --yes --no-install-recommends
+        libdbus-1-dev
+        libgirepository1.0-dev
     - run: pipenv install --python "$PYTHON_VERSION" --deploy --dev
       env:
         PYTHON_VERSION: ${{ matrix.python-version }}
@@ -50,7 +52,9 @@ jobs:
         python-version: ${{ matrix.python-version }}
     - run: pip install --upgrade pipenv>=2018.10.9
     - run: sudo apt-get update
-    - run: sudo apt-get install --yes --no-install-recommends libdbus-1-dev
+    - run: sudo apt-get install --yes --no-install-recommends
+        libdbus-1-dev
+        libgirepository1.0-dev
     - run: pipenv install --python "$PYTHON_VERSION" --deploy --dev
       env:
         PYTHON_VERSION: ${{ matrix.python-version }}

+ 8 - 0
CHANGELOG.md

@@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+### Added
+- forward logind's [PreparingForShutdown](https://www.freedesktop.org/wiki/Software/systemd/inhibit/)
+  to `systemctl/hostname/preparing-for-shutdown`
+- log shutdown [inhibitor locks](https://www.freedesktop.org/wiki/Software/systemd/inhibit/)
+
+### Fixed
+- explicit timestamp type specification to avoid
+  `OverflowError: Python int too large to convert to C long`
 
 ## [0.1.1] - 2020-06-18
 ### Fixed

+ 4 - 0
Dockerfile

@@ -7,10 +7,12 @@ ARG SOURCE_DIR_PATH=/systemctl-mqtt
 FROM $BASE_IMAGE as build
 
 RUN apk add --no-cache \
+        cairo-dev `# PyGObject > pycairo` \
         dbus-dev \
         gcc \
         git `# setuptools_scm` \
         glib-dev `# dbus-python` \
+        gobject-introspection-dev `# PyGObject` \
         make `# dbus-python` \
         musl-dev `# dbus-python` \
         py3-certifi `# pipenv` \
@@ -43,6 +45,8 @@ FROM $BASE_IMAGE
 RUN apk add --no-cache \
         ca-certificates \
         dbus-libs \
+        glib `# PyGObject` \
+        gobject-introspection `# PyGObject` \
         python3 \
         tini \
     && find / -xdev -type f -perm /u+s -exec chmod -c u-s {} \; \

+ 2 - 1
Pipfile

@@ -12,7 +12,8 @@ systemctl-mqtt = {editable = true,path = "."}
 black = {version = "==19.10b0", markers = "python_version >= '3.6'"}
 mypy = "*"
 pylint = "*"
-pylint-import-requirements = "*"
+# >=2.0.3 to skip PyGObject's custom loader & fix broken wheel
+pylint-import-requirements = ">=2.0.3"
 pytest = "*"
 pytest-cov = "*"
 

+ 16 - 4
Pipfile.lock

@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "57f72dc7c2b137c416cb0486c2752a3f1261c1d5893b94d875757bc058b511f8"
+            "sha256": "48654ce6218ea119cd05b10d6cc14ff27e306885deba781d3265fb0b38d4ad4c"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -28,6 +28,18 @@
             ],
             "version": "==1.5.0"
         },
+        "pycairo": {
+            "hashes": [
+                "sha256:2c143183280feb67f5beb4e543fd49990c28e7df427301ede04fc550d3562e84"
+            ],
+            "version": "==1.19.1"
+        },
+        "pygobject": {
+            "hashes": [
+                "sha256:012a589aec687bfa809a1ff9f5cd775dc7f6fcec1a6bc7fe88e1002a68f8ba34"
+            ],
+            "version": "==3.36.1"
+        },
         "systemctl-mqtt": {
             "editable": true,
             "path": "."
@@ -236,11 +248,11 @@
         },
         "pylint-import-requirements": {
             "hashes": [
-                "sha256:074ac465449401a174388d0a86e1120b54a92d170705da92f28fb138ba65a741",
-                "sha256:1d73bc745ffbe419082322f52d594ffa182ad64a7b6e1822e3a3c3e585eff66e"
+                "sha256:bb6056cf68baa25639e59510e5f1deef74d76c684498706675c8c7b6690a63c8",
+                "sha256:d34f4971699487b61c7ebc64aefbf3a33d8d134101eb8bcb9c1d578631fd155f"
             ],
             "index": "pypi",
-            "version": "==2.0.1"
+            "version": "==2.0.3"
         },
         "pyparsing": {
             "hashes": [

+ 1 - 1
README.md

@@ -17,7 +17,7 @@ $ systemctl-mqtt --mqtt-host HOSTNAME_OR_IP_ADDRESS
 
 On debian-based systems, dependencies can optionally be installed via:
 ```sh
-$ sudo apt-get install --no-install-recommends python3-dbus python3-paho-mqtt
+$ sudo apt-get install --no-install-recommends python3-dbus python3-gi python3-paho-mqtt
 ```
 
 Schedule poweroff by sending a MQTT message to topic `systemctl/hostname/poweroff`.

+ 1 - 1
setup.py

@@ -62,7 +62,7 @@ setuptools.setup(
     ],
     entry_points={"console_scripts": ["systemctl-mqtt = systemctl_mqtt:_main",]},
     # https://dbus.freedesktop.org/doc/dbus-python/news.html
-    install_requires=["dbus-python<2", "paho-mqtt<2"],
+    install_requires=["PyGObject<4", "dbus-python<2", "paho-mqtt<2"],
     setup_requires=["setuptools_scm"],
     tests_require=["pytest"],
 )

+ 117 - 11
systemctl_mqtt/__init__.py

@@ -18,13 +18,21 @@
 import argparse
 import datetime
 import functools
+import json
 import logging
+import os
 import pathlib
 import socket
+import threading
 import typing
 
 import dbus
+import dbus.mainloop.glib
+import dbus.types
 
+# black keeps inserting a blank line above
+# https://pygobject.readthedocs.io/en/latest/getting_started.html#ubuntu-logo-ubuntu-debian-logo-debian
+import gi.repository.GLib  # pylint-import-requirements: imports=PyGObject
 import paho.mqtt.client
 
 _LOGGER = logging.getLogger(__name__)
@@ -42,6 +50,32 @@ def _get_login_manager() -> dbus.proxies.Interface:
     return dbus.Interface(object=proxy, dbus_interface="org.freedesktop.login1.Manager")
 
 
+def _log_shutdown_inhibitors(login_manager: dbus.proxies.Interface) -> None:
+    if _LOGGER.getEffectiveLevel() > logging.DEBUG:
+        return
+    found_inhibitor = False
+    try:
+        # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
+        for what, who, why, mode, uid, pid in login_manager.ListInhibitors():
+            if "shutdown" in what:
+                found_inhibitor = True
+                _LOGGER.debug(
+                    "detected shutdown inhibitor %s (pid=%u, uid=%u, mode=%s): %s",
+                    who,
+                    pid,
+                    uid,
+                    mode,
+                    why,
+                )
+    except dbus.DBusException as exc:
+        _LOGGER.warning(
+            "failed to fetch shutdown inhibitors: %s", exc.get_dbus_message()
+        )
+        return
+    if not found_inhibitor:
+        _LOGGER.debug("no shutdown inhibitor locks found")
+
+
 def _schedule_shutdown(action: str) -> None:
     # https://github.com/systemd/systemd/blob/v237/src/systemctl/systemctl.c#L8553
     assert action in ["poweroff", "reboot"], action
@@ -51,7 +85,9 @@ def _schedule_shutdown(action: str) -> None:
     _LOGGER.info(
         "scheduling %s for %s", action, shutdown_datetime.strftime("%Y-%m-%d %H:%M:%S"),
     )
-    shutdown_epoch_usec = int(shutdown_datetime.timestamp() * 10 ** 6)
+    # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html?highlight=signature#basic-types
+    shutdown_epoch_usec = dbus.UInt64(shutdown_datetime.timestamp() * 10 ** 6)
+    login_manager = _get_login_manager()
     try:
         # $ gdbus introspect --system --dest org.freedesktop.login1 \
         #       --object-path /org/freedesktop/login1 | grep -A 1 ScheduleShutdown
@@ -65,7 +101,7 @@ def _schedule_shutdown(action: str) -> None:
         #       /org/freedesktop/login1 \
         #       org.freedesktop.login1.Manager.ScheduleShutdown \
         #       string:poweroff "uint64:$(date --date=10min +%s)000000"
-        _get_login_manager().ScheduleShutdown(action, shutdown_epoch_usec)
+        login_manager.ScheduleShutdown(action, shutdown_epoch_usec)
     except dbus.DBusException as exc:
         exc_msg = exc.get_dbus_message()
         if "authentication required" in exc_msg.lower():
@@ -75,19 +111,74 @@ def _schedule_shutdown(action: str) -> None:
             )
         else:
             _LOGGER.error("failed to schedule %s: %s", action, exc_msg)
+    _log_shutdown_inhibitors(login_manager)
 
 
-class _Settings:
-
-    # pylint: disable=too-few-public-methods
-
+class _State:
     def __init__(self, mqtt_topic_prefix: str) -> None:
         self._mqtt_topic_prefix = mqtt_topic_prefix
+        self._login_manager = _get_login_manager()  # type: dbus.proxies.Interface
+        self._shutdown_lock = None  # type: typing.Optional[dbus.types.UnixFd]
+        self._shutdown_lock_mutex = threading.Lock()
 
     @property
     def mqtt_topic_prefix(self) -> str:
         return self._mqtt_topic_prefix
 
+    def acquire_shutdown_lock(self) -> None:
+        with self._shutdown_lock_mutex:
+            assert self._shutdown_lock is None
+            # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
+            self._shutdown_lock = self._login_manager.Inhibit(
+                "shutdown", "systemctl-mqtt", "Report shutdown via MQTT", "delay",
+            )
+            _LOGGER.debug("acquired shutdown inhibitor lock")
+
+    def release_shutdown_lock(self) -> None:
+        with self._shutdown_lock_mutex:
+            if self._shutdown_lock:
+                # https://dbus.freedesktop.org/doc/dbus-python/dbus.types.html#dbus.types.UnixFd.take
+                os.close(self._shutdown_lock.take())
+                _LOGGER.debug("released shutdown inhibitor lock")
+                self._shutdown_lock = None
+
+    def _publish_preparing_for_shutdown(
+        self, mqtt_client: paho.mqtt.client.Client, active: bool
+    ) -> None:
+        # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1199
+        topic = self.mqtt_topic_prefix + "/preparing-for-shutdown"
+        payload = json.dumps(active)
+        _LOGGER.info("publishing %r on %s", payload, topic)
+        msg_info = mqtt_client.publish(
+            topic=topic, payload=payload,
+        )  # type: paho.mqtt.client.MQTTMessageInfo
+        msg_info.wait_for_publish()
+        if msg_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
+            _LOGGER.error(
+                "failed to publish on %s (return code %d)", topic, msg_info.rc
+            )
+
+    def _prepare_for_shutdown_handler(
+        self, active: dbus.Boolean, mqtt_client: paho.mqtt.client.Client
+    ) -> None:
+        assert isinstance(active, dbus.Boolean)
+        active = bool(active)
+        self._publish_preparing_for_shutdown(mqtt_client=mqtt_client, active=active)
+        if active:
+            self.release_shutdown_lock()
+        else:
+            self.acquire_shutdown_lock()
+
+    def register_prepare_for_shutdown_handler(
+        self, mqtt_client: paho.mqtt.client.Client
+    ) -> None:
+        self._login_manager.connect_to_signal(
+            signal_name="PrepareForShutdown",
+            handler_function=functools.partial(
+                self._prepare_for_shutdown_handler, mqtt_client=mqtt_client
+            ),
+        )
+
 
 class _MQTTAction:
 
@@ -100,7 +191,7 @@ class _MQTTAction:
     def mqtt_message_callback(
         self,
         mqtt_client: paho.mqtt.client.Client,
-        settings: _Settings,
+        state: _State,
         message: paho.mqtt.client.MQTTMessage,
     ) -> None:
         # pylint: disable=unused-argument; callback
@@ -124,7 +215,7 @@ _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
 
 def _mqtt_on_connect(
     mqtt_client: paho.mqtt.client.Client,
-    settings: _Settings,
+    state: _State,
     flags: typing.Dict,
     return_code: int,
 ) -> None:
@@ -133,8 +224,10 @@ def _mqtt_on_connect(
     assert return_code == 0, return_code  # connection accepted
     mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
     _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
+    state.acquire_shutdown_lock()
+    state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client)
     for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
-        topic = settings.mqtt_topic_prefix + "/" + topic_suffix
+        topic = state.mqtt_topic_prefix + "/" + topic_suffix
         _LOGGER.info("subscribing to %s", topic)
         mqtt_client.subscribe(topic)
         mqtt_client.message_callback_add(
@@ -152,9 +245,11 @@ def _run(
     mqtt_password: typing.Optional[str],
     mqtt_topic_prefix: str,
 ) -> None:
+    # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html#setting-up-an-event-loop
+    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
     # https://pypi.org/project/paho-mqtt/
     mqtt_client = paho.mqtt.client.Client(
-        userdata=_Settings(mqtt_topic_prefix=mqtt_topic_prefix)
+        userdata=_State(mqtt_topic_prefix=mqtt_topic_prefix)
     )
     mqtt_client.on_connect = _mqtt_on_connect
     mqtt_client.tls_set(ca_certs=None)  # enable tls trusting default system certs
@@ -166,7 +261,18 @@ def _run(
     elif mqtt_password:
         raise ValueError("Missing MQTT username")
     mqtt_client.connect(host=mqtt_host, port=mqtt_port)
-    mqtt_client.loop_forever()
+    # loop_start runs loop_forever in a new thread (daemon)
+    # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1814
+    # loop_forever attempts to reconnect if disconnected
+    # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1744
+    mqtt_client.loop_start()
+    try:
+        gi.repository.GLib.MainLoop().run()
+    finally:
+        # blocks until loop_forever stops
+        _LOGGER.debug("waiting for MQTT loop to stop")
+        mqtt_client.loop_stop()
+        _LOGGER.debug("MQTT loop stopped")
 
 
 def _get_hostname() -> str:

+ 64 - 2
tests/test_dbus.py

@@ -37,6 +37,66 @@ def test__get_login_manager():
     assert isinstance(login_manager.CanPowerOff(), dbus.String)
 
 
+def test__log_shutdown_inhibitors_some(caplog):
+    login_manager = unittest.mock.MagicMock()
+    login_manager.ListInhibitors.return_value = dbus.Array(
+        [
+            dbus.Struct(
+                (
+                    dbus.String("shutdown:sleep"),
+                    dbus.String("Developer"),
+                    dbus.String("Haven't pushed my commits yet"),
+                    dbus.String("delay"),
+                    dbus.UInt32(1000),
+                    dbus.UInt32(1234),
+                ),
+                signature=None,
+            ),
+            dbus.Struct(
+                (
+                    dbus.String("shutdown"),
+                    dbus.String("Editor"),
+                    dbus.String(""),
+                    dbus.String("Unsafed files open"),
+                    dbus.UInt32(0),
+                    dbus.UInt32(42),
+                ),
+                signature=None,
+            ),
+        ],
+        signature=dbus.Signature("(ssssuu)"),
+    )
+    with caplog.at_level(logging.DEBUG):
+        systemctl_mqtt._log_shutdown_inhibitors(login_manager)
+    assert len(caplog.records) == 2
+    assert caplog.records[0].levelno == logging.DEBUG
+    assert (
+        caplog.records[0].message
+        == "detected shutdown inhibitor Developer (pid=1234, uid=1000, mode=delay): "
+        + "Haven't pushed my commits yet"
+    )
+
+
+def test__log_shutdown_inhibitors_none(caplog):
+    login_manager = unittest.mock.MagicMock()
+    login_manager.ListInhibitors.return_value = dbus.Array([])
+    with caplog.at_level(logging.DEBUG):
+        systemctl_mqtt._log_shutdown_inhibitors(login_manager)
+    assert len(caplog.records) == 1
+    assert caplog.records[0].levelno == logging.DEBUG
+    assert caplog.records[0].message == "no shutdown inhibitor locks found"
+
+
+def test__log_shutdown_inhibitors_fail(caplog):
+    login_manager = unittest.mock.MagicMock()
+    login_manager.ListInhibitors.side_effect = dbus.DBusException("mocked")
+    with caplog.at_level(logging.DEBUG):
+        systemctl_mqtt._log_shutdown_inhibitors(login_manager)
+    assert len(caplog.records) == 1
+    assert caplog.records[0].levelno == logging.WARNING
+    assert caplog.records[0].message == "failed to fetch shutdown inhibitors: mocked"
+
+
 @pytest.mark.parametrize("action", ["poweroff", "reboot"])
 def test__schedule_shutdown(action):
     login_manager_mock = unittest.mock.MagicMock()
@@ -48,12 +108,13 @@ def test__schedule_shutdown(action):
     schedule_args, schedule_kwargs = login_manager_mock.ScheduleShutdown.call_args
     assert len(schedule_args) == 2
     assert schedule_args[0] == action
+    assert isinstance(schedule_args[1], dbus.UInt64)
     shutdown_datetime = datetime.datetime.fromtimestamp(
         schedule_args[1] / 10 ** 6, tz=_UTC,
     )
     delay = shutdown_datetime - datetime.datetime.now(tz=_UTC)
     assert delay.total_seconds() == pytest.approx(
-        datetime.timedelta(seconds=4).total_seconds(), abs=0.1,
+        systemctl_mqtt._SHUTDOWN_DELAY.total_seconds(), abs=0.1,
     )
     assert not schedule_kwargs
 
@@ -79,13 +140,14 @@ def test__schedule_shutdown_fail(caplog, action, exception_message, log_message)
     ), caplog.at_level(logging.DEBUG):
         systemctl_mqtt._schedule_shutdown(action=action)
     assert login_manager_mock.ScheduleShutdown.call_count == 1
-    assert len(caplog.records) == 2
+    assert len(caplog.records) == 3
     assert caplog.records[0].levelno == logging.INFO
     assert caplog.records[0].message.startswith("scheduling {} for ".format(action))
     assert caplog.records[1].levelno == logging.ERROR
     assert caplog.records[1].message == "failed to schedule {}: {}".format(
         action, log_message
     )
+    assert "inhibitor" in caplog.records[2].message
 
 
 @pytest.mark.parametrize(

+ 86 - 21
tests/test_mqtt.py

@@ -16,8 +16,11 @@
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
 import logging
+import threading
+import time
 import unittest.mock
 
+import paho.mqtt.client
 import pytest
 from paho.mqtt.client import MQTTMessage
 
@@ -37,7 +40,11 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
         "ssl.SSLContext.wrap_socket", autospec=True,
     ) as ssl_wrap_socket_mock, unittest.mock.patch(
         "paho.mqtt.client.Client.loop_forever", autospec=True,
-    ) as mqtt_loop_forever_mock:
+    ) as mqtt_loop_forever_mock, unittest.mock.patch(
+        "gi.repository.GLib.MainLoop.run"
+    ) as glib_loop_mock, unittest.mock.patch(
+        "systemctl_mqtt._get_login_manager"
+    ):
         ssl_wrap_socket_mock.return_value.send = len
         systemctl_mqtt._run(
             mqtt_host=mqtt_host,
@@ -60,6 +67,8 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
     assert ssl_context.check_hostname is True
     assert ssl_wrap_socket_mock.call_args[1]["server_hostname"] == mqtt_host
     # loop started?
+    while threading.active_count() > 1:
+        time.sleep(0.01)
     assert mqtt_loop_forever_mock.call_count == 1
     (mqtt_client,) = mqtt_loop_forever_mock.call_args[0]
     assert mqtt_client._tls_insecure is False
@@ -73,6 +82,11 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
         "paho.mqtt.client.Client.subscribe"
     ) as mqtt_subscribe_mock:
         mqtt_client.on_connect(mqtt_client, mqtt_client._userdata, {}, 0)
+    state = mqtt_client._userdata
+    assert (
+        state._login_manager.connect_to_signal.call_args[1]["signal_name"]
+        == "PrepareForShutdown"
+    )
     mqtt_subscribe_mock.assert_called_once_with(mqtt_topic_prefix + "/poweroff")
     assert mqtt_client.on_message is None
     assert (  # pylint: disable=comparison-with-callable
@@ -85,30 +99,23 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
     assert caplog.records[0].message == "connected to MQTT broker {}:{}".format(
         mqtt_host, mqtt_port
     )
-    assert caplog.records[1].levelno == logging.INFO
-    assert caplog.records[1].message == "subscribing to {}".format(
+    assert caplog.records[1].levelno == logging.DEBUG
+    assert caplog.records[1].message == "acquired shutdown inhibitor lock"
+    assert caplog.records[2].levelno == logging.INFO
+    assert caplog.records[2].message == "subscribing to {}".format(
         mqtt_topic_prefix + "/poweroff"
     )
-    assert caplog.records[2].levelno == logging.DEBUG
-    assert caplog.records[2].message == "registered MQTT callback for topic {}".format(
+    assert caplog.records[3].levelno == logging.DEBUG
+    assert caplog.records[3].message == "registered MQTT callback for topic {}".format(
         mqtt_topic_prefix + "/poweroff"
     ) + " triggering {}".format(
         systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"].action
     )
-    # message callback
-    caplog.clear()
-    poweroff_message = MQTTMessage(topic=mqtt_topic_prefix.encode() + b"/poweroff")
-    with unittest.mock.patch.object(
-        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"], "action",
-    ) as poweroff_action_mock:
-        mqtt_client._handle_on_message(poweroff_message)
-    poweroff_action_mock.assert_called_once_with()
-    assert all(r.levelno == logging.DEBUG for r in caplog.records)
-    assert caplog.records[0].message == "received topic={} payload=b''".format(
-        poweroff_message.topic
-    )
-    assert caplog.records[1].message.startswith("executing action poweroff")
-    assert caplog.records[2].message.startswith("completed action poweroff")
+    # dbus loop started?
+    glib_loop_mock.assert_called_once_with()
+    # waited for mqtt loop to stop?
+    assert mqtt_client._thread_terminate
+    assert mqtt_client._thread is None
 
 
 @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
@@ -123,7 +130,11 @@ def test__run_authentication(
         "ssl.SSLContext.wrap_socket"
     ) as ssl_wrap_socket_mock, unittest.mock.patch(
         "paho.mqtt.client.Client.loop_forever", autospec=True,
-    ) as mqtt_loop_forever_mock:
+    ) as mqtt_loop_forever_mock, unittest.mock.patch(
+        "gi.repository.GLib.MainLoop.run"
+    ), unittest.mock.patch(
+        "systemctl_mqtt._get_login_manager"
+    ):
         ssl_wrap_socket_mock.return_value.send = len
         systemctl_mqtt._run(
             mqtt_host=mqtt_host,
@@ -141,11 +152,65 @@ def test__run_authentication(
         assert mqtt_client._password is None
 
 
+def _initialize_mqtt_client(
+    mqtt_host, mqtt_port, mqtt_topic_prefix
+) -> paho.mqtt.client.Client:
+    with unittest.mock.patch("socket.create_connection"), unittest.mock.patch(
+        "ssl.SSLContext.wrap_socket",
+    ) as ssl_wrap_socket_mock, unittest.mock.patch(
+        "paho.mqtt.client.Client.loop_forever", autospec=True,
+    ) as mqtt_loop_forever_mock, unittest.mock.patch(
+        "gi.repository.GLib.MainLoop.run"
+    ), unittest.mock.patch(
+        "systemctl_mqtt._get_login_manager"
+    ):
+        ssl_wrap_socket_mock.return_value.send = len
+        systemctl_mqtt._run(
+            mqtt_host=mqtt_host,
+            mqtt_port=mqtt_port,
+            mqtt_username=None,
+            mqtt_password=None,
+            mqtt_topic_prefix=mqtt_topic_prefix,
+        )
+    while threading.active_count() > 1:
+        time.sleep(0.01)
+    assert mqtt_loop_forever_mock.call_count == 1
+    (mqtt_client,) = mqtt_loop_forever_mock.call_args[0]
+    mqtt_client.socket().getpeername.return_value = (mqtt_host, mqtt_port)
+    mqtt_client.on_connect(mqtt_client, mqtt_client._userdata, {}, 0)
+    return mqtt_client
+
+
+@pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
+@pytest.mark.parametrize("mqtt_port", [1833])
+@pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
+def test__client_handle_message(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
+    mqtt_client = _initialize_mqtt_client(
+        mqtt_host=mqtt_host, mqtt_port=mqtt_port, mqtt_topic_prefix=mqtt_topic_prefix
+    )
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+    poweroff_message = MQTTMessage(topic=mqtt_topic_prefix.encode() + b"/poweroff")
+    with unittest.mock.patch.object(
+        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"], "action",
+    ) as poweroff_action_mock:
+        mqtt_client._handle_on_message(poweroff_message)
+    poweroff_action_mock.assert_called_once_with()
+    assert all(r.levelno == logging.DEBUG for r in caplog.records)
+    assert caplog.records[0].message == "received topic={} payload=b''".format(
+        poweroff_message.topic
+    )
+    assert caplog.records[1].message.startswith("executing action poweroff")
+    assert caplog.records[2].message.startswith("completed action poweroff")
+
+
 @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
 @pytest.mark.parametrize("mqtt_port", [1833])
 @pytest.mark.parametrize("mqtt_password", ["secret"])
 def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_password):
-    with unittest.mock.patch("paho.mqtt.client.Client"):
+    with unittest.mock.patch("paho.mqtt.client.Client"), unittest.mock.patch(
+        "systemctl_mqtt._get_login_manager"
+    ):
         with pytest.raises(ValueError):
             systemctl_mqtt._run(
                 mqtt_host=mqtt_host,

+ 0 - 28
tests/test_settings.py

@@ -1,28 +0,0 @@
-# systemctl-mqtt - MQTT client triggering shutdown on systemd-based systems
-#
-# Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <https://www.gnu.org/licenses/>.
-
-import pytest
-
-import systemctl_mqtt
-
-# pylint: disable=protected-access
-
-
-@pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
-def test_mqtt_topic_action_mapping(mqtt_topic_prefix):
-    settings = systemctl_mqtt._Settings(mqtt_topic_prefix=mqtt_topic_prefix)
-    assert settings.mqtt_topic_prefix == mqtt_topic_prefix

+ 76 - 0
tests/test_state_dbus.py

@@ -0,0 +1,76 @@
+# systemctl-mqtt - MQTT client triggering shutdown on systemd-based systems
+#
+# Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import logging
+import unittest.mock
+
+import dbus.types
+import pytest
+
+import systemctl_mqtt
+
+# pylint: disable=protected-access
+
+
+def test_shutdown_lock():
+    lock_fd = unittest.mock.MagicMock()
+    with unittest.mock.patch("systemctl_mqtt._get_login_manager"):
+        state = systemctl_mqtt._State(mqtt_topic_prefix="any")
+        state._login_manager.Inhibit.return_value = lock_fd
+        state.acquire_shutdown_lock()
+    state._login_manager.Inhibit.assert_called_once_with(
+        "shutdown", "systemctl-mqtt", "Report shutdown via MQTT", "delay",
+    )
+    assert state._shutdown_lock == lock_fd
+    # https://dbus.freedesktop.org/doc/dbus-python/dbus.types.html#dbus.types.UnixFd.take
+    lock_fd.take.return_value = "fdnum"
+    with unittest.mock.patch("os.close") as close_mock:
+        state.release_shutdown_lock()
+    close_mock.assert_called_once_with("fdnum")
+
+
+@pytest.mark.parametrize("active", [True, False])
+def test_prepare_for_shutdown_handler(caplog, active):
+    with unittest.mock.patch("systemctl_mqtt._get_login_manager"):
+        state = systemctl_mqtt._State(mqtt_topic_prefix="any")
+    mqtt_client_mock = unittest.mock.MagicMock()
+    state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client_mock)
+    # pylint: disable=no-member,comparison-with-callable
+    connect_to_signal_kwargs = state._login_manager.connect_to_signal.call_args[1]
+    assert connect_to_signal_kwargs["signal_name"] == "PrepareForShutdown"
+    handler_function = connect_to_signal_kwargs["handler_function"]
+    assert handler_function.func == state._prepare_for_shutdown_handler
+    with unittest.mock.patch.object(
+        state, "acquire_shutdown_lock"
+    ) as acquire_lock_mock, unittest.mock.patch.object(
+        state, "release_shutdown_lock"
+    ) as release_lock_mock:
+        handler_function(dbus.types.Boolean(active))
+    if active:
+        acquire_lock_mock.assert_not_called()
+        release_lock_mock.assert_called_once_with()
+    else:
+        acquire_lock_mock.assert_called_once_with()
+        release_lock_mock.assert_not_called()
+    mqtt_client_mock.publish.assert_called_once_with(
+        topic="any/preparing-for-shutdown", payload="true" if active else "false",
+    )
+    assert len(caplog.records) == 1
+    assert caplog.records[0].levelno == logging.ERROR
+    assert caplog.records[0].message.startswith(
+        "failed to publish on any/preparing-for-shutdown"
+    )