Browse Source

publish home assistant discovery config

Fabian Peter Hammerle 3 years ago
parent
commit
34c3188f60

+ 9 - 0
README.md

@@ -61,6 +61,15 @@ $ mosquitto_pub -h MQTT_BROKER -t intertechno-cc1101/some-name/set -m ON
 
 ## Home Assistant 🏡
 
+### Automatic Discovery
+
+[Home Assistant](https://www.home-assistant.io/) will detect devices specified in `--alias-file aliases.json`
+automatically, if connected to the same MQTT broker
+and [MQTT discovery](https://www.home-assistant.io/docs/mqtt/discovery/) is enabled
+(enabled by default since version [0.117.0](https://github.com/home-assistant/core/commit/306ee305747a4f7ba758352503f99f221f0ad85a)).
+
+### Manual Configuration
+
 ```yaml
 # https://www.home-assistant.io/docs/mqtt/broker/#configuration-variables
 mqtt:

+ 49 - 0
intertechno_cc1101_mqtt/__init__.py

@@ -7,6 +7,8 @@ import typing
 import paho.mqtt.client
 import intertechno_cc1101
 
+import intertechno_cc1101_mqtt._homeassistant
+
 _LOGGER = logging.getLogger(__name__)
 
 Aliases = typing.Dict[str, typing.Dict[str, int]]
@@ -81,6 +83,50 @@ def _mqtt_on_message(
         _LOGGER.error("failed to send signal", exc_info=True)
 
 
+def _publish_homeassistant_discovery_configs(
+    mqtt_client: paho.mqtt.client.Client, aliases: Aliases
+) -> None:
+    # <discovery_prefix>/<component>/[<node_id>/]<object_id>/config
+    # https://www.home-assistant.io/docs/mqtt/discovery/
+    # https://www.home-assistant.io/integrations/switch.mqtt/#configuration-variables
+    # https://github.com/fphammerle/systemctl-mqtt/blob/v0.5.0/systemctl_mqtt/__init__.py#L163
+    # https://github.com/fphammerle/wireless-sensor-mqtt/blob/v0.3.0/wireless_sensor_mqtt/__init__.py#L153
+    for alias in aliases.keys():
+        # pylint: disable=protected-access; internal
+        if not intertechno_cc1101_mqtt._homeassistant.validate_object_id(alias):
+            if len(alias) == 0:
+                _LOGGER.warning(
+                    "empty alias; skipping publishing of discovery config for home assistant"
+                )
+                continue
+            _LOGGER.warning(
+                "alias %r contains characters unsupported by home assistant"
+                " (supported characters: %s); skipping publishing of discovery config",
+                alias,
+                intertechno_cc1101_mqtt._homeassistant.OBJECT_ID_ALLOWED_CHARS,
+            )
+            continue
+        discovery_topic = "homeassistant/switch/{}/config".format(alias)
+        _LOGGER.debug(
+            "publishing home assistant discovery config on topic %s", discovery_topic
+        )
+        mqtt_client.publish(
+            topic="homeassistant/switch/{}/config".format(alias),
+            payload=json.dumps(
+                {
+                    "unique_id": "intertechno-cc1101-mqtt/aliases/{}".format(alias),
+                    "command_topic": "intertechno-cc1101/{}/set".format(alias),
+                    "payload_on": "ON",  # default
+                    "payload_off": "OFF",  # default
+                    "retain": "true",
+                    # friendly_name & template for default entity_id
+                    "name": alias,
+                }
+            ),
+            retain=True,
+        )
+
+
 def _mqtt_on_connect(
     mqtt_client: paho.mqtt.client.Client,
     aliases: Aliases,
@@ -101,6 +147,9 @@ def _mqtt_on_connect(
         set_alias_topic = "intertechno-cc1101/+/set"
         _LOGGER.info("subscribing to MQTT topic %r (alias)", set_alias_topic)
         mqtt_client.subscribe(set_alias_topic)
+        _publish_homeassistant_discovery_configs(
+            mqtt_client=mqtt_client, aliases=aliases
+        )
 
 
 def _run(

+ 11 - 0
intertechno_cc1101_mqtt/_homeassistant.py

@@ -0,0 +1,11 @@
+# https://github.com/fphammerle/wireless-sensor-mqtt/blob/v0.3.0/wireless_sensor_mqtt/_homeassistant.py
+# https://github.com/fphammerle/systemctl-mqtt/blob/v0.5.0/systemctl_mqtt/_homeassistant.py
+
+import re
+
+# https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
+OBJECT_ID_ALLOWED_CHARS = r"a-zA-Z0-9_-"
+
+
+def validate_object_id(object_id: str) -> bool:
+    return re.match(r"^[{}]+$".format(OBJECT_ID_ALLOWED_CHARS), object_id) is not None

+ 128 - 0
tests/test_homeassistant.py

@@ -0,0 +1,128 @@
+import json
+import logging
+import unittest.mock
+
+import pytest
+
+import intertechno_cc1101_mqtt
+import intertechno_cc1101_mqtt._homeassistant
+
+# pylint: disable=protected-access
+
+
+# https://github.com/fphammerle/wireless-sensor-mqtt/blob/v0.3.0/tests/test_homeassistant.py
+@pytest.mark.parametrize(
+    ("object_id", "valid"),
+    [
+        ("raspberrypi", True),
+        ("da-sh", True),
+        ("under_score", True),
+        ('" or ""="', False),
+        ("", False),
+    ],
+)
+def test_validate_object_id(object_id, valid):
+    assert intertechno_cc1101_mqtt._homeassistant.validate_object_id(object_id) == valid
+
+
+@pytest.mark.parametrize(
+    ("aliases", "discovery_topics", "unique_ids", "command_topics"),
+    (
+        ({}, [], [], []),
+        (
+            {
+                "some-name": {"address": 12345678, "button-index": 0},
+                "another-name": {"address": 12345678, "button-index": 1},
+            },
+            [
+                "homeassistant/switch/some-name/config",
+                "homeassistant/switch/another-name/config",
+            ],
+            [
+                "intertechno-cc1101-mqtt/aliases/some-name",
+                "intertechno-cc1101-mqtt/aliases/another-name",
+            ],
+            ["intertechno-cc1101/some-name/set", "intertechno-cc1101/another-name/set"],
+        ),
+    ),
+)
+def test__publish_homeassistant_discovery_configs(
+    caplog, aliases, discovery_topics, unique_ids, command_topics
+):
+    mqtt_client = unittest.mock.MagicMock()
+    with caplog.at_level(logging.DEBUG):
+        intertechno_cc1101_mqtt._publish_homeassistant_discovery_configs(
+            mqtt_client=mqtt_client, aliases=aliases
+        )
+    assert mqtt_client.publish.call_count == len(aliases)
+    assert all(
+        not call.args and set(call.kwargs.keys()) == {"topic", "payload", "retain"}
+        for call in mqtt_client.publish.call_args_list
+    )
+    assert [
+        call.kwargs["topic"] for call in mqtt_client.publish.call_args_list
+    ] == discovery_topics
+    assert all(
+        call.kwargs["retain"] is True for call in mqtt_client.publish.call_args_list
+    )
+    configs = [
+        json.loads(call.kwargs["payload"])
+        for call in mqtt_client.publish.call_args_list
+    ]
+    assert all(
+        set(c.keys())
+        == {"unique_id", "command_topic", "payload_on", "payload_off", "retain", "name"}
+        for c in configs
+    )
+    assert all(
+        c["payload_on"] == "ON" and c["payload_off"] == "OFF" and c["retain"] == "true"
+        for c in configs
+    )
+    assert [c["unique_id"] for c in configs] == unique_ids
+    assert [c["command_topic"] for c in configs] == command_topics
+    assert [c["name"] for c in configs] == list(aliases.keys())
+    assert caplog.record_tuples == [
+        (
+            "intertechno_cc1101_mqtt",
+            logging.DEBUG,
+            "publishing home assistant discovery config on topic {}".format(topic),
+        )
+        for topic in discovery_topics
+    ]
+
+
+def test__publish_homeassistant_discovery_configs_invalid_object_id(caplog):
+    mqtt_client = unittest.mock.MagicMock()
+    with caplog.at_level(logging.DEBUG):
+        intertechno_cc1101_mqtt._publish_homeassistant_discovery_configs(
+            mqtt_client=mqtt_client,
+            aliases={
+                "": {"address": 12345678, "button-index": 0},
+                "invalid/alias": {"address": 12345678, "button-index": 0},
+                "valid-alias": {"address": 12345678, "button-index": 0},
+            },
+        )
+    assert mqtt_client.publish.call_count == 1
+    assert (
+        mqtt_client.publish.call_args.kwargs["topic"]
+        == "homeassistant/switch/valid-alias/config"
+    )
+    assert caplog.record_tuples == [
+        (
+            "intertechno_cc1101_mqtt",
+            logging.WARNING,
+            "empty alias; skipping publishing of discovery config for home assistant",
+        ),
+        (
+            "intertechno_cc1101_mqtt",
+            logging.WARNING,
+            "alias 'invalid/alias' contains characters unsupported by home assistant"
+            " (supported characters: a-zA-Z0-9_-); skipping publishing of discovery config",
+        ),
+        (
+            "intertechno_cc1101_mqtt",
+            logging.DEBUG,
+            "publishing home assistant discovery config"
+            " on topic homeassistant/switch/valid-alias/config",
+        ),
+    ]

+ 7 - 1
tests/test_mqtt.py

@@ -105,7 +105,9 @@ def test__run_alias_file_path(caplog, tmp_path, mqtt_host, mqtt_port, aliases):
         )
     mqtt_client_mock.assert_called_once_with(userdata=aliases)
     mqtt_client_mock().socket().getpeername.return_value = (mqtt_host, mqtt_port)
-    with caplog.at_level(logging.INFO):
+    with unittest.mock.patch(
+        "intertechno_cc1101_mqtt._publish_homeassistant_discovery_configs"
+    ) as publish_discovery_config_mock, caplog.at_level(logging.INFO):
         mqtt_client_mock().on_connect(mqtt_client_mock(), aliases, {}, 0)
     assert mqtt_client_mock().subscribe.call_args_list[0] == unittest.mock.call(
         "intertechno-cc1101/+/+/set"
@@ -118,6 +120,7 @@ def test__run_alias_file_path(caplog, tmp_path, mqtt_host, mqtt_port, aliases):
     if len(aliases) == 0:
         assert mqtt_client_mock().subscribe.call_count == 1
         assert len(caplog.records) == 1
+        publish_discovery_config_mock.assert_not_called()
     else:
         assert mqtt_client_mock().subscribe.call_count == 2
         assert len(caplog.records) == 2
@@ -129,3 +132,6 @@ def test__run_alias_file_path(caplog, tmp_path, mqtt_host, mqtt_port, aliases):
             logging.INFO,
             "subscribing to MQTT topic 'intertechno-cc1101/+/set' (alias)",
         )
+        publish_discovery_config_mock.assert_called_once_with(
+            mqtt_client=mqtt_client_mock(), aliases=aliases
+        )