Browse Source

home assistant: replace component-based with device-based discovery & disable "retain" flag

Fabian Peter Hammerle 2 months ago
parent
commit
1c2a8ca6d9
10 changed files with 142 additions and 105 deletions
  1. 12 0
      CHANGELOG.md
  2. 5 11
      README.md
  3. 2 1
      setup.py
  4. 52 38
      systemctl_mqtt/__init__.py
  5. 4 4
      systemctl_mqtt/_homeassistant.py
  6. 2 2
      tests/test_action.py
  7. 12 9
      tests/test_cli.py
  8. 11 6
      tests/test_homeassistant.py
  9. 11 11
      tests/test_mqtt.py
  10. 31 23
      tests/test_state_dbus.py

+ 12 - 0
CHANGELOG.md

@@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+### Changed
+- automatic discovery in home assistant:
+  - replace component-based (topic:
+    `<discovery_prefix>/binary_sensor/<node_id>/preparing-for-shutdown/config`)
+    with device-based discovery (`<discovery_prefix>/device/<object_id>/config`)
+  - replace command-line option `--homeassistant-node-id` with
+    `--homeassistant-discovery-object-id`
+  - rename entity `binary_sensor.[hostname]_preparing_for_shutdown` to
+    `binary_sensor.[hostname]_logind_preparing_for_shutdown`
+  - disable "retain" flag for discovery messages
+    (to avoid reappearing ghost devices)
+
 ### Fixed
 - apparmor profile for architectures other than x86_64/amd64
   (`ImportError: Error loading [...]/_gi.cpython-38-aarch64-linux-gnu.so: Permission denied`)

+ 5 - 11
README.md

@@ -86,20 +86,14 @@ automation:
 
 ### 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 # default in home assistant >=v0.117.0
-  # credentials, additional options…
-```
+When [MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
+is enabled (default in Home Assistant ≥0.117.0), a new entity
+`binary_sensor.hostname_logind_preparing_for_shutdown` will be added
+automatically.
 
 ![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`
+When using a custom discovery topic prefix
 pass `--homeassistant-discovery-prefix custom-prefix` to `systemctl-mqtt`.
 
 ## Docker 🐳

+ 2 - 1
setup.py

@@ -66,7 +66,8 @@ setuptools.setup(
         ]
     },
     # >=3.6 variable type hints, f-strings & * to force keyword-only arguments
-    python_requires=">=3.8",  # python<3.8 untested
+    # >=3.8 importlib.metadata
+    python_requires=">=3.8",
     # https://dbus.freedesktop.org/doc/dbus-python/news.html
     install_requires=["PyGObject<4", "dbus-python<2", "paho-mqtt<2"],
     setup_requires=["setuptools_scm"],

+ 52 - 38
systemctl_mqtt/__init__.py

@@ -19,6 +19,7 @@ import abc
 import argparse
 import datetime
 import functools
+import importlib.metadata
 import json
 import logging
 import os
@@ -51,12 +52,12 @@ class _State:
         *,
         mqtt_topic_prefix: str,
         homeassistant_discovery_prefix: str,
-        homeassistant_node_id: str,
+        homeassistant_discovery_object_id: str,
         poweroff_delay: datetime.timedelta,
     ) -> None:
         self._mqtt_topic_prefix = mqtt_topic_prefix
         self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
-        self._homeassistant_node_id = homeassistant_node_id
+        self._homeassistant_discovery_object_id = homeassistant_discovery_object_id
         self._login_manager: dbus.proxies.Interface = (
             systemctl_mqtt._dbus.get_login_manager()
         )
@@ -158,41 +159,48 @@ class _State:
             block=False,
         )
 
