Browse Source

add birth & last will message on topic `systemctl/[hostname]/status`

implements https://github.com/fphammerle/systemctl-mqtt/issues/38

https://github.com/fphammerle/switchbot-mqtt/commit/e3e8f0f0b2d6a56961b938403718ceb4363fb219
https://github.com/fphammerle/switchbot-mqtt/commit/9947875774e85ade715fe963d2fa1112624a1394
Fabian Peter Hammerle 3 months ago
parent
commit
c3533ee752
4 changed files with 105 additions and 10 deletions
  1. 3 0
      CHANGELOG.md
  2. 37 6
      systemctl_mqtt/__init__.py
  3. 64 4
      tests/test_mqtt.py
  4. 1 0
      tests/test_state_dbus.py

+ 3 - 0
CHANGELOG.md

@@ -8,7 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 - suspend when receiving message on topic `systemctl/[hostname]/suspend`
   (https://github.com/fphammerle/systemctl-mqtt/issues/97)
+- birth & last will message on topic `systemctl/[hostname]/status`
+  ("online" / "offline", https://github.com/fphammerle/systemctl-mqtt/issues/38)
 - automatic discovery in home assistant:
+  - availability status
   - entity `button.[hostname]_logind_lock_all_sessions`
   - entity `button.[hostname]_logind_poweroff`
   - entity `button.[hostname]_logind_suspend`

+ 37 - 6
systemctl_mqtt/__init__.py

@@ -41,6 +41,10 @@ import systemctl_mqtt._mqtt
 
 _MQTT_DEFAULT_PORT = 1883
 _MQTT_DEFAULT_TLS_PORT = 8883
+# > payload_not_available string (Optional, default: offline)
+# https://web.archive.org/web/20250101075341/https://www.home-assistant.io/integrations/sensor.mqtt/#payload_not_available
+_MQTT_PAYLOAD_NOT_AVAILABLE = "offline"
+_MQTT_PAYLOAD_AVAILABLE = "online"
 _ARGUMENT_LOG_LEVEL_MAPPING = {
     a: getattr(logging, a.upper())
     for a in ("debug", "info", "warning", "error", "critical")
@@ -70,6 +74,14 @@ class _State:
     def mqtt_topic_prefix(self) -> str:
         return self._mqtt_topic_prefix
 
+    @property
+    def mqtt_availability_topic(self) -> str:
+        # > mqtt.ATTR_TOPIC: "homeassistant/status",
+        # https://github.com/home-assistant/core/blob/2024.12.5/tests/components/mqtt/conftest.py#L23
+        # > _MQTT_AVAILABILITY_TOPIC = "switchbot-mqtt/status"
+        # https://github.com/fphammerle/switchbot-mqtt/blob/v3.3.1/switchbot_mqtt/__init__.py#L30
+        return self._mqtt_topic_prefix + "/status"
+
     @property
     def shutdown_lock_acquired(self) -> bool:
         return self._shutdown_lock is not None
@@ -161,6 +173,7 @@ class _State:
                 "sw_version": package_metadata["Version"],
                 "support_url": package_metadata["Home-page"],
             },
+            "availability": {"topic": self.mqtt_availability_topic},
             "components": {
                 "logind/preparing-for-shutdown": {
                     "unique_id": unique_id_prefix + "-logind-preparing-for-shutdown",
@@ -306,18 +319,36 @@ async def _run(  # pylint: disable=too-many-arguments
         tls_context=None if mqtt_disable_tls else ssl.create_default_context(),
         username=None if mqtt_username is None else mqtt_username,
         password=None if mqtt_password is None else mqtt_password,
+        will=aiomqtt.Will(  # e.g. on SIGTERM & SIGKILL
+            topic=state.mqtt_availability_topic,
+            payload=_MQTT_PAYLOAD_NOT_AVAILABLE,
+            retain=True,
+        ),
     ) as mqtt_client:
         _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_host, mqtt_port)
         if not state.shutdown_lock_acquired:
             state.acquire_shutdown_lock()
         await state.publish_homeassistant_device_config(mqtt_client=mqtt_client)
         await state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
-        # asyncio.TaskGroup added in python3.11
-        await asyncio.gather(
-            _mqtt_message_loop(state=state, mqtt_client=mqtt_client),
-            _dbus_signal_loop(state=state, mqtt_client=mqtt_client),
-            return_exceptions=False,
-        )
+        try:
+            await mqtt_client.publish(
+                topic=state.mqtt_availability_topic,
+                payload=_MQTT_PAYLOAD_AVAILABLE,
+                retain=True,
+            )
+            # asynpio.TaskGroup added in python3.11
+            await asyncio.gather(
+                _mqtt_message_loop(state=state, mqtt_client=mqtt_client),
+                _dbus_signal_loop(state=state, mqtt_client=mqtt_client),
+                return_exceptions=False,
+            )
+        finally:  # e.g. on SIGINT
+            # https://web.archive.org/web/20250101080719/https://github.com/empicano/aiomqtt/issues/28
+            await mqtt_client.publish(
+                topic=state.mqtt_availability_topic,
+                payload=_MQTT_PAYLOAD_NOT_AVAILABLE,
+                retain=True,
+            )
 
 
 def _main() -> None:

+ 64 - 4
tests/test_mqtt.py

@@ -77,6 +77,13 @@ async def test__run(
     assert isinstance(mqtt_client_init_kwargs.pop("tls_context"), ssl.SSLContext)
     assert mqtt_client_init_kwargs.pop("username") is None
     assert mqtt_client_init_kwargs.pop("password") is None
+    assert mqtt_client_init_kwargs.pop("will") == aiomqtt.Will(
+        topic=mqtt_topic_prefix + "/status",
+        payload="offline",
+        qos=0,
+        retain=True,
+        properties=None,
+    )
     assert not mqtt_client_init_kwargs
     login_manager_mock.Inhibit.assert_called_once_with(
         what="shutdown",
@@ -87,7 +94,7 @@ async def test__run(
     login_manager_mock.Get.assert_called_once_with("PreparingForShutdown")
     async with mqtt_client_class_mock() as mqtt_client_mock:
         pass
-    assert mqtt_client_mock.publish.call_count == 2
+    assert mqtt_client_mock.publish.call_count == 4
     assert (
         mqtt_client_mock.publish.call_args_list[0][1]["topic"]
         == f"{homeassistant_discovery_prefix}/device/{homeassistant_discovery_object_id}/config"
@@ -97,6 +104,16 @@ async def test__run(
         payload="false",
         retain=False,
     )
+    assert mqtt_client_mock.publish.call_args_list[2][1] == {
+        "topic": mqtt_topic_prefix + "/status",
+        "payload": "online",
+        "retain": True,
+    }
+    assert mqtt_client_mock.publish.call_args_list[3][1] == {
+        "topic": mqtt_topic_prefix + "/status",
+        "payload": "offline",
+        "retain": True,
+    }
     assert sorted(mqtt_client_mock.subscribe.call_args_list) == [
         unittest.mock.call(mqtt_topic_prefix + "/lock-all-sessions"),
         unittest.mock.call(mqtt_topic_prefix + "/poweroff"),
@@ -160,9 +177,7 @@ async def test__run_tls(caplog, mqtt_host, mqtt_port, mqtt_disable_tls):
         assert mqtt_client_init_kwargs.pop("tls_context") is None
     else:
         assert isinstance(mqtt_client_init_kwargs.pop("tls_context"), ssl.SSLContext)
-    assert mqtt_client_init_kwargs.pop("username") is None
-    assert mqtt_client_init_kwargs.pop("password") is None
-    assert not mqtt_client_init_kwargs
+    assert set(mqtt_client_init_kwargs.keys()) == {"username", "password", "will"}
     assert caplog.records[0].levelno == logging.INFO
     assert caplog.records[0].message == (
         f"connecting to MQTT broker {mqtt_host}:{mqtt_port}"
@@ -256,6 +271,51 @@ async def test__run_authentication_missing_username(
 
 
 @pytest.mark.asyncio
+@pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host"])
+async def test__run_sigint(mqtt_topic_prefix: str):
+    login_manager_mock = unittest.mock.MagicMock()
+    with unittest.mock.patch(
+        "aiomqtt.Client", autospec=False
+    ) as mqtt_client_class_mock, unittest.mock.patch(
+        "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
+    ), unittest.mock.patch(
+        "asyncio.gather", side_effect=KeyboardInterrupt
+    ):
+        login_manager_mock.Inhibit.return_value = (jeepney.fds.FileDescriptor(-1),)
+        login_manager_mock.Get.return_value = (("b", False),)
+        with pytest.raises(KeyboardInterrupt):
+            await systemctl_mqtt._run(
+                mqtt_host="mqtt-broker.local",
+                mqtt_port=1883,
+                mqtt_username=None,
+                mqtt_password=None,
+                mqtt_topic_prefix=mqtt_topic_prefix,
+                homeassistant_discovery_prefix="homeassistant",
+                homeassistant_discovery_object_id="host",
+                poweroff_delay=datetime.timedelta(),
+            )
+    async with mqtt_client_class_mock() as mqtt_client_mock:
+        pass
+    assert mqtt_client_mock.publish.call_count == 4
+    assert mqtt_client_mock.publish.call_args_list[0][1]["topic"].endswith("/config")
+    assert mqtt_client_mock.publish.call_args_list[1][1]["topic"].endswith(
+        "/preparing-for-shutdown"
+    )
+    assert mqtt_client_mock.publish.call_args_list[2][1] == {
+        "topic": mqtt_topic_prefix + "/status",
+        "payload": "online",
+        "retain": True,
+    }
+    assert mqtt_client_mock.publish.call_args_list[3][1] == {
+        "topic": mqtt_topic_prefix + "/status",
+        "payload": "offline",
+        "retain": True,
+    }
+
+
+@pytest.mark.asyncio
+@pytest.mark.filterwarnings("ignore:coroutine '_dbus_signal_loop' was never awaited")
+@pytest.mark.filterwarnings("ignore:coroutine '_mqtt_message_loop' was never awaited")
 @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
 async def test__mqtt_message_loop_trigger_poweroff(
     caplog: pytest.LogCaptureFixture, mqtt_topic_prefix: str

+ 1 - 0
tests/test_state_dbus.py

@@ -176,6 +176,7 @@ async def test_publish_homeassistant_device_config(
             "support_url": "https://github.com/fphammerle/systemctl-mqtt",
         },
         "device": {"identifiers": [hostname], "name": hostname},
+        "availability": {"topic": topic_prefix + "/status"},
         "components": {
             "logind/preparing-for-shutdown": {
                 "unique_id": f"systemctl-mqtt-{hostname}-logind-preparing-for-shutdown",