5 Commits 59e043a7be ... c7796a8701

Author SHA1 Message Date
  Fabian Peter Hammerle c7796a8701 release v0.3.0 3 weeks ago
  Fabian Peter Hammerle 43835e1fc9 fixed fatal error on mqtt reconnect: tried to re-acquire shutdown inhibitor lock 3 weeks ago
  Fabian Peter Hammerle f1371e1fec refactor: move some constants & methods to new systemctl_mqtt/_dbus.py 3 weeks ago
  Fabian Peter Hammerle 1f4ff18dbc added missing license headers 3 weeks ago
  Fabian Peter Hammerle 56dc5bad31 prepare-for-shutdown: publish config for home assistant to enable automatic discovery 3 weeks ago

+ 11 - 1
CHANGELOG.md

@@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+## [0.3.0] - 2020-06-21
+### Added
+- home assistant: enable [automatic discovery](https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix)
+  for logind's `PreparingForShutdown` signal
+
+### Fixed
+- fatal error on MQTT reconnect:
+  tried to re-acquire shutdown inhibitor lock
+
 ## [0.2.0] - 2020-06-21
 ### Added
 - forward logind's [PreparingForShutdown](https://www.freedesktop.org/wiki/Software/systemd/inhibit/)
@@ -30,7 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - MQTT message on topic `systemctl/hostname/poweroff`
   schedules a poweroff via systemd's dbus interface (4 seconds delay)
 
-[Unreleased]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.2.0...HEAD
+[Unreleased]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.3.0...HEAD
+[0.3.0]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.2.0...v0.3.0
 [0.2.0]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.1.1...v0.2.0
 [0.1.1]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.1.0...v0.1.1
 [0.1.0]: https://github.com/fphammerle/systemctl-mqtt/releases/tag/v0.1.0

+ 18 - 0
README.md