-    def publish_preparing_for_shutdown_homeassistant_config(
+    def publish_homeassistant_device_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/
+        # https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery
         discovery_topic = "/".join(
             (
                 self._homeassistant_discovery_prefix,
-                "binary_sensor",
-                self._homeassistant_node_id,
-                "preparing-for-shutdown",
+                "device",
+                self._homeassistant_discovery_object_id,
                 "config",
             )
         )
-        unique_id = "/".join(
-            (
-                "systemctl-mqtt",
-                self._homeassistant_node_id,
-                "logind",
-                "preparing-for-shutdown",
-            )
+        hostname = (
+            # pylint: disable=protected-access; function in internal module
+            systemctl_mqtt._utils.get_hostname()
         )
-        # https://www.home-assistant.io/integrations/binary_sensor.mqtt/#configuration-variables
+        package_metadata = importlib.metadata.metadata(__name__)
+        unique_id_prefix = "systemctl-mqtt-" + hostname
         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": f"{self._homeassistant_node_id} preparing for shutdown",
+            "device": {"identifiers": [hostname], "name": hostname},
+            "origin": {
+                "name": package_metadata["Name"],
+                "sw_version": package_metadata["Version"],
+                "support_url": package_metadata["Home-page"],
+            },
+            "components": {
+                "logind/preparing-for-shutdown": {
+                    "unique_id": unique_id_prefix + "-logind-preparing-for-shutdown",
+                    "object_id": f"{hostname}_logind_preparing_for_shutdown",
+                    "name": "preparing for shutdown",  # home assistant prepends device name
+                    "platform": "binary_sensor",
+                    "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),
+                },
+            },
         }
         _LOGGER.debug("publishing home assistant config on %s", discovery_topic)
         mqtt_client.publish(
-            topic=discovery_topic, payload=json.dumps(config), retain=True
+            topic=discovery_topic, payload=json.dumps(config), retain=False
         )
 
 
@@ -260,7 +268,7 @@ def _mqtt_on_connect(
         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)
+    state.publish_homeassistant_device_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)
@@ -281,7 +289,7 @@ def _run(
     mqtt_password: typing.Optional[str],
     mqtt_topic_prefix: str,
     homeassistant_discovery_prefix: str,
-    homeassistant_node_id: str,
+    homeassistant_discovery_object_id: str,
     poweroff_delay: datetime.timedelta,
     mqtt_disable_tls: bool = False,
 ) -> None:
@@ -293,7 +301,7 @@ def _run(
         userdata=_State(
             mqtt_topic_prefix=mqtt_topic_prefix,
             homeassistant_discovery_prefix=homeassistant_discovery_prefix,
-            homeassistant_node_id=homeassistant_node_id,
+            homeassistant_discovery_object_id=homeassistant_discovery_object_id,
             poweroff_delay=poweroff_delay,
         )
     )
@@ -333,7 +341,6 @@ def _main() -> None:
     )
     argparser = argparse.ArgumentParser(
         description="MQTT client triggering & reporting shutdown on systemd-based systems",
-        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
     )
     argparser.add_argument("--mqtt-host", type=str, required=True)
     argparser.add_argument(
@@ -357,21 +364,24 @@ def _main() -> None:
         type=str,
         # pylint: disable=protected-access
         default="systemctl/" + systemctl_mqtt._utils.get_hostname(),
-        help=" ",  # show default
+        help="default: %(default)s",
     )
     # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
     argparser.add_argument(
-        "--homeassistant-discovery-prefix", type=str, default="homeassistant", help=" "
+        "--homeassistant-discovery-prefix",
+        type=str,
+        default="homeassistant",
+        help="home assistant's prefix for discovery topics" + " (default: %(default)s)",
     )
     argparser.add_argument(
-        "--homeassistant-node-id",
+        "--homeassistant-discovery-object-id",
         type=str,
         # pylint: disable=protected-access
-        default=systemctl_mqtt._homeassistant.get_default_node_id(),
-        help=" ",
+        default=systemctl_mqtt._homeassistant.get_default_discovery_object_id(),
+        help="part of discovery topic (default: %(default)s)",
     )
     argparser.add_argument(
-        "--poweroff-delay-seconds", type=float, default=4.0, help=" "
+        "--poweroff-delay-seconds", type=float, default=4.0, help="default: %(default)s"
     )
     args = argparser.parse_args()
     if args.mqtt_port:
@@ -390,12 +400,16 @@ def _main() -> None:
     else:
         mqtt_password = args.mqtt_password
     # pylint: disable=protected-access
-    if not systemctl_mqtt._homeassistant.validate_node_id(args.homeassistant_node_id):
+    if not systemctl_mqtt._homeassistant.validate_discovery_object_id(
+        args.homeassistant_discovery_object_id
+    ):
         raise ValueError(
             # pylint: disable=protected-access
-            f"invalid home assistant node id {args.homeassistant_node_id!r} (length >= 1"
-            f", allowed characters: {systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS})"
-            "\nchange --homeassistant-node-id"
+            "invalid home assistant discovery object id"
+            f" {args.homeassistant_discovery_object_id!r} (length >= 1"
+            ", allowed characters:"
+            f" {systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS})"
+            "\nchange --homeassistant-discovery-object-id"
         )
     _run(
         mqtt_host=args.mqtt_host,
@@ -405,6 +419,6 @@ def _main() -> None:
         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,
+        homeassistant_discovery_object_id=args.homeassistant_discovery_object_id,
         poweroff_delay=datetime.timedelta(seconds=args.poweroff_delay_seconds),
     )

