Browse Source

migrate from dbus-python to pure-python jeepney

fixes https://github.com/fphammerle/systemctl-mqtt/issues/39
Fabian Peter Hammerle 2 months ago
parent
commit
73513eabb0

+ 0 - 9
.github/workflows/python.yml

@@ -23,11 +23,6 @@ jobs:
       with:
         python-version: ${{ matrix.python-version }}
     - run: pip install --upgrade pipenv==2024.1.0
-    - run: sudo apt-get update
-    # 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 }}
@@ -57,10 +52,6 @@ jobs:
     # > [...]/coverage/inorout.py:507: CoverageWarning:
     # . Module  was never imported. (module-not-imported)
     - run: pip install --upgrade pipenv==2023.6.18
-    - run: sudo apt-get update
-    - run: sudo apt-get install --yes --no-install-recommends
-        libdbus-1-dev
-        libgirepository1.0-dev
     # by default pipenv picks the latest version in PATH
     - run: pipenv install --python "$PYTHON_VERSION" --deploy --dev
       env:

+ 6 - 0
CHANGELOG.md

@@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - declare compatibility with `python3.11`, `python3.12` & `python3.13`
 
 ### Changed
+- migrate from [dbus-python](https://gitlab.freedesktop.org/dbus/dbus-python/)
+  to pure-python [jeepney](https://gitlab.com/takluyver/jeepney)
+  (removes indirect dependency on libdbus, glib,
+  [PyGObject](https://gitlab.gnome.org/GNOME/pygobject) and
+  [pycairo](https://github.com/pygobject/pycairo),
+  fixes https://github.com/fphammerle/systemctl-mqtt/issues/39)
 - automatic discovery in home assistant:
   - replace component-based (topic:
     `<discovery_prefix>/binary_sensor/<node_id>/preparing-for-shutdown/config`)

+ 0 - 12
Dockerfile

@@ -10,20 +10,11 @@ ARG SOURCE_DIR_PATH=/systemctl-mqtt
 FROM $BASE_IMAGE AS build
 
 RUN apk add --no-cache \
-        cairo-dev `# PyGObject > pycairo` \
-        dbus `# dbus-run-session for dbus-python's build` \
-        dbus-dev \
-        gcc \
         git `# setuptools_scm` \
-        glib-dev `# dbus-python` \
-        gobject-introspection-dev `# PyGObject` \
         jq `# edit Pipfile.lock` \
-        make `# dbus-python` \
-        musl-dev `# dbus-python` \
         py3-certifi `# pipenv` \
         py3-pip `# pipenv install` \
         py3-virtualenv `# pipenv` \
-        python3-dev `# dbus-python` \
     && adduser -S build
 
 USER build
@@ -60,9 +51,6 @@ 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 {} \; \

+ 0 - 4
Pipfile

@@ -6,10 +6,6 @@ name = "pypi"
 [packages]
 systemctl-mqtt = {editable = true, path = "."}
 
-# > ImportError: [...]/python3.10/site-packages/gi/_gi.cpython-310-x86_64-linux-gnu.so:
-# . undefined symbol: _PyUnicode_AsStringAndSize
-PyGObject = "!=3.30.5"
-
 [dev-packages]
 black = "*"
 mypy = "*"

+ 5 - 29
Pipfile.lock

@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "24dc95af12720c58346fe15f2cc5e62aa6ead522cd37bb203dbf125bddb8c89e"
+            "sha256": "006c2a13bf3537443cf16ece65026026f7f514bbfc1f984c5ef197cd4a03f68f"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -16,12 +16,13 @@
         ]
     },
     "default": {
-        "dbus-python": {
+        "jeepney": {
             "hashes": [
-                "sha256:ad67819308618b5069537be237f8e68ca1c7fcc95ee4a121fe6845b1418248f8"
+                "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806",
+                "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"
             ],
             "markers": "python_version >= '3.7'",
-            "version": "==1.3.2"
+            "version": "==0.8.0"
         },
         "paho-mqtt": {
             "hashes": [
@@ -29,31 +30,6 @@
             ],
             "version": "==1.6.1"
         },
-        "pycairo": {
-            "hashes": [
-                "sha256:01505c138a313df2469f812405963532fc2511fb9bca9bdc8e0ab94c55d1ced8",
-                "sha256:03bf570e3919901572987bc69237b648fe0de242439980be3e606b396e3318c9",
-                "sha256:1b1321652a6e27c4de3069709b1cae22aed2707fd8c5e889c04a95669228af2a",
-                "sha256:27cb4d3a80e3b9990af552818515a8e466e0317063a6e61585533f1a86f1b7d5",
-                "sha256:5cb21e7a00a2afcafea7f14390235be33497a2cce53a98a19389492a60628430",
-                "sha256:9a9b79f92a434dae65c34c830bb9abdbd92654195e73d52663cbe45af1ad14b2",
-                "sha256:b0349d744c068b6644ae23da6ada111c8a8a7e323b56cbce3707cba5bdb474cc",
-                "sha256:d40a6d80b15dacb3672dc454df4bc4ab3988c6b3f36353b24a255dc59a1c8aea",
-                "sha256:e20f431244634cf244ab6b4c3a2e540e65746eed1324573cf291981c3e65fc05",
-                "sha256:e2239b9bb6c05edae5f3be97128e85147a155465e644f4d98ea0ceac7afc04ee",
-                "sha256:f9ca8430751f1fdcd3f072377560c9e15608b9a42d61375469db853566993c9b"
-            ],
-            "markers": "python_version >= '3.9'",
-            "version": "==1.27.0"
-        },
-        "pygobject": {
-            "hashes": [
-                "sha256:4500ad3dbf331773d8dedf7212544c999a76fc96b63a91b3dcac1e5925a1d103"
-            ],
-            "index": "pypi",
-            "markers": "python_version >= '3.9' and python_version < '4.0'",
-            "version": "==3.50.0"
-        },
         "systemctl-mqtt": {
             "editable": true,
             "path": "."

+ 1 - 1
README.md

@@ -18,7 +18,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-gi python3-paho-mqtt
+$ sudo apt-get install --no-install-recommends python3-jeepney python3-paho-mqtt
 ```
 
 ## Usage

+ 0 - 4
docker-apparmor-profile

@@ -25,10 +25,6 @@ profile systemctl-mqtt flags=(attach_disconnected) {
   # https://jlk.fjfi.cvut.cz/arch/manpages/man/apparmor.d.5#Access_Modes
   /systemctl-mqtt/ r,
   /systemctl-mqtt/** r,
-  /systemctl-mqtt/.venv/lib/python3.12/site-packages/_dbus_bindings.cpython-312-*-linux-musl.so m,
-  /systemctl-mqtt/.venv/lib/python3.12/site-packages/_dbus_glib_bindings.cpython-312-*-linux-musl.so m,
-  /systemctl-mqtt/.venv/lib/python3.12/site-packages/gi/_gi.cpython-312-*-linux-musl.so m,
-  /systemctl-mqtt/.venv/lib/python3.12/site-packages/gi/_gi_cairo.cpython-312-*-linux-musl.so m,
   # https://presentations.nordisch.org/apparmor/#/25
   /systemctl-mqtt/.venv/bin/systemctl-mqtt rix,
   /etc/** r,

+ 11 - 2
setup.py

@@ -65,8 +65,17 @@ setuptools.setup(
     # >=3.6 variable type hints, f-strings & * to force keyword-only arguments
     # >=3.8 importlib.metadata
     python_requires=">=3.9",  # <3.9 untested
-    # https://dbus.freedesktop.org/doc/dbus-python/news.html
-    install_requires=["PyGObject<4", "dbus-python<2", "paho-mqtt<2"],
+    # > Currently, the only main loop supported by dbus-python is GLib.
+    # https://web.archive.org/web/20241228081405/https://dbus.freedesktop.org/doc/dbus-python/tutorial.html#setting-up-an-event-loop
+    # PyGObject depends on pycairo
+    # > When pip-installing systemctl-mqtt on a system without graphics it
+    # > fails as pycairo fails building.
+    # https://web.archive.org/web/20241228083145/https://github.com/fphammerle/systemctl-mqtt/issues/39
+    # > Jeepney is a pure Python D-Bus module. It consists of an IO-free core
+    # > implementing the protocol, and integrations for both blocking I/O and
+    # > for different asynchronous frameworks.
+    # https://web.archive.org/web/20241206000411/https://www.freedesktop.org/wiki/Software/DBusBindings/
+    install_requires=["jeepney>=0.8,<0.9", "paho-mqtt<2"],
     setup_requires=["setuptools_scm"],
     tests_require=["pytest"],
 )

+ 46 - 51
systemctl_mqtt/__init__.py

@@ -28,12 +28,9 @@ import socket
 import threading
 import typing
 
-import dbus
-import dbus.mainloop.glib
-
-# 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 jeepney
+import jeepney.bus_messages
+import jeepney.io.blocking
 import paho.mqtt.client
 
 import systemctl_mqtt._dbus
@@ -58,10 +55,8 @@ class _State:
         self._mqtt_topic_prefix = mqtt_topic_prefix
         self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
         self._homeassistant_discovery_object_id = homeassistant_discovery_object_id
-        self._login_manager: dbus.proxies.Interface = (
-            systemctl_mqtt._dbus.get_login_manager()
-        )
-        self._shutdown_lock: typing.Optional[dbus.types.UnixFd] = None
+        self._login_manager = systemctl_mqtt._dbus.get_login_manager_proxy()
+        self._shutdown_lock: typing.Optional[jeepney.fds.FileDescriptor] = None
         self._shutdown_lock_mutex = threading.Lock()
         self.poweroff_delay = poweroff_delay
 
@@ -77,16 +72,21 @@ class _State:
         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"
+            (self._shutdown_lock,) = self._login_manager.Inhibit(
+                what="shutdown",
+                who="systemctl-mqtt",
+                why="Report shutdown via MQTT",
+                mode="delay",
             )
+            assert isinstance(
+                self._shutdown_lock, jeepney.fds.FileDescriptor
+            ), self._shutdown_lock
             _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())
+                self._shutdown_lock.close()
                 _LOGGER.debug("released shutdown inhibitor lock")
                 self._shutdown_lock = None
 
@@ -113,10 +113,9 @@ class _State:
                 "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
+    def preparing_for_shutdown_handler(
+        self, active: bool, 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, block=True
@@ -126,35 +125,21 @@ class _State:
         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
-            ),
-        )
-
     def publish_preparing_for_shutdown(
         self, mqtt_client: paho.mqtt.client.Client
     ) -> None:
         try:
-            active = self._login_manager.Get(
-                "org.freedesktop.login1.Manager",
-                "PreparingForShutdown",
-                dbus_interface="org.freedesktop.DBus.Properties",
-            )
-        except dbus.DBusException as exc:
+            ((return_type, active),) = self._login_manager.Get("PreparingForShutdown")
+        except jeepney.wrappers.DBusErrorResponse as exc:
             _LOGGER.error(
-                "failed to read logind's PreparingForShutdown property: %s",
-                exc.get_dbus_message(),
+                "failed to read logind's PreparingForShutdown property: %s", exc
             )
             return
-        assert isinstance(active, dbus.Boolean), active
+        assert return_type == "b", return_type
+        assert isinstance(active, bool), active
         self._publish_preparing_for_shutdown(
             mqtt_client=mqtt_client,
-            active=bool(active),
+            active=active,
             # https://github.com/eclipse/paho.mqtt.python/issues/439#issuecomment-565514393
             block=False,
         )
@@ -278,7 +263,6 @@ def _mqtt_on_connect(
     _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
     if not state.shutdown_lock_acquired:
         state.acquire_shutdown_lock()
-    state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client)
     state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
     state.publish_homeassistant_device_config(mqtt_client=mqtt_client)
     for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
@@ -293,7 +277,7 @@ def _mqtt_on_connect(
         )
 
 
-def _run(
+def _run(  # pylint: disable=too-many-arguments
     *,
     mqtt_host: str,
     mqtt_port: int,
@@ -305,18 +289,24 @@ def _run(
     poweroff_delay: datetime.timedelta,
     mqtt_disable_tls: bool = False,
 ) -> None:
-    # pylint: disable=too-many-arguments
-    # 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=_State(
-            mqtt_topic_prefix=mqtt_topic_prefix,
-            homeassistant_discovery_prefix=homeassistant_discovery_prefix,
-            homeassistant_discovery_object_id=homeassistant_discovery_object_id,
-            poweroff_delay=poweroff_delay,
-        )
+    # pylint: disable=too-many-locals; will be split up when switching to async mqtt
+    dbus_connection = jeepney.io.blocking.open_dbus_connection(bus="SYSTEM")
+    bus_proxy = jeepney.io.blocking.Proxy(
+        msggen=jeepney.bus_messages.message_bus, connection=dbus_connection
+    )
+    preparing_for_shutdown_match_rule = (
+        # pylint: disable=protected-access
+        systemctl_mqtt._dbus.get_login_manager_signal_match_rule("PrepareForShutdown")
+    )
+    assert bus_proxy.AddMatch(preparing_for_shutdown_match_rule) == ()
+    state = _State(
+        mqtt_topic_prefix=mqtt_topic_prefix,
+        homeassistant_discovery_prefix=homeassistant_discovery_prefix,
+        homeassistant_discovery_object_id=homeassistant_discovery_object_id,
+        poweroff_delay=poweroff_delay,
     )
+    # https://pypi.org/project/paho-mqtt/
+    mqtt_client = paho.mqtt.client.Client(userdata=state)
     mqtt_client.on_connect = _mqtt_on_connect
     if not mqtt_disable_tls:
         mqtt_client.tls_set(ca_certs=None)  # enable tls trusting default system certs
@@ -337,7 +327,12 @@ def _run(
     # 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()
+        with dbus_connection.filter(preparing_for_shutdown_match_rule) as queue:
+            while True:
+                (preparing_for_sleep,) = dbus_connection.recv_until_filtered(queue).body
+                state.preparing_for_shutdown_handler(
+                    active=preparing_for_sleep, mqtt_client=mqtt_client
+                )
     finally:
         # blocks until loop_forever stops
         _LOGGER.debug("waiting for MQTT loop to stop")

+ 102 - 28
systemctl_mqtt/_dbus.py

@@ -18,28 +18,106 @@
 import datetime
 import logging
 
-import dbus
+import jeepney
+import jeepney.io.blocking
 
 _LOGGER = logging.getLogger(__name__)
 
+_LOGIN_MANAGER_OBJECT_PATH = "/org/freedesktop/login1"
+_LOGIN_MANAGER_INTERFACE = "org.freedesktop.login1.Manager"
 
-def get_login_manager() -> dbus.proxies.Interface:
-    # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html
-    bus = dbus.SystemBus()
-    proxy = bus.get_object(
-        bus_name="org.freedesktop.login1", object_path="/org/freedesktop/login1"
-    )  # type: dbus.proxies.ProxyObject
-    # https://freedesktop.org/wiki/Software/systemd/logind/
-    return dbus.Interface(object=proxy, dbus_interface="org.freedesktop.login1.Manager")
 
+def get_login_manager_signal_match_rule(member: str) -> jeepney.MatchRule:
+    return jeepney.MatchRule(
+        type="signal",
+        interface=_LOGIN_MANAGER_INTERFACE,
+        member=member,
+        path=_LOGIN_MANAGER_OBJECT_PATH,
+    )
+
+
+class LoginManager(jeepney.MessageGenerator):
+    """
+    https://freedesktop.org/wiki/Software/systemd/logind/
+
+    $ python3 -m jeepney.bindgen \
+        --bus unix:path=/var/run/dbus/system_bus_socket \
+        --name org.freedesktop.login1 --path /org/freedesktop/login1
+    """
+
+    interface = _LOGIN_MANAGER_INTERFACE
+
+    def __init__(self):
+        super().__init__(
+            object_path=_LOGIN_MANAGER_OBJECT_PATH, bus_name="org.freedesktop.login1"
+        )
+
+    # pylint: disable=invalid-name; inherited method names from Manager object
+
+    def ListInhibitors(self) -> jeepney.low_level.Message:
+        return jeepney.new_method_call(remote_obj=self, method="ListInhibitors")
+
+    def LockSessions(self) -> jeepney.low_level.Message:
+        return jeepney.new_method_call(remote_obj=self, method="LockSessions")
+
+    def CanPowerOff(self) -> jeepney.low_level.Message:
+        return jeepney.new_method_call(remote_obj=self, method="CanPowerOff")
 
-def _log_shutdown_inhibitors(login_manager: dbus.proxies.Interface) -> None:
+    def ScheduleShutdown(
+        self, *, action: str, time: datetime.datetime
+    ) -> jeepney.low_level.Message:
+        return jeepney.new_method_call(
+            remote_obj=self,
+            method="ScheduleShutdown",
+            signature="st",
+            body=(action, int(time.timestamp() * 1e6)),  # (type, usec)
+        )
+
+    def Inhibit(
+        self, *, what: str, who: str, why: str, mode: str
+    ) -> jeepney.low_level.Message:
+        return jeepney.new_method_call(
+            remote_obj=self,
+            method="Inhibit",
+            signature="ssss",
+            body=(what, who, why, mode),
+        )
+
+    def Get(self, property_name: str) -> jeepney.low_level.Message:
+        return jeepney.new_method_call(
+            remote_obj=jeepney.DBusAddress(
+                object_path=self.object_path,
+                bus_name=self.bus_name,
+                interface="org.freedesktop.DBus.Properties",
+            ),
+            method="Get",
+            signature="ss",
+            body=(self.interface, property_name),
+        )
+
+
+def get_login_manager_proxy() -> jeepney.io.blocking.Proxy:
+    # https://jeepney.readthedocs.io/en/latest/integrate.html
+    # https://gitlab.com/takluyver/jeepney/-/blob/master/examples/aio_notify.py
+    return jeepney.io.blocking.Proxy(
+        msggen=LoginManager(),
+        connection=jeepney.io.blocking.open_dbus_connection(
+            bus="SYSTEM",
+            # > dbus-broker[…]: Peer :1.… is being disconnected as it does not
+            # . support receiving file descriptors it requested.
+            enable_fds=True,
+        ),
+    )
+
+
+def _log_shutdown_inhibitors(login_manager_proxy: jeepney.io.blocking.Proxy) -> 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():
+        (inhibitors,) = login_manager_proxy.ListInhibitors()
+        for what, who, why, mode, uid, pid in inhibitors:
             if "shutdown" in what:
                 found_inhibitor = True
                 _LOGGER.debug(
@@ -50,10 +128,8 @@ def _log_shutdown_inhibitors(login_manager: dbus.proxies.Interface) -> None:
                     mode,
                     why,
                 )
-    except dbus.DBusException as exc:
-        _LOGGER.warning(
-            "failed to fetch shutdown inhibitors: %s", exc.get_dbus_message()
-        )
+    except jeepney.wrappers.DBusErrorResponse as exc:
+        _LOGGER.warning("failed to fetch shutdown inhibitors: %s", exc)
         return
     if not found_inhibitor:
         _LOGGER.debug("no shutdown inhibitor locks found")
@@ -62,15 +138,11 @@ def _log_shutdown_inhibitors(login_manager: dbus.proxies.Interface) -> None:
 def schedule_shutdown(*, action: str, delay: datetime.timedelta) -> None:
     # https://github.com/systemd/systemd/blob/v237/src/systemctl/systemctl.c#L8553
     assert action in ["poweroff", "reboot"], action
-    shutdown_datetime = datetime.datetime.now() + delay
+    time = datetime.datetime.now() + delay
     # datetime.datetime.isoformat(timespec=) not available in python3.5
     # https://github.com/python/cpython/blob/v3.5.9/Lib/datetime.py#L1552
-    _LOGGER.info(
-        "scheduling %s for %s", action, shutdown_datetime.strftime("%Y-%m-%d %H:%M:%S")
-    )
-    # 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()
+    _LOGGER.info("scheduling %s for %s", action, time.strftime("%Y-%m-%d %H:%M:%S"))
+    login_manager = get_login_manager_proxy()
     try:
         # $ gdbus introspect --system --dest org.freedesktop.login1 \
         #       --object-path /org/freedesktop/login1 | grep -A 1 ScheduleShutdown
@@ -84,16 +156,18 @@ def schedule_shutdown(*, action: str, delay: datetime.timedelta) -> None:
         #       /org/freedesktop/login1 \
         #       org.freedesktop.login1.Manager.ScheduleShutdown \
         #       string:poweroff "uint64:$(date --date=10min +%s)000000"
-        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():
+        login_manager.ScheduleShutdown(action=action, time=time)
+    except jeepney.wrappers.DBusErrorResponse as exc:
+        if (
+            exc.name == "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired"
+            and exc.data == ("Interactive authentication required.",)
+        ):
             _LOGGER.error(
                 "failed to schedule %s: unauthorized; missing polkit authorization rules?",
                 action,
             )
         else:
-            _LOGGER.error("failed to schedule %s: %s", action, exc_msg)
+            _LOGGER.error("failed to schedule %s: %s", action, exc)
     _log_shutdown_inhibitors(login_manager)
 
 
@@ -102,4 +176,4 @@ def lock_all_sessions() -> None:
     $ loginctl lock-sessions
     """
     _LOGGER.info("instruct all sessions to activate screen locks")
-    get_login_manager().LockSessions()
+    get_login_manager_proxy().LockSessions()

+ 98 - 0
tests/dbus/message-generators/test_login_manager.py

@@ -0,0 +1,98 @@
+# systemctl-mqtt - MQTT client triggering & reporting shutdown on systemd-based systems
+#
+# Copyright (C) 2024 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 contextlib
+import datetime
+import typing
+import unittest.mock
+
+import pytest
+from jeepney.low_level import HeaderFields, Message
+
+import systemctl_mqtt._dbus
+
+# pylint: disable=protected-access
+
+
+@contextlib.contextmanager
+def mock_open_dbus_connection() -> typing.Iterator[unittest.mock.MagicMock]:
+    with unittest.mock.patch("jeepney.io.blocking.open_dbus_connection") as mock:
+        yield mock.return_value
+
+
+@pytest.mark.parametrize(
+    ("member", "signature", "kwargs", "body"),
+    [
+        ("ListInhibitors", None, {}, ()),
+        ("LockSessions", None, {}, ()),
+        ("CanPowerOff", None, {}, ()),
+        (
+            "ScheduleShutdown",
+            "st",
+            {
+                "action": "poweroff",
+                "time": datetime.datetime(
+                    1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc
+                ),
+            },
+            ("poweroff", 0),
+        ),
+        (
+            "Inhibit",
+            "ssss",
+            {"what": "poweroff", "who": "me", "why": "fixing bugs", "mode": "block"},
+            ("poweroff", "me", "fixing bugs", "block"),
+        ),
+    ],
+)
+def test_method(
+    member: str,
+    signature: typing.Optional[str],
+    kwargs: typing.Dict[str, typing.Any],
+    body: typing.Tuple[typing.Any],
+) -> None:
+    with mock_open_dbus_connection() as dbus_connection_mock:
+        proxy = systemctl_mqtt._dbus.get_login_manager_proxy()
+    getattr(proxy, member)(**kwargs)
+    dbus_connection_mock.send_and_get_reply.assert_called_once()
+    message: Message = dbus_connection_mock.send_and_get_reply.call_args[0][0]
+    if signature:
+        assert message.header.fields.pop(HeaderFields.signature) == signature
+    assert message.header.fields == {
+        HeaderFields.path: "/org/freedesktop/login1",
+        HeaderFields.destination: "org.freedesktop.login1",
+        HeaderFields.interface: "org.freedesktop.login1.Manager",
+        HeaderFields.member: member,
+    }
+    assert message.body == body
+
+
+@pytest.mark.parametrize("property_name", ["HandlePowerKey", "Docked"])
+def test_get(property_name: str) -> None:
+    with mock_open_dbus_connection() as dbus_connection_mock:
+        proxy = systemctl_mqtt._dbus.get_login_manager_proxy()
+    proxy.Get(property_name=property_name)
+    dbus_connection_mock.send_and_get_reply.assert_called_once()
+    message: Message = dbus_connection_mock.send_and_get_reply.call_args[0][0]
+    assert message.header.fields == {
+        HeaderFields.path: "/org/freedesktop/login1",
+        HeaderFields.destination: "org.freedesktop.login1",
+        HeaderFields.interface: "org.freedesktop.DBus.Properties",
+        HeaderFields.member: "Get",
+        HeaderFields.signature: "ss",
+    }
+    assert message.body == ("org.freedesktop.login1.Manager", property_name)

+ 9 - 4
tests/test_action.py

@@ -14,6 +14,8 @@ import systemctl_mqtt
 def test_poweroff_trigger(delay):
     action = systemctl_mqtt._MQTTActionSchedulePoweroff()
     with unittest.mock.patch(
+        "systemctl_mqtt._dbus.get_login_manager_proxy"
+    ), unittest.mock.patch(
         "systemctl_mqtt._dbus.schedule_shutdown"
     ) as schedule_shutdown_mock:
         action.trigger(
@@ -34,7 +36,7 @@ def test_mqtt_topic_suffix_action_mapping_poweroff(topic_suffix, expected_action
     mqtt_action = systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING[topic_suffix]
     login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
     ):
         mqtt_action.trigger(
             state=systemctl_mqtt._State(
@@ -46,8 +48,11 @@ def test_mqtt_topic_suffix_action_mapping_poweroff(topic_suffix, expected_action
         )
     login_manager_mock.ScheduleShutdown.assert_called_once()
     schedule_args, schedule_kwargs = login_manager_mock.ScheduleShutdown.call_args
-    assert len(schedule_args) == 2
-    assert schedule_args[0] == expected_action_arg
+    assert not schedule_args
+    assert schedule_kwargs.pop("action") == expected_action_arg
+    assert abs(
+        datetime.datetime.now() - schedule_kwargs.pop("time")
+    ) < datetime.timedelta(seconds=2)
     assert not schedule_kwargs
 
 
@@ -55,7 +60,7 @@ def test_mqtt_topic_suffix_action_mapping_lock():
     mqtt_action = systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["lock-all-sessions"]
     login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
     ):
         mqtt_action.trigger(state="dummy")
     login_manager_mock.LockSessions.assert_called_once_with()

+ 105 - 49
tests/test_dbus.py

@@ -17,54 +17,41 @@
 
 import datetime
 import logging
+import typing
 import unittest.mock
 
-import dbus
+import jeepney
+import jeepney.low_level
+import jeepney.wrappers
 import pytest
 
 import systemctl_mqtt._dbus
 
-_UTC = datetime.timezone(offset=datetime.timedelta(seconds=0))
-
 # pylint: disable=protected-access
 
 
-def test_get_login_manager():
-    login_manager = systemctl_mqtt._dbus.get_login_manager()
-    assert isinstance(login_manager, dbus.proxies.Interface)
-    assert login_manager.dbus_interface == "org.freedesktop.login1.Manager"
+def test_get_login_manager_proxy():
+    login_manager = systemctl_mqtt._dbus.get_login_manager_proxy()
+    assert isinstance(login_manager, jeepney.io.blocking.Proxy)
+    assert login_manager._msggen.interface == "org.freedesktop.login1.Manager"
     # https://freedesktop.org/wiki/Software/systemd/logind/
-    assert isinstance(login_manager.CanPowerOff(), dbus.String)
+    assert login_manager.CanPowerOff() in {("yes",), ("challenge",)}
 
 
 def test__log_shutdown_inhibitors_some(caplog):
     login_manager = unittest.mock.MagicMock()
-    login_manager.ListInhibitors.return_value = dbus.Array(
+    login_manager.ListInhibitors.return_value = (
         [
-            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,
+            (
+                "shutdown:sleep",
+                "Developer",
+                "Haven't pushed my commits yet",
+                "delay",
+                1000,
+                1234,
             ),
+            ("shutdown", "Editor", "", "Unsafed files open", 0, 42),
         ],
-        signature=dbus.Signature("(ssssuu)"),
     )
     with caplog.at_level(logging.DEBUG):
         systemctl_mqtt._dbus._log_shutdown_inhibitors(login_manager)
@@ -79,7 +66,7 @@ def test__log_shutdown_inhibitors_some(caplog):
 
 def test__log_shutdown_inhibitors_none(caplog):
     login_manager = unittest.mock.MagicMock()
-    login_manager.ListInhibitors.return_value = dbus.Array([])
+    login_manager.ListInhibitors.return_value = ([],)
     with caplog.at_level(logging.DEBUG):
         systemctl_mqtt._dbus._log_shutdown_inhibitors(login_manager)
     assert len(caplog.records) == 1
@@ -89,12 +76,15 @@ def test__log_shutdown_inhibitors_none(caplog):
 
 def test__log_shutdown_inhibitors_fail(caplog):
     login_manager = unittest.mock.MagicMock()
-    login_manager.ListInhibitors.side_effect = dbus.DBusException("mocked")
+    login_manager.ListInhibitors.side_effect = DBusErrorResponseMock("error", "mocked")
     with caplog.at_level(logging.DEBUG):
         systemctl_mqtt._dbus._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"
+    assert (
+        caplog.records[0].message
+        == "failed to fetch shutdown inhibitors: [error] mocked"
+    )
 
 
 @pytest.mark.parametrize("action", ["poweroff", "reboot"])
@@ -102,40 +92,52 @@ def test__log_shutdown_inhibitors_fail(caplog):
 def test__schedule_shutdown(action, delay):
     login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
     ):
         systemctl_mqtt._dbus.schedule_shutdown(action=action, delay=delay)
     login_manager_mock.ScheduleShutdown.assert_called_once()
     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
-    )
-    actual_delay = shutdown_datetime - datetime.datetime.now(tz=_UTC)
+    assert not schedule_args
+    assert schedule_kwargs.pop("action") == action
+    actual_delay = schedule_kwargs.pop("time") - datetime.datetime.now()
     assert actual_delay.total_seconds() == pytest.approx(delay.total_seconds(), abs=0.1)
     assert not schedule_kwargs
 
 
+class DBusErrorResponseMock(jeepney.wrappers.DBusErrorResponse):
+    # pylint: disable=missing-class-docstring,super-init-not-called
+    def __init__(self, name: str, data: typing.Any):
+        self.name = name
+        self.data = data
+
+
 @pytest.mark.parametrize("action", ["poweroff"])
 @pytest.mark.parametrize(
-    ("exception_message", "log_message"),
+    ("error_name", "error_message", "log_message"),
     [
-        ("test message", "test message"),
         (
+            "test error",
+            "test message",
+            "[test error] ('test message',)",
+        ),
+        (
+            "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
             "Interactive authentication required.",
             "unauthorized; missing polkit authorization rules?",
         ),
     ],
 )
-def test__schedule_shutdown_fail(caplog, action, exception_message, log_message):
+def test__schedule_shutdown_fail(
+    caplog, action, error_name, error_message, log_message
+):
     login_manager_mock = unittest.mock.MagicMock()
-    login_manager_mock.ScheduleShutdown.side_effect = dbus.DBusException(
-        exception_message
+    login_manager_mock.ScheduleShutdown.side_effect = DBusErrorResponseMock(
+        name=error_name,
+        data=(error_message,),
     )
+    login_manager_mock.ListInhibitors.return_value = ([],)
     with unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
     ), caplog.at_level(logging.DEBUG):
         systemctl_mqtt._dbus.schedule_shutdown(
             action=action, delay=datetime.timedelta(seconds=21)
@@ -152,10 +154,64 @@ def test__schedule_shutdown_fail(caplog, action, exception_message, log_message)
 def test_lock_all_sessions(caplog):
     login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
     ), caplog.at_level(logging.INFO):
         systemctl_mqtt._dbus.lock_all_sessions()
     login_manager_mock.LockSessions.assert_called_once_with()
     assert len(caplog.records) == 1
     assert caplog.records[0].levelno == logging.INFO
     assert caplog.records[0].message == "instruct all sessions to activate screen locks"
+
+
+def test__run_signal_loop():
+    # pylint: disable=too-many-locals,too-many-arguments
+    login_manager_mock = unittest.mock.MagicMock()
+    dbus_connection_mock = unittest.mock.MagicMock()
+    with unittest.mock.patch(
+        "paho.mqtt.client.Client"
+    ) as mqtt_client_mock, unittest.mock.patch(
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
+    ), unittest.mock.patch(
+        "jeepney.io.blocking.open_dbus_connection", return_value=dbus_connection_mock
+    ) as open_dbus_connection_mock:
+        add_match_reply = unittest.mock.Mock()
+        add_match_reply.body = ()
+        dbus_connection_mock.send_and_get_reply.return_value = add_match_reply
+        dbus_connection_mock.recv_until_filtered.side_effect = [
+            jeepney.low_level.Message(header=None, body=(False,)),
+            jeepney.low_level.Message(header=None, body=(True,)),
+            jeepney.low_level.Message(header=None, body=(False,)),
+        ]
+        login_manager_mock.Inhibit.return_value = (jeepney.fds.FileDescriptor(-1),)
+        with pytest.raises(StopIteration):
+            systemctl_mqtt._run(
+                mqtt_host="localhost",
+                mqtt_port=1833,
+                mqtt_username=None,
+                mqtt_password=None,
+                mqtt_topic_prefix="systemctl/host",
+                homeassistant_discovery_prefix="homeassistant",
+                homeassistant_discovery_object_id="test",
+                poweroff_delay=datetime.timedelta(),
+            )
+    open_dbus_connection_mock.assert_called_once_with(bus="SYSTEM")
+    dbus_connection_mock.send_and_get_reply.assert_called_once()
+    add_match_msg = dbus_connection_mock.send_and_get_reply.call_args[0][0]
+    assert (
+        add_match_msg.header.fields[jeepney.low_level.HeaderFields.member] == "AddMatch"
+    )
+    assert add_match_msg.body == (
+        "interface='org.freedesktop.login1.Manager',member='PrepareForShutdown'"
+        ",path='/org/freedesktop/login1',type='signal'",
+    )
+    assert mqtt_client_mock().publish.call_args_list == [
+        unittest.mock.call(
+            topic="systemctl/host/preparing-for-shutdown", payload="false", retain=True
+        ),
+        unittest.mock.call(
+            topic="systemctl/host/preparing-for-shutdown", payload="true", retain=True
+        ),
+        unittest.mock.call(
+            topic="systemctl/host/preparing-for-shutdown", payload="false", retain=True
+        ),
+    ]

+ 84 - 34
tests/test_mqtt.py

@@ -15,13 +15,16 @@
 # 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 contextlib
 import datetime
 import logging
 import threading
 import time
+import typing
 import unittest.mock
 
-import dbus
+import jeepney.fds
+import jeepney.low_level
 import paho.mqtt.client
 import pytest
 from paho.mqtt.client import MQTTMessage
@@ -31,6 +34,16 @@ import systemctl_mqtt
 # pylint: disable=protected-access,too-many-positional-arguments
 
 
+@contextlib.contextmanager
+def mock_open_dbus_connection() -> typing.Iterator[unittest.mock.MagicMock]:
+    with unittest.mock.patch("jeepney.io.blocking.open_dbus_connection") as mock:
+        add_match_reply = unittest.mock.Mock()
+        add_match_reply.body = ()
+        mock.return_value.send_and_get_reply.return_value = add_match_reply
+        mock.return_value.recv_until_filtered.side_effect = []
+        yield mock
+
+
 @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
 @pytest.mark.parametrize("mqtt_port", [1833])
 @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
@@ -46,6 +59,7 @@ def test__run(
 ):
     # pylint: disable=too-many-locals,too-many-arguments
     caplog.set_level(logging.DEBUG)
+    login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(
         "socket.create_connection"
     ) as create_socket_mock, unittest.mock.patch(
@@ -53,22 +67,22 @@ def test__run(
     ) 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"
-    ) as glib_loop_mock, unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager"
-    ) as get_login_manager_mock:
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
+    ), mock_open_dbus_connection() as open_dbus_connection_mock:
         ssl_wrap_socket_mock.return_value.send = len
-        get_login_manager_mock.return_value.Get.return_value = dbus.Boolean(False)
-        systemctl_mqtt._run(
-            mqtt_host=mqtt_host,
-            mqtt_port=mqtt_port,
-            mqtt_username=None,
-            mqtt_password=None,
-            mqtt_topic_prefix=mqtt_topic_prefix,
-            homeassistant_discovery_prefix=homeassistant_discovery_prefix,
-            homeassistant_discovery_object_id=homeassistant_discovery_object_id,
-            poweroff_delay=datetime.timedelta(),
-        )
+        login_manager_mock.Inhibit.return_value = (jeepney.fds.FileDescriptor(-1),)
+        login_manager_mock.Get.return_value = (("b", False),)
+        with pytest.raises(StopIteration):
+            systemctl_mqtt._run(
+                mqtt_host=mqtt_host,
+                mqtt_port=mqtt_port,
+                mqtt_username=None,
+                mqtt_password=None,
+                mqtt_topic_prefix=mqtt_topic_prefix,
+                homeassistant_discovery_prefix=homeassistant_discovery_prefix,
+                homeassistant_discovery_object_id=homeassistant_discovery_object_id,
+                poweroff_delay=datetime.timedelta(),
+            )
     assert caplog.records[0].levelno == logging.INFO
     assert caplog.records[0].message == (
         f"connecting to MQTT broker {mqtt_host}:{mqtt_port} (TLS enabled)"
@@ -98,11 +112,15 @@ def test__run(
         "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"
+    open_dbus_connection_mock.assert_called_once_with(bus="SYSTEM")
+    login_manager_mock.Inhibit.assert_called_once_with(
+        what="shutdown",
+        who="systemctl-mqtt",
+        why="Report shutdown via MQTT",
+        mode="delay",
     )
+    login_manager_mock.Get.assert_called_once_with("PreparingForShutdown")
+    open_dbus_connection_mock.return_value.send_and_get_reply.assert_called_once()
     assert sorted(mqtt_subscribe_mock.call_args_list) == [
         unittest.mock.call(mqtt_topic_prefix + "/lock-all-sessions"),
         unittest.mock.call(mqtt_topic_prefix + "/poweroff"),
@@ -146,8 +164,7 @@ def test__run(
         f" triggering {systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING[s]}"
         for s in ("poweroff", "lock-all-sessions")
     }
-    # dbus loop started?
-    glib_loop_mock.assert_called_once_with()
+    open_dbus_connection_mock.return_value.filter.assert_called_once()
     # waited for mqtt loop to stop?
     assert mqtt_client._thread_terminate
     assert mqtt_client._thread is None
@@ -160,7 +177,7 @@ def test__run_tls(caplog, mqtt_host, mqtt_port, mqtt_disable_tls):
     caplog.set_level(logging.INFO)
     with unittest.mock.patch(
         "paho.mqtt.client.Client"
-    ) as mqtt_client_class, unittest.mock.patch("gi.repository.GLib.MainLoop.run"):
+    ) as mqtt_client_class, mock_open_dbus_connection(), pytest.raises(StopIteration):
         systemctl_mqtt._run(
             mqtt_host=mqtt_host,
             mqtt_port=mqtt_port,
@@ -186,7 +203,7 @@ def test__run_tls(caplog, mqtt_host, mqtt_port, mqtt_disable_tls):
 def test__run_tls_default():
     with unittest.mock.patch(
         "paho.mqtt.client.Client"
-    ) as mqtt_client_class, unittest.mock.patch("gi.repository.GLib.MainLoop.run"):
+    ) as mqtt_client_class, mock_open_dbus_connection(), pytest.raises(StopIteration):
         systemctl_mqtt._run(
             mqtt_host="mqtt-broker.local",
             mqtt_port=1833,
@@ -215,9 +232,9 @@ def test__run_authentication(
     ) 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._dbus.get_login_manager"
+        "systemctl_mqtt._dbus.get_login_manager_proxy"
+    ), mock_open_dbus_connection(), pytest.raises(
+        StopIteration
     ):
         ssl_wrap_socket_mock.return_value.send = len
         systemctl_mqtt._run(
@@ -247,12 +264,15 @@ def _initialize_mqtt_client(
     ) 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._dbus.get_login_manager"
-    ) as get_login_manager_mock:
+        "systemctl_mqtt._dbus.get_login_manager_proxy"
+    ) as get_login_manager_mock, mock_open_dbus_connection(), pytest.raises(
+        StopIteration
+    ):
         ssl_wrap_socket_mock.return_value.send = len
-        get_login_manager_mock.return_value.Get.return_value = dbus.Boolean(False)
+        get_login_manager_mock.return_value.Inhibit.return_value = (
+            jeepney.fds.FileDescriptor(-1),
+        )
+        get_login_manager_mock.return_value.Get.return_value = (("b", True),)
         systemctl_mqtt._run(
             mqtt_host=mqtt_host,
             mqtt_port=mqtt_port,
@@ -301,8 +321,8 @@ def test__client_handle_message(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix)
 @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"), unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager"
-    ):
+        "systemctl_mqtt._dbus.get_login_manager_proxy"
+    ), mock_open_dbus_connection():
         with pytest.raises(ValueError, match=r"^Missing MQTT username$"):
             systemctl_mqtt._run(
                 mqtt_host=mqtt_host,
@@ -365,3 +385,33 @@ def test_mqtt_message_callback_poweroff_retained(
     )
     assert caplog.records[1].levelno == logging.INFO
     assert caplog.records[1].message == "ignoring retained message"
+
+
+@pytest.mark.parametrize("active", [True, False])
+@pytest.mark.parametrize("block", [True, False])
+def test__publish_preparing_for_shutdown_blocking(active: bool, block: bool) -> None:
+    login_manager_mock = unittest.mock.MagicMock()
+    login_manager_mock.Get.return_value = (("b", active),)
+    with unittest.mock.patch(
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
+    ):
+        state = systemctl_mqtt._State(
+            mqtt_topic_prefix="prefix",
+            homeassistant_discovery_prefix="prefix",
+            homeassistant_discovery_object_id="object-id",
+            poweroff_delay=datetime.timedelta(),
+        )
+    mqtt_client_mock = unittest.mock.MagicMock()
+    state._publish_preparing_for_shutdown(
+        mqtt_client=mqtt_client_mock, active=active, block=block
+    )
+    mqtt_client_mock.publish.assert_called_once_with(
+        topic="prefix/preparing-for-shutdown",
+        payload="true" if active else "false",
+        retain=True,
+    )
+    msg_info = mqtt_client_mock.publish.return_value
+    if block:
+        msg_info.wait_for_publish.assert_called_once()
+    else:
+        msg_info.wait_for_publish.assert_not_called()

+ 37 - 69
tests/test_state_dbus.py

@@ -19,9 +19,10 @@ import datetime
 import json
 import logging
 import re
+import typing
 import unittest.mock
 
-import dbus.types
+import jeepney.wrappers
 import pytest
 
 import systemctl_mqtt
@@ -30,88 +31,47 @@ import systemctl_mqtt
 
 
 def test_shutdown_lock():
-    lock_fd = unittest.mock.MagicMock()
-    with unittest.mock.patch("systemctl_mqtt._dbus.get_login_manager"):
+    lock_fd = unittest.mock.MagicMock(spec=jeepney.fds.FileDescriptor)
+    with unittest.mock.patch(
+        "systemctl_mqtt._dbus.get_login_manager_proxy"
+    ) as get_login_manager_mock:
         state = systemctl_mqtt._State(
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
             homeassistant_discovery_object_id=None,
             poweroff_delay=datetime.timedelta(),
         )
-        state._login_manager.Inhibit.return_value = lock_fd
+        get_login_manager_mock.return_value.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"
+        what="shutdown",
+        who="systemctl-mqtt",
+        why="Report shutdown via MQTT",
+        mode="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._dbus.get_login_manager"):
-        state = systemctl_mqtt._State(
-            mqtt_topic_prefix="any",
-            homeassistant_discovery_prefix=None,
-            homeassistant_discovery_object_id=None,
-            poweroff_delay=datetime.timedelta(),
-        )
-    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",
-        retain=True,
-    )
-    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"
-    )
+    lock_fd.close.assert_not_called()
+    state.release_shutdown_lock()
+    lock_fd.close.assert_called_once_with()
 
 
 @pytest.mark.parametrize("active", [True, False])
-def test_publish_preparing_for_shutdown(active):
+def test_publish_preparing_for_shutdown(active: bool) -> None:
     login_manager_mock = unittest.mock.MagicMock()
-    login_manager_mock.Get.return_value = dbus.Boolean(active)
+    login_manager_mock.Get.return_value = (("b", active),)[:]
     with unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
     ):
         state = systemctl_mqtt._State(
             mqtt_topic_prefix="any",
-            homeassistant_discovery_prefix=None,
-            homeassistant_discovery_object_id=None,
+            homeassistant_discovery_prefix="pre/fix",
+            homeassistant_discovery_object_id="obj",
             poweroff_delay=datetime.timedelta(),
         )
     assert state._login_manager == login_manager_mock
     mqtt_client_mock = unittest.mock.MagicMock()
     state.publish_preparing_for_shutdown(mqtt_client=mqtt_client_mock)
-    login_manager_mock.Get.assert_called_once_with(
-        "org.freedesktop.login1.Manager",
-        "PreparingForShutdown",
-        dbus_interface="org.freedesktop.DBus.Properties",
-    )
+    login_manager_mock.Get.assert_called_once_with("PreparingForShutdown")
     mqtt_client_mock.publish.assert_called_once_with(
         topic="any/preparing-for-shutdown",
         payload="true" if active else "false",
@@ -119,11 +79,18 @@ def test_publish_preparing_for_shutdown(active):
     )
 
 
+class DBusErrorResponseMock(jeepney.wrappers.DBusErrorResponse):
+    # pylint: disable=missing-class-docstring,super-init-not-called
+    def __init__(self, name: str, data: typing.Any):
+        self.name = name
+        self.data = data
+
+
 def test_publish_preparing_for_shutdown_get_fail(caplog):
     login_manager_mock = unittest.mock.MagicMock()
-    login_manager_mock.Get.side_effect = dbus.DBusException("mocked")
+    login_manager_mock.Get.side_effect = DBusErrorResponseMock("error", ("mocked",))
     with unittest.mock.patch(
-        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
     ):
         state = systemctl_mqtt._State(
             mqtt_topic_prefix="any",
@@ -138,7 +105,7 @@ def test_publish_preparing_for_shutdown_get_fail(caplog):
     assert caplog.records[0].levelno == logging.ERROR
     assert (
         caplog.records[0].message
-        == "failed to read logind's PreparingForShutdown property: mocked"
+        == "failed to read logind's PreparingForShutdown property: [error] ('mocked',)"
     )
 
 
@@ -149,12 +116,13 @@ def test_publish_preparing_for_shutdown_get_fail(caplog):
 def test_publish_homeassistant_device_config(
     topic_prefix, discovery_prefix, object_id, hostname
 ):
-    state = systemctl_mqtt._State(
-        mqtt_topic_prefix=topic_prefix,
-        homeassistant_discovery_prefix=discovery_prefix,
-        homeassistant_discovery_object_id=object_id,
-        poweroff_delay=datetime.timedelta(),
-    )
+    with unittest.mock.patch("jeepney.io.blocking.open_dbus_connection"):
+        state = systemctl_mqtt._State(
+            mqtt_topic_prefix=topic_prefix,
+            homeassistant_discovery_prefix=discovery_prefix,
+            homeassistant_discovery_object_id=object_id,
+            poweroff_delay=datetime.timedelta(),
+        )
     mqtt_client = unittest.mock.MagicMock()
     with unittest.mock.patch(
         "systemctl_mqtt._utils.get_hostname", return_value=hostname