소스 검색

Publish device config on homeassistant startup

fixes the device discovery when home assistant is started after systemctl-mqtt, or after a ha restart.

https://github.com/fphammerle/systemctl-mqtt/pull/245
frantzju 4 일 전
부모
커밋
736527acf3
3개의 변경된 파일88개의 추가작업 그리고 3개의 파일을 삭제
  1. 4 0
      CHANGELOG.md
  2. 10 0
      systemctl_mqtt/__init__.py
  3. 74 3
      tests/test_mqtt.py

+ 4 - 0
CHANGELOG.md

@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - add missing dbus rules for systemd Manager and Unit in apparmor profile.
   ([#244](https://github.com/fphammerle/systemctl-mqtt/pull/244)
   by Julien Frantz (julien.frantz@gmail.com))
+- publish Home Assistant device discovery config whenever Home Assistant sends
+  its birth message on startup.
+  ([#244](https://github.com/fphammerle/systemctl-mqtt/pull/244)
+  by Julien Frantz (julien.frantz@gmail.com))
 
 ## [2.0.0] - 2025-11-22
 ### Added

+ 10 - 0
systemctl_mqtt/__init__.py

@@ -46,6 +46,9 @@ _MQTT_DEFAULT_TLS_PORT = 8883
 # 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"
+# https://www.home-assistant.io/integrations/mqtt/#birth-and-last-will-messages
+_HOMEASSISTANT_BIRTH_TOPIC = "homeassistant/status"
+_HOMEASSISTANT_BIRTH_PAYLOAD = b"online"
 _ARGUMENT_LOG_LEVEL_MAPPING = {
     a: getattr(logging, a.upper())
     for a in ("debug", "info", "warning", "error", "critical")
@@ -342,6 +345,9 @@ _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
 
 
 async def _mqtt_message_loop(*, state: _State, mqtt_client: aiomqtt.Client) -> None:
+    _LOGGER.info("subscribing to %s", _HOMEASSISTANT_BIRTH_TOPIC)
+    await mqtt_client.subscribe(_HOMEASSISTANT_BIRTH_TOPIC)
+
     action_by_topic: dict[str, _MQTTAction] = {}
     for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
         topic = state.mqtt_topic_prefix + "/" + topic_suffix
@@ -370,6 +376,10 @@ async def _mqtt_message_loop(*, state: _State, mqtt_client: aiomqtt.Client) -> N
     async for message in mqtt_client.messages:
         if message.retain:
             _LOGGER.info("ignoring retained message on topic %r", message.topic.value)
+        elif message.topic.value == _HOMEASSISTANT_BIRTH_TOPIC:
+            _LOGGER.debug("received homeassistant status: %r", message.payload)
+            if message.payload == _HOMEASSISTANT_BIRTH_PAYLOAD:
+                await state.publish_homeassistant_device_config(mqtt_client=mqtt_client)
         else:
             _LOGGER.debug(
                 "received message on topic %r: %r", message.topic.value, message.payload

+ 74 - 3
tests/test_mqtt.py

@@ -118,6 +118,7 @@ async def test__run(
         "retain": True,
     }
     assert sorted(mqtt_client_mock.subscribe.call_args_list) == [
+        unittest.mock.call("homeassistant/status"),
         unittest.mock.call(mqtt_topic_prefix + "/lock-all-sessions"),
         unittest.mock.call(mqtt_topic_prefix + "/poweroff"),
         unittest.mock.call(mqtt_topic_prefix + "/suspend"),
@@ -142,11 +143,12 @@ async def test__run(
         caplog.records[4].message
         == f"publishing 'false' on {mqtt_topic_prefix}/preparing-for-shutdown"
     )
-    assert all(r.levelno == logging.INFO for r in caplog.records[5::2])
-    assert {r.message for r in caplog.records[5:]} == {
+    assert all(r.levelno == logging.INFO for r in caplog.records[5:])
+    expected_subscription_messages = {
         f"subscribing to {mqtt_topic_prefix}/{s}"
         for s in ("poweroff", "lock-all-sessions", "suspend")
-    }
+    } | {"subscribing to homeassistant/status"}
+    assert {r.message for r in caplog.records[5:]} == expected_subscription_messages
     dbus_signal_loop_mock.assert_awaited_once()
 
 
@@ -360,6 +362,7 @@ async def test__mqtt_message_loop_trigger_poweroff(
             state=state, mqtt_client=mqtt_client_mock
         )
     assert sorted(mqtt_client_mock.subscribe.await_args_list) == [
+        unittest.mock.call("homeassistant/status"),
         unittest.mock.call(mqtt_topic_prefix + "/lock-all-sessions"),
         unittest.mock.call(mqtt_topic_prefix + "/poweroff"),
         unittest.mock.call(mqtt_topic_prefix + "/suspend"),
@@ -420,6 +423,74 @@ async def test__mqtt_message_loop_retained(
     ]
 
 
+@pytest.mark.asyncio
+async def test__mqtt_message_loop_homeassistant_status_online(
+    caplog: pytest.LogCaptureFixture,
+) -> None:
+    mqtt_topic_prefix = "systemctl/host"
+    state = systemctl_mqtt._State(
+        mqtt_topic_prefix=mqtt_topic_prefix,
+        homeassistant_discovery_prefix="homeassistant",
+        homeassistant_discovery_object_id="whatever",
+        poweroff_delay=datetime.timedelta(seconds=21),
+        monitored_system_unit_names=[],
+        controlled_system_unit_names=[],
+    )
+    mqtt_client_mock = unittest.mock.AsyncMock()
+    mqtt_client_mock.messages.__aiter__.return_value = [
+        aiomqtt.Message(
+            topic="homeassistant/status",
+            payload=b"online",
+            qos=0,
+            retain=False,
+            mid=1,
+            properties=None,
+        )
+    ]
+    with unittest.mock.patch.object(
+        state, "publish_homeassistant_device_config"
+    ) as publish_config_mock, caplog.at_level(logging.INFO):
+        await systemctl_mqtt._mqtt_message_loop(
+            state=state, mqtt_client=mqtt_client_mock
+        )
+    publish_config_mock.assert_awaited_once_with(mqtt_client=mqtt_client_mock)
+    assert (
+        unittest.mock.call("homeassistant/status")
+        in mqtt_client_mock.subscribe.await_args_list
+    )
+
+
+@pytest.mark.asyncio
+async def test__mqtt_message_loop_homeassistant_status_offline(caplog) -> None:
+    mqtt_topic_prefix = "systemctl/host"
+    state = systemctl_mqtt._State(
+        mqtt_topic_prefix=mqtt_topic_prefix,
+        homeassistant_discovery_prefix="homeassistant",
+        homeassistant_discovery_object_id="whatever",
+        poweroff_delay=datetime.timedelta(seconds=21),
+        monitored_system_unit_names=[],
+        controlled_system_unit_names=[],
+    )
+    mqtt_client_mock = unittest.mock.AsyncMock()
+    mqtt_client_mock.messages.__aiter__.return_value = [
+        aiomqtt.Message(
+            topic="homeassistant/status",
+            payload=b"offline",
+            qos=0,
+            retain=False,
+            mid=2,
+            properties=None,
+        )
+    ]
+    with unittest.mock.patch.object(
+        state, "publish_homeassistant_device_config"
+    ) as publish_config_mock, caplog.at_level(logging.DEBUG):
+        await systemctl_mqtt._mqtt_message_loop(
+            state=state, mqtt_client=mqtt_client_mock
+        )
+    publish_config_mock.assert_not_called()
+
+
 @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "systemd/raspberrypi"])
 @pytest.mark.parametrize("unit_name", ["foo.service", "bar.service"])
 def test_state_get_system_unit_active_state_mqtt_topic(