+ 4 - 4
systemctl_mqtt/_homeassistant.py

@@ -22,14 +22,14 @@ import systemctl_mqtt._utils
 NODE_ID_ALLOWED_CHARS = r"a-zA-Z0-9_-"
 
 
-def get_default_node_id() -> str:
+def get_default_discovery_object_id() -> str:
     return re.sub(
         f"[^{NODE_ID_ALLOWED_CHARS}]",
         "",
         # pylint: disable=protected-access
-        systemctl_mqtt._utils.get_hostname(),
+        "systemctl-mqtt-" + systemctl_mqtt._utils.get_hostname(),
     )
 
 
-def validate_node_id(node_id: str) -> bool:
-    return re.match(f"^[{NODE_ID_ALLOWED_CHARS}]+$", node_id) is not None
+def validate_discovery_object_id(object_id: str) -> bool:
+    return re.match(f"^[{NODE_ID_ALLOWED_CHARS}]+$", object_id) is not None

+ 2 - 2
tests/test_action.py

@@ -20,7 +20,7 @@ def test_poweroff_trigger(delay):
             state=systemctl_mqtt._State(
                 mqtt_topic_prefix="systemctl/hostname",
                 homeassistant_discovery_prefix="homeassistant",
-                homeassistant_node_id="node",
+                homeassistant_discovery_object_id="node",
                 poweroff_delay=delay,
             )
         )
@@ -40,7 +40,7 @@ def test_mqtt_topic_suffix_action_mapping_poweroff(topic_suffix, expected_action
             state=systemctl_mqtt._State(
                 mqtt_topic_prefix="systemctl/hostname",
                 homeassistant_discovery_prefix="homeassistant",
-                homeassistant_node_id="node",
+                homeassistant_discovery_object_id="node",
                 poweroff_delay=datetime.timedelta(),
             )
         )

+ 12 - 9
tests/test_cli.py

@@ -159,7 +159,7 @@ def test__main(
         mqtt_password=expected_password,
         mqtt_topic_prefix=expected_topic_prefix or "systemctl/hostname",
         homeassistant_discovery_prefix="homeassistant",
-        homeassistant_node_id="hostname",
+        homeassistant_discovery_object_id="systemctl-mqtt-hostname",
         poweroff_delay=datetime.timedelta(seconds=4),
     )
 
@@ -206,7 +206,7 @@ def test__main_password_file(tmpdir, password_file_content, expected_password):
         mqtt_password=expected_password,
         mqtt_topic_prefix="systemctl/hostname",
         homeassistant_discovery_prefix="homeassistant",
-        homeassistant_node_id="hostname",
+        homeassistant_discovery_object_id="systemctl-mqtt-hostname",
         poweroff_delay=datetime.timedelta(seconds=4),
     )
 
@@ -254,13 +254,13 @@ def test__main_homeassistant_discovery_prefix(args, discovery_prefix):
 
 
 @pytest.mark.parametrize(
-    ("args", "node_id"),
+    ("args", "object_id"),
     [
-        ([], "fallback"),
-        (["--homeassistant-node-id", "raspberrypi"], "raspberrypi"),
+        ([], "systemctl-mqtt-fallback"),
+        (["--homeassistant-discovery-object-id", "raspberrypi"], "raspberrypi"),
     ],
 )