@@ -71,6 +71,24 @@ automation:
     entity_id: switch.desk_lamp
 ```
 
+### Automatic Discovery of Shutdown Sensor (Optional)
+
+After enabling [MQTT device discovery](https://www.home-assistant.io/docs/mqtt/discovery/)
+home assistant will automatically detect a new entity
+`binary_sensor.hostname_preparing_for_shutdown`.
+
+```yaml
+mqtt:
+  broker: BROKER_HOSTNAME_OR_IP_ADDRESS
+  discovery: true
+  # credentials, additional options…
+```
+
+![homeassistant discovery binary_sensor.hostname_preparing_for_shutdown](docs/homeassistant/preparing-for-shutdown/settings/discovery/2020-06-21.png)
+
+When using a custom `discovery_prefix`
+pass `--homeassistant-discovery-prefix custom-prefix` to `systemctl-mqtt`.
+
 ## Docker 🐳
 
 1. Clone this repository.

BIN
docs/homeassistant/preparing-for-shutdown/settings/discovery/2020-06-21.png


+ 106 - 91
systemctl_mqtt/__init__.py

@@ -28,96 +28,32 @@ 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__)
-
-_SHUTDOWN_DELAY = datetime.timedelta(seconds=4)
-
+import systemctl_mqtt._dbus
+import systemctl_mqtt._homeassistant
+import systemctl_mqtt._mqtt
 
-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 _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
-    shutdown_datetime = datetime.datetime.now() + _SHUTDOWN_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()
-    try:
-        # $ gdbus introspect --system --dest org.freedesktop.login1 \
-        #       --object-path /org/freedesktop/login1 | grep -A 1 ScheduleShutdown
-        # ScheduleShutdown(in  s arg_0,
-        #                  in  t arg_1);
-        # $ gdbus call --system --dest org.freedesktop.login1 \
-        #       --object-path /org/freedesktop/login1 \
-        #       --method org.freedesktop.login1.Manager.ScheduleShutdown \
-        #       poweroff "$(date --date=10min +%s)000000"
-        # $ dbus-send --type=method_call --print-reply --system --dest=org.freedesktop.login1 \
-        #       /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():
-            _LOGGER.error(
-                "failed to schedule %s: unauthorized; missing polkit authorization rules?",
-                action,
-            )
-        else:
-            _LOGGER.error("failed to schedule %s: %s", action, exc_msg)
-    _log_shutdown_inhibitors(login_manager)
+_LOGGER = logging.getLogger(__name__)
 
 
 class _State:
-    def __init__(self, mqtt_topic_prefix: str) -> None:
+    def __init__(
+        self,
+        mqtt_topic_prefix: str,
+        homeassistant_discovery_prefix: str,
+        homeassistant_node_id: str,
+    ) -> None:
         self._mqtt_topic_prefix = mqtt_topic_prefix
-        self._login_manager = _get_login_manager()  # type: dbus.proxies.Interface
+        self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
+        self._homeassistant_node_id = homeassistant_node_id
+        self._login_manager = (
+            systemctl_mqtt._dbus.get_login_manager()
+        )  # type: dbus.proxies.Interface
         self._shutdown_lock = None  # type: typing.Optional[dbus.types.UnixFd]
         self._shutdown_lock_mutex = threading.Lock()
 
@@ -125,6 +61,10 @@ class _State:
     def mqtt_topic_prefix(self) -> str:
         return self._mqtt_topic_prefix
 
+    @property
+    def shutdown_lock_acquired(self) -> bool:
+        return self._shutdown_lock is not None
+
     def acquire_shutdown_lock(self) -> None:
         with self._shutdown_lock_mutex:
             assert self._shutdown_lock is None
@@ -142,12 +82,17 @@ class _State:
                 _LOGGER.debug("released shutdown inhibitor lock")
                 self._shutdown_lock = None
 
+    @property
+    def _preparing_for_shutdown_topic(self) -> str:
+        return self.mqtt_topic_prefix + "/preparing-for-shutdown"
+
     def _publish_preparing_for_shutdown(
         self, mqtt_client: paho.mqtt.client.Client, active: bool, block: 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)
+        topic = self._preparing_for_shutdown_topic
+        # pylint: disable=protected-access
+        payload = systemctl_mqtt._mqtt.encode_bool(active)
         _LOGGER.info("publishing %r on %s", payload, topic)
         msg_info = mqtt_client.publish(
             topic=topic, payload=payload, retain=True,
@@ -206,6 +151,43 @@ class _State:
             block=False,
         )
 
+    def publish_preparing_for_shutdown_homeassistant_config(
+        self, mqtt_client: paho.mqtt.client.Client
+    ) -> None:
+        # <discovery_prefix>/<component>/[<node_id>/]<object_id>/config
+        # https://www.home-assistant.io/docs/mqtt/discovery/
+        discovery_topic = "/".join(
+            (
+                self._homeassistant_discovery_prefix,
+                "binary_sensor",
+                self._homeassistant_node_id,
+                "preparing-for-shutdown",
+                "config",
+            )
+        )
+        unique_id = "/".join(
+            (
+                "systemctl-mqtt",
+                self._homeassistant_node_id,
+                "logind",
+                "preparing-for-shutdown",
+            )
+        )
+        # https://www.home-assistant.io/integrations/binary_sensor.mqtt/#configuration-variables
+        config = {
+            "unique_id": unique_id,
+            "state_topic": self._preparing_for_shutdown_topic,
+            # pylint: disable=protected-access
+            "payload_on": systemctl_mqtt._mqtt.encode_bool(True),
+            "payload_off": systemctl_mqtt._mqtt.encode_bool(False),
+            # friendly_name & template for default entity_id
+            "name": "{} preparing for shutdown".format(self._homeassistant_node_id),
+        }
+        _LOGGER.debug("publishing home assistant config on %s", discovery_topic)
+        mqtt_client.publish(
+            topic=discovery_topic, payload=json.dumps(config), retain=True,
+        )
+
 
 class _MQTTAction:
 
@@ -235,7 +217,12 @@ class _MQTTAction:
 
 _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
     "poweroff": _MQTTAction(
-        name="poweroff", action=functools.partial(_schedule_shutdown, action="poweroff")
+        name="poweroff",
+        action=functools.partial(
+            # pylint: disable=protected-access
+            systemctl_mqtt._dbus.schedule_shutdown,
+            action="poweroff",
+        ),
     ),
 }
 
@@ -251,9 +238,11 @@ 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()
+    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_preparing_for_shutdown_homeassistant_config(mqtt_client=mqtt_client)
     for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
         topic = state.mqtt_topic_prefix + "/" + topic_suffix
         _LOGGER.info("subscribing to %s", topic)
@@ -262,7 +251,7 @@ def _mqtt_on_connect(
             sub=topic, callback=action.mqtt_message_callback
         )
         _LOGGER.debug(
-            "registered MQTT callback for topic %s triggering %r", topic, action.action
+            "registered MQTT callback for topic %s triggering %r", topic, action.action,
         )
 
 
@@ -272,12 +261,19 @@ def _run(
     mqtt_username: typing.Optional[str],
     mqtt_password: typing.Optional[str],
     mqtt_topic_prefix: str,
+    homeassistant_discovery_prefix: str,
+    homeassistant_node_id: str,
 ) -> 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)
+        userdata=_State(
+            mqtt_topic_prefix=mqtt_topic_prefix,
+            homeassistant_discovery_prefix=homeassistant_discovery_prefix,
+            homeassistant_node_id=homeassistant_node_id,
+        )
     )
     mqtt_client.on_connect = _mqtt_on_connect
     mqtt_client.tls_set(ca_certs=None)  # enable tls trusting default system certs
@@ -303,10 +299,6 @@ def _run(
         _LOGGER.debug("MQTT loop stopped")
 
 
-def _get_hostname() -> str:
-    return socket.gethostname()
-
-
 def _main() -> None:
     logging.basicConfig(
         level=logging.DEBUG,
@@ -329,13 +321,24 @@ def _main() -> None:
         dest="mqtt_password_path",
         help="stripping trailing newline",
     )
-    # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
     argparser.add_argument(
         "--mqtt-topic-prefix",
         type=str,
-        default="systemctl/" + _get_hostname(),
+        # pylint: disable=protected-access
+        default="systemctl/" + systemctl_mqtt._utils.get_hostname(),
         help=" ",  # show default
     )
+    # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
+    argparser.add_argument(
+        "--homeassistant-discovery-prefix", type=str, default="homeassistant", help=" ",
+    )
+    argparser.add_argument(
+        "--homeassistant-node-id",
+        type=str,
+        # pylint: disable=protected-access
+        default=systemctl_mqtt._homeassistant.get_default_node_id(),
+        help=" ",
+    )
     args = argparser.parse_args()
     if args.mqtt_password_path:
         # .read_text() replaces \r\n with \n
@@ -346,10 +349,22 @@ def _main() -> None:
             mqtt_password = mqtt_password[:-1]
     else:
         mqtt_password = args.mqtt_password
+    # pylint: disable=protected-access
+    if not systemctl_mqtt._homeassistant.validate_node_id(args.homeassistant_node_id):
+        raise ValueError(
+            "invalid home assistant node id {!r} (length >= 1, allowed characters: {})".format(
+                args.homeassistant_node_id,
+                # pylint: disable=protected-access
+                systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS,
+            )
+            + "\nchange --homeassistant-node-id"
+        )
     _run(
         mqtt_host=args.mqtt_host,
         mqtt_port=args.mqtt_port,
         mqtt_username=args.mqtt_username,
         mqtt_password=mqtt_password,
         mqtt_topic_prefix=args.mqtt_topic_prefix,
+        homeassistant_discovery_prefix=args.homeassistant_discovery_prefix,
+        homeassistant_node_id=args.homeassistant_node_id,
     )

+ 99 - 0
systemctl_mqtt/_dbus.py

@@ -0,0 +1,99 @@
+# systemctl-mqtt - MQTT client triggering & reporting 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 datetime
+import logging
+
+import dbus
+
+_LOGGER = logging.getLogger(__name__)
+
+_SHUTDOWN_DELAY = datetime.timedelta(seconds=4)
+
+
+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 _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
+    shutdown_datetime = datetime.datetime.now() + _SHUTDOWN_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()
+    try:
+        # $ gdbus introspect --system --dest org.freedesktop.login1 \
+        #       --object-path /org/freedesktop/login1 | grep -A 1 ScheduleShutdown
+        # ScheduleShutdown(in  s arg_0,
+        #                  in  t arg_1);
+        # $ gdbus call --system --dest org.freedesktop.login1 \
+        #       --object-path /org/freedesktop/login1 \
+        #       --method org.freedesktop.login1.Manager.ScheduleShutdown \
+        #       poweroff "$(date --date=10min +%s)000000"
+        # $ dbus-send --type=method_call --print-reply --system --dest=org.freedesktop.login1 \
+        #       /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():
+            _LOGGER.error(
+                "failed to schedule %s: unauthorized; missing polkit authorization rules?",
+                action,
+            )
+        else:
+            _LOGGER.error("failed to schedule %s: %s", action, exc_msg)
+    _log_shutdown_inhibitors(login_manager)

+ 35 - 0
systemctl_mqtt/_homeassistant.py

@@ -0,0 +1,35 @@
+# systemctl-mqtt - MQTT client triggering & reporting 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 re
+
+import systemctl_mqtt._utils
+
+NODE_ID_ALLOWED_CHARS = r"a-zA-Z0-9_-"
+
+
+def get_default_node_id() -> str:
+    return re.sub(
+        r"[^{}]".format(NODE_ID_ALLOWED_CHARS),
+        "",
+        # pylint: disable=protected-access
+        systemctl_mqtt._utils.get_hostname(),
+    )
+
+
+def validate_node_id(node_id: str) -> bool:
+    return re.match(r"^[{}]+$".format(NODE_ID_ALLOWED_CHARS), node_id) is not None

+ 22 - 0
systemctl_mqtt/_mqtt.py

@@ -0,0 +1,22 @@
+# systemctl-mqtt - MQTT client triggering & reporting 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 json
+
+
+def encode_bool(value: bool) -> str:
+    return json.dumps(value)

+ 22 - 0
systemctl_mqtt/_utils.py

@@ -0,0 +1,22 @@
+# systemctl-mqtt - MQTT client triggering & reporting 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 socket
+
+
+def get_hostname() -> str:
+    return socket.gethostname()

+ 54 - 7
tests/test_cli.py

@@ -21,6 +21,10 @@ import unittest.mock
 import pytest
 
 import systemctl_mqtt
+import systemctl_mqtt._homeassistant
+import systemctl_mqtt._utils
+
+# pylint: disable=protected-access
 
 
 @pytest.mark.parametrize(
@@ -100,7 +104,9 @@ def test__main(
     # pylint: disable=too-many-arguments
     with unittest.mock.patch("systemctl_mqtt._run") as run_mock, unittest.mock.patch(
         "sys.argv", argv
-    ), unittest.mock.patch("systemctl_mqtt._get_hostname", return_value="hostname"):
+    ), unittest.mock.patch(
+        "systemctl_mqtt._utils.get_hostname", return_value="hostname"
+    ):
         # pylint: disable=protected-access
         systemctl_mqtt._main()
     run_mock.assert_called_once_with(
@@ -109,6 +115,8 @@ def test__main(
         mqtt_username=expected_username,
         mqtt_password=expected_password,
         mqtt_topic_prefix=expected_topic_prefix or "systemctl/hostname",
+        homeassistant_discovery_prefix="homeassistant",
+        homeassistant_node_id="hostname",
     )
 
 
@@ -141,7 +149,9 @@ def test__main_password_file(tmpdir, password_file_content, expected_password):
             "--mqtt-password-file",
             str(mqtt_password_path),
         ],
-    ), unittest.mock.patch("systemctl_mqtt._get_hostname", return_value="hostname"):
+    ), unittest.mock.patch(
+        "systemctl_mqtt._utils.get_hostname", return_value="hostname"
+    ):
         # pylint: disable=protected-access
         systemctl_mqtt._main()
     run_mock.assert_called_once_with(
@@ -150,6 +160,8 @@ def test__main_password_file(tmpdir, password_file_content, expected_password):
         mqtt_username="me",
         mqtt_password=expected_password,
         mqtt_topic_prefix="systemctl/hostname",
+        homeassistant_discovery_prefix="homeassistant",
+        homeassistant_node_id="hostname",
     )
 
 
@@ -179,8 +191,43 @@ def test__main_password_file_collision(capsys):
     )
 
 
-@pytest.mark.parametrize("hostname", ["test"])
-def test__get_hostname(hostname):
-    with unittest.mock.patch("socket.gethostname", return_value=hostname):
-        # pylint: disable=protected-access
-        assert systemctl_mqtt._get_hostname() == hostname
+@pytest.mark.parametrize(
+    ("args", "discovery_prefix"),
+    [
+        ([], "homeassistant"),
+        (["--homeassistant-discovery-prefix", "home/assistant"], "home/assistant"),
+    ],
+)
+def test__main_homeassistant_discovery_prefix(args, discovery_prefix):
+    with unittest.mock.patch("systemctl_mqtt._run") as run_mock, unittest.mock.patch(
+        "sys.argv", ["", "--mqtt-host", "mqtt-broker.local"] + args
+    ):
+        systemctl_mqtt._main()
+    assert run_mock.call_count == 1
+    assert run_mock.call_args[1]["homeassistant_discovery_prefix"] == discovery_prefix
+
+
+@pytest.mark.parametrize(
+    ("args", "node_id"),
+    [([], "fallback"), (["--homeassistant-node-id", "raspberrypi"], "raspberrypi"),],
+)
+def test__main_homeassistant_node_id(args, node_id):
+    with unittest.mock.patch("systemctl_mqtt._run") as run_mock, unittest.mock.patch(
+        "sys.argv", ["", "--mqtt-host", "mqtt-broker.local"] + args
+    ), unittest.mock.patch(
+        "systemctl_mqtt._utils.get_hostname", return_value="fallback",
+    ):
+        systemctl_mqtt._main()
+    assert run_mock.call_count == 1
+    assert run_mock.call_args[1]["homeassistant_node_id"] == node_id
+
+
+@pytest.mark.parametrize(
+    "args", [["--homeassistant-node-id", "no pe"], ["--homeassistant-node-id", ""]],
+)
+def test__main_homeassistant_node_id_invalid(args):
+    with unittest.mock.patch(
+        "sys.argv", ["", "--mqtt-host", "mqtt-broker.local"] + args
+    ):
+        with pytest.raises(ValueError):
+            systemctl_mqtt._main()

+ 12 - 12
tests/test_dbus.py

@@ -22,15 +22,15 @@ import unittest.mock
 import dbus
 import pytest
 
-import systemctl_mqtt
+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._get_login_manager()
+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"
     # https://freedesktop.org/wiki/Software/systemd/logind/
@@ -67,7 +67,7 @@ def test__log_shutdown_inhibitors_some(caplog):
         signature=dbus.Signature("(ssssuu)"),
     )
     with caplog.at_level(logging.DEBUG):
-        systemctl_mqtt._log_shutdown_inhibitors(login_manager)
+        systemctl_mqtt._dbus._log_shutdown_inhibitors(login_manager)
     assert len(caplog.records) == 2
     assert caplog.records[0].levelno == logging.DEBUG
     assert (
@@ -81,7 +81,7 @@ 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)
+        systemctl_mqtt._dbus._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"
@@ -91,7 +91,7 @@ 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)
+        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"
@@ -101,9 +101,9 @@ def test__log_shutdown_inhibitors_fail(caplog):
 def test__schedule_shutdown(action):
     login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(
-        "systemctl_mqtt._get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock,
     ):
-        systemctl_mqtt._schedule_shutdown(action=action)
+        systemctl_mqtt._dbus.schedule_shutdown(action=action)
     assert login_manager_mock.ScheduleShutdown.call_count == 1
     schedule_args, schedule_kwargs = login_manager_mock.ScheduleShutdown.call_args
     assert len(schedule_args) == 2
@@ -114,7 +114,7 @@ def test__schedule_shutdown(action):
     )
     delay = shutdown_datetime - datetime.datetime.now(tz=_UTC)
     assert delay.total_seconds() == pytest.approx(
-        systemctl_mqtt._SHUTDOWN_DELAY.total_seconds(), abs=0.1,
+        systemctl_mqtt._dbus._SHUTDOWN_DELAY.total_seconds(), abs=0.1,
     )
     assert not schedule_kwargs
 
@@ -136,9 +136,9 @@ def test__schedule_shutdown_fail(caplog, action, exception_message, log_message)
         exception_message
     )
     with unittest.mock.patch(
-        "systemctl_mqtt._get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock,
     ), caplog.at_level(logging.DEBUG):
-        systemctl_mqtt._schedule_shutdown(action=action)
+        systemctl_mqtt._dbus.schedule_shutdown(action=action)
     assert login_manager_mock.ScheduleShutdown.call_count == 1
     assert len(caplog.records) == 3
     assert caplog.records[0].levelno == logging.INFO
@@ -157,7 +157,7 @@ def test_mqtt_topic_suffix_action_mapping(topic_suffix, expected_action_arg):
     mqtt_action = systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING[topic_suffix]
     login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(
-        "systemctl_mqtt._get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock,
     ):
         mqtt_action.action()
     assert login_manager_mock.ScheduleShutdown.call_count == 1

+ 54 - 0
tests/test_homeassistant.py

@@ -0,0 +1,54 @@
+# systemctl-mqtt - MQTT client triggering & reporting 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 unittest.mock
+
+import pytest
+
+import systemctl_mqtt._homeassistant
+
+# pylint: disable=protected-access
+
+
+@pytest.mark.parametrize(
+    ("hostname", "expected_node_id"),
+    [
+        ("raspberrypi", "raspberrypi"),
+        ("da-sh", "da-sh"),
+        ("under_score", "under_score"),
+        ("someone evil mocked the hostname", "someoneevilmockedthehostname"),
+    ],
+)
+def test_get_default_node_id(hostname, expected_node_id):
+    with unittest.mock.patch(
+        "systemctl_mqtt._utils.get_hostname", return_value=hostname
+    ):
+        assert systemctl_mqtt._homeassistant.get_default_node_id() == expected_node_id
+
+
+@pytest.mark.parametrize(
+    ("node_id", "valid"),
+    [
+        ("raspberrypi", True),
+        ("da-sh", True),
+        ("under_score", True),
+        ('" or ""="', False),
+        ("", False),
+    ],
+)
+def test_validate_node_id(node_id, valid):
+    assert systemctl_mqtt._homeassistant.validate_node_id(node_id) == valid

+ 36 - 9
tests/test_mqtt.py

@@ -33,7 +33,17 @@ import systemctl_mqtt
 @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__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
+@pytest.mark.parametrize("homeassistant_discovery_prefix", ["homeassistant"])
+@pytest.mark.parametrize("homeassistant_node_id", ["host", "node"])
+def test__run(
+    caplog,
+    mqtt_host,
+    mqtt_port,
+    mqtt_topic_prefix,
+    homeassistant_discovery_prefix,
+    homeassistant_node_id,
+):
+    # pylint: disable=too-many-locals,too-many-arguments
     caplog.set_level(logging.DEBUG)
     with unittest.mock.patch(
         "socket.create_connection"
@@ -44,7 +54,7 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
     ) 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"
+        "systemctl_mqtt._dbus.get_login_manager"
     ) as get_login_manager_mock:
         ssl_wrap_socket_mock.return_value.send = len
         get_login_manager_mock.return_value.Get.return_value = dbus.Boolean(False)
@@ -54,6 +64,8 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
             mqtt_username=None,
             mqtt_password=None,
             mqtt_topic_prefix=mqtt_topic_prefix,
+            homeassistant_discovery_prefix=homeassistant_discovery_prefix,
+            homeassistant_node_id=homeassistant_node_id,
         )
     assert caplog.records[0].levelno == logging.INFO
     assert caplog.records[0].message == "connecting to MQTT broker {}:{}".format(
@@ -107,12 +119,21 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
     assert caplog.records[2].message == "publishing 'false' on {}".format(
         mqtt_topic_prefix + "/preparing-for-shutdown"
     )
-    assert caplog.records[3].levelno == logging.INFO
-    assert caplog.records[3].message == "subscribing to {}".format(
+    assert caplog.records[3].levelno == logging.DEBUG
+    assert (
+        caplog.records[3].message
+        == "publishing home assistant config on "
+        + homeassistant_discovery_prefix
+        + "/binary_sensor/"
+        + homeassistant_node_id
+        + "/preparing-for-shutdown/config"
+    )
+    assert caplog.records[4].levelno == logging.INFO
+    assert caplog.records[4].message == "subscribing to {}".format(
         mqtt_topic_prefix + "/poweroff"
     )
-    assert caplog.records[4].levelno == logging.DEBUG
-    assert caplog.records[4].message == "registered MQTT callback for topic {}".format(
+    assert caplog.records[5].levelno == logging.DEBUG
+    assert caplog.records[5].message == "registered MQTT callback for topic {}".format(
         mqtt_topic_prefix + "/poweroff"
     ) + " triggering {}".format(
         systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"].action
@@ -139,7 +160,7 @@ def test__run_authentication(
     ) as mqtt_loop_forever_mock, unittest.mock.patch(
         "gi.repository.GLib.MainLoop.run"
     ), unittest.mock.patch(
-        "systemctl_mqtt._get_login_manager"
+        "systemctl_mqtt._dbus.get_login_manager"
     ):
         ssl_wrap_socket_mock.return_value.send = len
         systemctl_mqtt._run(
@@ -148,6 +169,8 @@ def test__run_authentication(
             mqtt_username=mqtt_username,
             mqtt_password=mqtt_password,
             mqtt_topic_prefix=mqtt_topic_prefix,
+            homeassistant_discovery_prefix="discovery-prefix",
+            homeassistant_node_id="node-id",
         )
     assert mqtt_loop_forever_mock.call_count == 1
     (mqtt_client,) = mqtt_loop_forever_mock.call_args[0]
@@ -168,7 +191,7 @@ def _initialize_mqtt_client(
     ) as mqtt_loop_forever_mock, unittest.mock.patch(
         "gi.repository.GLib.MainLoop.run"
     ), unittest.mock.patch(
-        "systemctl_mqtt._get_login_manager"
+        "systemctl_mqtt._dbus.get_login_manager"
     ) as get_login_manager_mock:
         ssl_wrap_socket_mock.return_value.send = len
         get_login_manager_mock.return_value.Get.return_value = dbus.Boolean(False)
@@ -178,6 +201,8 @@ def _initialize_mqtt_client(
             mqtt_username=None,
             mqtt_password=None,
             mqtt_topic_prefix=mqtt_topic_prefix,
+            homeassistant_discovery_prefix="discovery-prefix",
+            homeassistant_node_id="node-id",
         )
     while threading.active_count() > 1:
         time.sleep(0.01)
@@ -216,7 +241,7 @@ 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._get_login_manager"
+        "systemctl_mqtt._dbus.get_login_manager"
     ):
         with pytest.raises(ValueError):
             systemctl_mqtt._run(
@@ -225,6 +250,8 @@ def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_passwor
                 mqtt_username=None,
                 mqtt_password=mqtt_password,
                 mqtt_topic_prefix="prefix",
+                homeassistant_discovery_prefix="discovery-prefix",
+                homeassistant_node_id="node-id",
             )
 
 

+ 64 - 8
tests/test_state_dbus.py

@@ -15,6 +15,7 @@
 # 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 json
 import logging
 import unittest.mock
 
@@ -28,8 +29,12 @@ import systemctl_mqtt
 
 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")
+    with unittest.mock.patch("systemctl_mqtt._dbus.get_login_manager"):
+        state = systemctl_mqtt._State(
+            mqtt_topic_prefix="any",
+            homeassistant_discovery_prefix=None,
+            homeassistant_node_id=None,
+        )
         state._login_manager.Inhibit.return_value = lock_fd
         state.acquire_shutdown_lock()
     state._login_manager.Inhibit.assert_called_once_with(
@@ -45,8 +50,12 @@ def test_shutdown_lock():
 
 @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")
+    with unittest.mock.patch("systemctl_mqtt._dbus.get_login_manager"):
+        state = systemctl_mqtt._State(
+            mqtt_topic_prefix="any",
+            homeassistant_discovery_prefix=None,
+            homeassistant_node_id=None,
+        )
     mqtt_client_mock = unittest.mock.MagicMock()
     state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client_mock)
     # pylint: disable=no-member,comparison-with-callable
@@ -83,9 +92,13 @@ def test_publish_preparing_for_shutdown(active):
     login_manager_mock = unittest.mock.MagicMock()
     login_manager_mock.Get.return_value = dbus.Boolean(active)
     with unittest.mock.patch(
-        "systemctl_mqtt._get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
     ):
-        state = systemctl_mqtt._State(mqtt_topic_prefix="any")
+        state = systemctl_mqtt._State(
+            mqtt_topic_prefix="any",
+            homeassistant_discovery_prefix=None,
+            homeassistant_node_id=None,
+        )
     assert state._login_manager == login_manager_mock
     mqtt_client_mock = unittest.mock.MagicMock()
     state.publish_preparing_for_shutdown(mqtt_client=mqtt_client_mock)
@@ -105,9 +118,13 @@ def test_publish_preparing_for_shutdown_get_fail(caplog):
     login_manager_mock = unittest.mock.MagicMock()
     login_manager_mock.Get.side_effect = dbus.DBusException("mocked")
     with unittest.mock.patch(
-        "systemctl_mqtt._get_login_manager", return_value=login_manager_mock
+        "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock
     ):
-        state = systemctl_mqtt._State(mqtt_topic_prefix="any")
+        state = systemctl_mqtt._State(
+            mqtt_topic_prefix="any",
+            homeassistant_discovery_prefix=None,
+            homeassistant_node_id=None,
+        )
     mqtt_client_mock = unittest.mock.MagicMock()
     state.publish_preparing_for_shutdown(mqtt_client=None)
     mqtt_client_mock.publish.assert_not_called()
@@ -117,3 +134,42 @@ def test_publish_preparing_for_shutdown_get_fail(caplog):
         caplog.records[0].message
         == "failed to read logind's PreparingForShutdown property: mocked"
     )
+
+
+@pytest.mark.parametrize("topic_prefix", ["systemctl/hostname", "hostname/systemctl"])
+@pytest.mark.parametrize("discovery_prefix", ["homeassistant", "home/assistant"])
+@pytest.mark.parametrize("node_id", ["node", "node-id"])
+@pytest.mark.parametrize("hostname", ["hostname", "host-name"])
+def test_publish_preparing_for_shutdown_homeassistant_config(
+    topic_prefix, discovery_prefix, node_id, hostname,
+):
+    state = systemctl_mqtt._State(
+        mqtt_topic_prefix=topic_prefix,
+        homeassistant_discovery_prefix=discovery_prefix,
+        homeassistant_node_id=node_id,
+    )
+    mqtt_client = unittest.mock.MagicMock()
+    with unittest.mock.patch(
+        "systemctl_mqtt._utils.get_hostname", return_value=hostname
+    ):
+        state.publish_preparing_for_shutdown_homeassistant_config(
+            mqtt_client=mqtt_client
+        )
+    assert mqtt_client.publish.call_count == 1
+    publish_args, publish_kwargs = mqtt_client.publish.call_args
+    assert not publish_args
+    assert publish_kwargs["retain"]
+    assert (
+        publish_kwargs["topic"]
+        == discovery_prefix
+        + "/binary_sensor/"
+        + node_id
+        + "/preparing-for-shutdown/config"
+    )
+    assert json.loads(publish_kwargs["payload"]) == {
+        "unique_id": "systemctl-mqtt/" + node_id + "/logind/preparing-for-shutdown",
+        "state_topic": topic_prefix + "/preparing-for-shutdown",
+        "payload_on": "true",
+        "payload_off": "false",
+        "name": node_id + " preparing for shutdown",
+    }

+ 29 - 0
tests/test_utils.py

@@ -0,0 +1,29 @@
+# systemctl-mqtt - MQTT client triggering & reporting 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 unittest.mock
+
+import pytest
+
+import systemctl_mqtt._utils
+
+
+@pytest.mark.parametrize("hostname", ["test"])
+def test__get_hostname(hostname):
+    with unittest.mock.patch("socket.gethostname", return_value=hostname):
+        # pylint: disable=protected-access
+        assert systemctl_mqtt._utils.get_hostname() == hostname