-def test__main_homeassistant_node_id(args, node_id):
+def test__main_homeassistant_discovery_object_id(args, object_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(
@@ -268,14 +268,17 @@ def test__main_homeassistant_node_id(args, node_id):
     ):
         systemctl_mqtt._main()
     run_mock.assert_called_once()
-    assert run_mock.call_args[1]["homeassistant_node_id"] == node_id
+    assert run_mock.call_args[1]["homeassistant_discovery_object_id"] == object_id
 
 
 @pytest.mark.parametrize(
     "args",
-    [["--homeassistant-node-id", "no pe"], ["--homeassistant-node-id", ""]],
+    [
+        ["--homeassistant-discovery-object-id", "no pe"],
+        ["--homeassistant-discovery-object-id", ""],
+    ],
 )
-def test__main_homeassistant_node_id_invalid(args):
+def test__main_homeassistant_discovery_object_id_invalid(args):
     with unittest.mock.patch(
         "sys.argv", ["", "--mqtt-host", "mqtt-broker.local"] + args
     ):

+ 11 - 6
tests/test_homeassistant.py

@@ -25,7 +25,7 @@ import systemctl_mqtt._homeassistant
 
 
 @pytest.mark.parametrize(
-    ("hostname", "expected_node_id"),
+    ("hostname", "expected_object_id"),
     [
         ("raspberrypi", "raspberrypi"),
         ("da-sh", "da-sh"),
@@ -33,15 +33,18 @@ import systemctl_mqtt._homeassistant
         ("someone evil mocked the hostname", "someoneevilmockedthehostname"),
     ],
 )
-def test_get_default_node_id(hostname, expected_node_id):
+def test_get_default_discovery_object_id(hostname, expected_object_id):
     with unittest.mock.patch(
         "systemctl_mqtt._utils.get_hostname", return_value=hostname
     ):
-        assert systemctl_mqtt._homeassistant.get_default_node_id() == expected_node_id
+        assert (
+            systemctl_mqtt._homeassistant.get_default_discovery_object_id()
+            == "systemctl-mqtt-" + expected_object_id
+        )
 
 
 @pytest.mark.parametrize(
-    ("node_id", "valid"),
+    ("object_id", "valid"),
     [
         ("raspberrypi", True),
         ("da-sh", True),
@@ -50,5 +53,7 @@ def test_get_default_node_id(hostname, expected_node_id):
         ("", False),
     ],
 )
-def test_validate_node_id(node_id, valid):
-    assert systemctl_mqtt._homeassistant.validate_node_id(node_id) == valid
+def test_validate_discovery_object_id(object_id, valid):
+    assert (
+        systemctl_mqtt._homeassistant.validate_discovery_object_id(object_id) == valid
+    )

+ 11 - 11
tests/test_mqtt.py

@@ -35,14 +35,14 @@ import systemctl_mqtt
 @pytest.mark.parametrize("mqtt_port", [1833])
 @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
 @pytest.mark.parametrize("homeassistant_discovery_prefix", ["homeassistant"])
-@pytest.mark.parametrize("homeassistant_node_id", ["host", "node"])
+@pytest.mark.parametrize("homeassistant_discovery_object_id", ["host", "node"])
 def test__run(
     caplog,
     mqtt_host,
     mqtt_port,
     mqtt_topic_prefix,
     homeassistant_discovery_prefix,
-    homeassistant_node_id,
+    homeassistant_discovery_object_id,
 ):
     # pylint: disable=too-many-locals,too-many-arguments
     caplog.set_level(logging.DEBUG)
@@ -66,7 +66,7 @@ def test__run(
             mqtt_password=None,
             mqtt_topic_prefix=mqtt_topic_prefix,
             homeassistant_discovery_prefix=homeassistant_discovery_prefix,
-            homeassistant_node_id=homeassistant_node_id,
+            homeassistant_discovery_object_id=homeassistant_discovery_object_id,
             poweroff_delay=datetime.timedelta(),
         )
     assert caplog.records[0].levelno == logging.INFO
@@ -131,9 +131,9 @@ def test__run(
         caplog.records[3].message
         == "publishing home assistant config on "
         + homeassistant_discovery_prefix
-        + "/binary_sensor/"
-        + homeassistant_node_id
-        + "/preparing-for-shutdown/config"
+        + "/device/"
+        + homeassistant_discovery_object_id
+        + "/config"
     )
     assert all(r.levelno == logging.INFO for r in caplog.records[4::2])
     assert {r.message for r in caplog.records[4::2]} == {
@@ -169,7 +169,7 @@ def test__run_tls(caplog, mqtt_host, mqtt_port, mqtt_disable_tls):
             mqtt_password=None,
             mqtt_topic_prefix="systemctl/hosts",
             homeassistant_discovery_prefix="homeassistant",
-            homeassistant_node_id="host",
+            homeassistant_discovery_object_id="host",
             poweroff_delay=datetime.timedelta(),
         )
     assert caplog.records[0].levelno == logging.INFO
@@ -195,7 +195,7 @@ def test__run_tls_default():
             mqtt_password=None,
             mqtt_topic_prefix="systemctl/hosts",
             homeassistant_discovery_prefix="homeassistant",
-            homeassistant_node_id="host",
+            homeassistant_discovery_object_id="host",
             poweroff_delay=datetime.timedelta(),
         )
     # enabled by default
@@ -227,7 +227,7 @@ def test__run_authentication(
             mqtt_password=mqtt_password,
             mqtt_topic_prefix=mqtt_topic_prefix,
             homeassistant_discovery_prefix="discovery-prefix",
-            homeassistant_node_id="node-id",
+            homeassistant_discovery_object_id="node-id",
             poweroff_delay=datetime.timedelta(),
         )
     mqtt_loop_forever_mock.assert_called_once()
@@ -260,7 +260,7 @@ def _initialize_mqtt_client(
             mqtt_password=None,
             mqtt_topic_prefix=mqtt_topic_prefix,
             homeassistant_discovery_prefix="discovery-prefix",
-            homeassistant_node_id="node-id",
+            homeassistant_discovery_object_id="node-id",
             poweroff_delay=datetime.timedelta(),
         )
     while threading.active_count() > 1:
@@ -311,7 +311,7 @@ def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_passwor
                 mqtt_password=mqtt_password,
                 mqtt_topic_prefix="prefix",
                 homeassistant_discovery_prefix="discovery-prefix",
-                homeassistant_node_id="node-id",
+                homeassistant_discovery_object_id="node-id",
                 poweroff_delay=datetime.timedelta(),
             )
 

+ 31 - 23
tests/test_state_dbus.py

@@ -18,6 +18,7 @@
 import datetime
 import json
 import logging
+import re
 import unittest.mock
 
 import dbus.types
@@ -34,7 +35,7 @@ def test_shutdown_lock():
         state = systemctl_mqtt._State(
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
-            homeassistant_node_id=None,
+            homeassistant_discovery_object_id=None,
             poweroff_delay=datetime.timedelta(),
         )
         state._login_manager.Inhibit.return_value = lock_fd
@@ -56,7 +57,7 @@ def test_prepare_for_shutdown_handler(caplog, active):
         state = systemctl_mqtt._State(
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
-            homeassistant_node_id=None,
+            homeassistant_discovery_object_id=None,
             poweroff_delay=datetime.timedelta(),
         )
     mqtt_client_mock = unittest.mock.MagicMock()
@@ -100,7 +101,7 @@ def test_publish_preparing_for_shutdown(active):
         state = systemctl_mqtt._State(
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
-            homeassistant_node_id=None,
+            homeassistant_discovery_object_id=None,
             poweroff_delay=datetime.timedelta(),
         )
     assert state._login_manager == login_manager_mock
@@ -127,7 +128,7 @@ def test_publish_preparing_for_shutdown_get_fail(caplog):
         state = systemctl_mqtt._State(
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
-            homeassistant_node_id=None,
+            homeassistant_discovery_object_id=None,
             poweroff_delay=datetime.timedelta(),
         )
     mqtt_client_mock = unittest.mock.MagicMock()
@@ -143,39 +144,46 @@ def test_publish_preparing_for_shutdown_get_fail(caplog):
 
 @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("object_id", ["raspberrypi", "debian21"])
 @pytest.mark.parametrize("hostname", ["hostname", "host-name"])
-def test_publish_preparing_for_shutdown_homeassistant_config(
-    topic_prefix, discovery_prefix, node_id, hostname
+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_node_id=node_id,
+        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
     ):
-        state.publish_preparing_for_shutdown_homeassistant_config(
-            mqtt_client=mqtt_client
-        )
+        state.publish_homeassistant_device_config(mqtt_client=mqtt_client)
     mqtt_client.publish.assert_called_once()
     publish_args, publish_kwargs = mqtt_client.publish.call_args
     assert not publish_args
-    assert publish_kwargs["retain"]
+    assert not publish_kwargs["retain"]
     assert (
-        publish_kwargs["topic"]
-        == discovery_prefix
-        + "/binary_sensor/"
-        + node_id
-        + "/preparing-for-shutdown/config"
+        publish_kwargs["topic"] == discovery_prefix + "/device/" + object_id + "/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",
+    config = json.loads(publish_kwargs["payload"])
+    assert re.match(r"\d+\.\d+\.", config["origin"].pop("sw_version"))
+    assert config == {
+        "origin": {
+            "name": "systemctl-mqtt",
+            "support_url": "https://github.com/fphammerle/systemctl-mqtt",
+        },
+        "device": {"identifiers": [hostname], "name": hostname},
+        "components": {
+            "logind/preparing-for-shutdown": {
+                "unique_id": f"systemctl-mqtt-{hostname}-logind-preparing-for-shutdown",
+                "object_id": f"{hostname}_logind_preparing_for_shutdown",
+                "name": "preparing for shutdown",
+                "platform": "binary_sensor",
+                "state_topic": topic_prefix + "/preparing-for-shutdown",
+                "payload_on": "true",
+                "payload_off": "false",
+            }
+        },
     }