浏览代码

added support for aliases

Fabian Peter Hammerle 3 年之前
父节点
当前提交
07c9200f8d
共有 5 个文件被更改,包括 393 次插入168 次删除
  1. 19 0
      README.md
  2. 67 17
      intertechno_cc1101_mqtt/__init__.py
  3. 26 0
      tests/test_cli.py
  4. 45 151
      tests/test_mqtt.py
  5. 236 0
      tests/test_mqtt_message.py

+ 19 - 0
README.md

@@ -40,6 +40,25 @@ $ mosquitto_pub -h MQTT_BROKER -t intertechno-cc1101/12345678/0/set -m ON
 
 ⚠️ Support for TLS is not implemented yet. Do not connect to brokers via unprotected networks (e.g., internet, wifi network).
 
+### Aliases
+
+```sh
+$  intertechno-cc1101-mqtt --mqtt-host HOSTNAME_OR_IP_ADDRESS --alias-file aliases.json
+```
+
+with `aliases.json`:
+```json
+{"some-name": {"address": 12345678, "button-index": 0},
+"another-name": {"address": 12345678, "button-index": 1},
+"another_address": {"address": 21420815, "button-index": 0}}
+```
+
+Send `ON` or `OFF` to topic `intertechno-cc1101/[ALIAS]/set`.
+
+```sh
+$ mosquitto_pub -h MQTT_BROKER -t intertechno-cc1101/some-name/set -m ON
+```
+
 ## Home Assistant 🏡
 
 ```yaml

+ 67 - 17
intertechno_cc1101_mqtt/__init__.py

@@ -1,4 +1,5 @@
 import argparse
+import json
 import logging
 import pathlib
 import typing
@@ -8,19 +9,26 @@ import intertechno_cc1101
 
 _LOGGER = logging.getLogger(__name__)
 
+Aliases = typing.Dict[str, typing.Dict[str, int]]
 
-def _mqtt_on_message(
-    mqtt_client: paho.mqtt.client.Client,
-    userdata: None,
-    message: paho.mqtt.client.MQTTMessage,
-):
-    # pylint: disable=unused-argument; callback
-    # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
-    _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
-    if message.retain:
-        _LOGGER.warning("ignoring retained message")
-        return
-    topic_split = message.topic.split("/")
+
+def _parse_topic(
+    topic: str, aliases: Aliases
+) -> typing.Tuple[typing.Optional[int], typing.Optional[int]]:
+    topic_split = topic.split("/")
+    if len(topic_split) == 3:
+        try:
+            alias_attrs = aliases[topic_split[1]]
+        except KeyError:
+            _LOGGER.warning("unknown alias %r; ignoring message", topic_split[1])
+            return None, None
+        try:
+            return int(alias_attrs["address"]), int(alias_attrs["button-index"])
+        except KeyError:
+            _LOGGER.error(
+                "alias file must provide fields 'address' and 'button-index' for each alias"
+            )
+            return None, None
     try:
         address = int(topic_split[1])
     except ValueError:
@@ -28,7 +36,7 @@ def _mqtt_on_message(
             "failed to parse address %r, expected integer; ignoring message",
             topic_split[1],
         )
-        return
+        return None, None
     try:
         button_index = int(topic_split[2])
     except ValueError:
@@ -36,6 +44,23 @@ def _mqtt_on_message(
             "failed to parse button index %r, expected integer; ignoring message",
             topic_split[2],
         )
+        return None, None
+    return address, button_index
+
+
+def _mqtt_on_message(
+    mqtt_client: paho.mqtt.client.Client,
+    aliases: Aliases,
+    message: paho.mqtt.client.MQTTMessage,
+):
+    # pylint: disable=unused-argument; callback
+    # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
+    _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
+    if message.retain:  # TODO remove
+        _LOGGER.warning("ignoring retained message")
+        return
+    address, button_index = _parse_topic(topic=message.topic, aliases=aliases)
+    if not address:
         return
     try:
         remote_control = intertechno_cc1101.RemoteControl(address=address)
@@ -61,7 +86,7 @@ def _mqtt_on_message(
 
 def _mqtt_on_connect(
     mqtt_client: paho.mqtt.client.Client,
-    user_data: typing.Any,
+    aliases: Aliases,
     flags: typing.Dict,
     return_code: int,
 ) -> None:
@@ -70,11 +95,15 @@ 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)
-    set_topic = "intertechno-cc1101/+/+/set"
-    _LOGGER.info("subscribing to MQTT topic %r", set_topic)
     # alternative: .message_callback_add
     mqtt_client.on_message = _mqtt_on_message
+    set_topic = "intertechno-cc1101/+/+/set"
+    _LOGGER.info("subscribing to MQTT topic %r (address & button index)", set_topic)
     mqtt_client.subscribe(set_topic)
+    if aliases:
+        set_alias_topic = "intertechno-cc1101/+/set"
+        _LOGGER.info("subscribing to MQTT topic %r (alias)", set_alias_topic)
+        mqtt_client.subscribe(set_alias_topic)
 
 
 def _run(
@@ -82,9 +111,15 @@ def _run(
     mqtt_port: int,
     mqtt_username: typing.Optional[str],
     mqtt_password: typing.Optional[str],
+    alias_file_path: typing.Optional[pathlib.Path],
 ) -> None:
+    if alias_file_path:
+        with alias_file_path.open("r") as alias_file:
+            aliases = json.load(alias_file)
+    else:
+        aliases = {}
     # https://pypi.org/project/paho-mqtt/
-    mqtt_client = paho.mqtt.client.Client()
+    mqtt_client = paho.mqtt.client.Client(userdata=aliases)
     mqtt_client.on_connect = _mqtt_on_connect
     _LOGGER.info("connecting to MQTT broker %s:%d", mqtt_host, mqtt_port)
     if mqtt_username:
@@ -119,6 +154,20 @@ def _main() -> None:
         dest="mqtt_password_path",
         help="stripping trailing newline",
     )
+    argparser.add_argument(
+        "--alias-file",
+        metavar="PATH",
+        dest="alias_file_path",
+        type=pathlib.Path,
+        help="json: {}".format(
+            json.dumps(
+                {
+                    "some-alias": {"address": 12345678, "button-index": 0},
+                    "another-alias": {"address": 12345678, "button-index": 0},
+                }
+            )
+        ),
+    )
     args = argparser.parse_args()
     if args.mqtt_password_path:
         # .read_text() replaces \r\n with \n
@@ -134,4 +183,5 @@ def _main() -> None:
         mqtt_port=args.mqtt_port,
         mqtt_username=args.mqtt_username,
         mqtt_password=mqtt_password,
+        alias_file_path=args.alias_file_path,
     )

+ 26 - 0
tests/test_cli.py

@@ -1,3 +1,4 @@
+import pathlib
 import unittest.mock
 
 import pytest
@@ -65,6 +66,7 @@ def test__main(
         mqtt_port=expected_mqtt_port,
         mqtt_username=expected_username,
         mqtt_password=expected_password,
+        alias_file_path=None,
     )
 
 
@@ -107,6 +109,7 @@ def test__main_password_file(tmpdir, password_file_content, expected_password):
         mqtt_port=1883,
         mqtt_username="me",
         mqtt_password=expected_password,
+        alias_file_path=None,
     )
 
 
@@ -134,3 +137,26 @@ def test__main_password_file_collision(capsys):
         "argument --mqtt-password-file: not allowed with argument --mqtt-password\n"
         in err
     )
+
+
+def test__main_alias_file():
+    with unittest.mock.patch(
+        "intertechno_cc1101_mqtt._run"
+    ) as run_mock, unittest.mock.patch(
+        "sys.argv",
+        [
+            "",
+            "--mqtt-host",
+            "broker",
+            "--alias-file",
+            "/etc/intertechno-cc1101-mqtt/aliases.json",
+        ],
+    ):
+        intertechno_cc1101_mqtt._main()
+    run_mock.assert_called_once_with(
+        mqtt_host="broker",
+        mqtt_port=1883,
+        mqtt_username=None,
+        mqtt_password=None,
+        alias_file_path=pathlib.Path("/etc/intertechno-cc1101-mqtt/aliases.json"),
+    )

+ 45 - 151
tests/test_mqtt.py

@@ -1,8 +1,9 @@
+import json
 import logging
+import pathlib
 import unittest.mock
 
 import pytest
-from paho.mqtt.client import MQTTMessage
 
 import intertechno_cc1101_mqtt
 
@@ -20,13 +21,14 @@ def test__run(caplog, mqtt_host, mqtt_port):
             mqtt_port=mqtt_port,
             mqtt_username=None,
             mqtt_password=None,
+            alias_file_path=None,
         )
-    mqtt_client_mock.assert_called_once_with()
+    mqtt_client_mock.assert_called_once_with(userdata={})
     assert not mqtt_client_mock().username_pw_set.called
     mqtt_client_mock().connect.assert_called_once_with(host=mqtt_host, port=mqtt_port)
     mqtt_client_mock().socket().getpeername.return_value = (mqtt_host, mqtt_port)
     with caplog.at_level(logging.DEBUG):
-        mqtt_client_mock().on_connect(mqtt_client_mock(), None, {}, 0)
+        mqtt_client_mock().on_connect(mqtt_client_mock(), {}, {}, 0)
     # pylint: disable=comparison-with-callable
     assert mqtt_client_mock().on_message == intertechno_cc1101_mqtt._mqtt_on_message
     mqtt_client_mock().subscribe.assert_called_once_with("intertechno-cc1101/+/+/set")
@@ -45,7 +47,7 @@ def test__run(caplog, mqtt_host, mqtt_port):
         (
             "intertechno_cc1101_mqtt",
             logging.INFO,
-            "subscribing to MQTT topic 'intertechno-cc1101/+/+/set'",
+            "subscribing to MQTT topic 'intertechno-cc1101/+/+/set' (address & button index)",
         ),
     ]
 
@@ -61,8 +63,9 @@ def test__run_authentication(mqtt_host, mqtt_port, mqtt_username, mqtt_password)
             mqtt_port=mqtt_port,
             mqtt_username=mqtt_username,
             mqtt_password=mqtt_password,
+            alias_file_path=None,
         )
-    mqtt_client_mock.assert_called_once_with()
+    mqtt_client_mock.assert_called_once_with(userdata={})
     mqtt_client_mock().username_pw_set.assert_called_once_with(
         username=mqtt_username, password=mqtt_password
     )
@@ -79,159 +82,50 @@ def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_passwor
                 mqtt_port=mqtt_port,
                 mqtt_username=None,
                 mqtt_password=mqtt_password,
+                alias_file_path=None,
             )
 
 
-def test__mqtt_on_message_retained(caplog):
-    message = MQTTMessage(topic=b"intertechno-cc1101/12345678/0/set")
-    message.payload = b"ON"
-    message.retain = True
-    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
-        with caplog.at_level(logging.DEBUG):
-            intertechno_cc1101_mqtt._mqtt_on_message("dummy", None, message)
-    remote_control_mock.assert_not_called()
-    assert caplog.record_tuples == [
-        (
-            "intertechno_cc1101_mqtt",
-            logging.DEBUG,
-            "received topic=intertechno-cc1101/12345678/0/set payload=b'ON'",
-        ),
-        ("intertechno_cc1101_mqtt", logging.WARNING, "ignoring retained message"),
-    ]
-
-
-@pytest.mark.parametrize(
-    ("topic", "address"),
-    (
-        (b"intertechno-cc1101/12345678/0/set", 12345678),
-        (b"intertechno-cc1101/1234/0/set", 1234),
-    ),
-)
-def test__mqtt_on_message_address(topic, address):
-    message = MQTTMessage(topic=topic)
-    message.payload = b"ON"
-    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
-        intertechno_cc1101_mqtt._mqtt_on_message("dummy", None, message)
-    remote_control_mock.assert_called_once_with(address=address)
-
-
+@pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
+@pytest.mark.parametrize("mqtt_port", [1833])
 @pytest.mark.parametrize(
-    ("topic", "address_str"),
-    (
-        (b"intertechno-cc1101/abcdef/0/set", "abcdef"),
-        (b"intertechno-cc1101//0/set", ""),
-    ),
+    "aliases", [{}, {"some-alias": {"address": 12345678, "button-index": 0}}]
 )
-def test__mqtt_on_message_invalid_address(caplog, topic, address_str):
-    message = MQTTMessage(topic=topic)
-    message.payload = b"ON"
-    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
-        with caplog.at_level(logging.WARNING):
-            intertechno_cc1101_mqtt._mqtt_on_message("dummy", None, message)
-    remote_control_mock.assert_not_called()
-    assert caplog.record_tuples == [
-        (
-            "intertechno_cc1101_mqtt",
-            logging.WARNING,
-            "failed to parse address {!r}, expected integer; ignoring message".format(
-                address_str
-            ),
+def test__run_alias_file_path(caplog, tmp_path, mqtt_host, mqtt_port, aliases):
+    alias_file_path = tmp_path.joinpath("aliases.json")
+    alias_file_path.write_text(json.dumps(aliases))
+    assert isinstance(alias_file_path, pathlib.Path)
+    with unittest.mock.patch("paho.mqtt.client.Client") as mqtt_client_mock:
+        intertechno_cc1101_mqtt._run(
+            mqtt_host=mqtt_host,
+            mqtt_port=mqtt_port,
+            mqtt_username=None,
+            mqtt_password=None,
+            alias_file_path=alias_file_path,
         )
-    ]
-
-
-@pytest.mark.parametrize(
-    ("topic", "button_index"),
-    ((b"intertechno-cc1101/12345678/0/set", 0), (b"intertechno-cc1101/1234/7/set", 7)),
-)
-@pytest.mark.parametrize(
-    ("payload", "turn_on"),
-    ((b"ON", True), (b"On", True), (b"on", True), (b"OFF", False), (b"off", False)),
-)
-def test__mqtt_on_message_button_index_action(topic, button_index, payload, turn_on):
-    message = MQTTMessage(topic=topic)
-    message.payload = payload
-    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
-        intertechno_cc1101_mqtt._mqtt_on_message("dummy", None, message)
-    if turn_on:
-        remote_control_mock().turn_on.assert_called_once_with(button_index=button_index)
-        remote_control_mock().turn_off.assert_not_called()
+    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):
+        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"
+    )
+    assert caplog.record_tuples[0] == (
+        "intertechno_cc1101_mqtt",
+        logging.INFO,
+        "subscribing to MQTT topic 'intertechno-cc1101/+/+/set' (address & button index)",
+    )
+    if len(aliases) == 0:
+        assert mqtt_client_mock().subscribe.call_count == 1
+        assert len(caplog.records) == 1
     else:
-        remote_control_mock().turn_off.assert_called_once_with(
-            button_index=button_index
-        )
-        remote_control_mock().turn_on.assert_not_called()
-
-
-@pytest.mark.parametrize(
-    ("topic", "button_index_str"),
-    (
-        (b"intertechno-cc1101/12345678/abc/set", "abc"),
-        (b"intertechno-cc1101/12345678//set", ""),
-    ),
-)
-def test__mqtt_on_message_invalid_button_index(caplog, topic, button_index_str):
-    message = MQTTMessage(topic=topic)
-    message.payload = b"ON"
-    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
-        with caplog.at_level(logging.WARNING):
-            intertechno_cc1101_mqtt._mqtt_on_message("dummy", None, message)
-    remote_control_mock().turn_on.assert_not_called()
-    remote_control_mock().turn_off.assert_not_called()
-    assert caplog.record_tuples == [
-        (
-            "intertechno_cc1101_mqtt",
-            logging.WARNING,
-            "failed to parse button index {!r}, expected integer; ignoring message".format(
-                button_index_str
-            ),
+        assert mqtt_client_mock().subscribe.call_count == 2
+        assert len(caplog.records) == 2
+        assert mqtt_client_mock().subscribe.call_args_list[1] == unittest.mock.call(
+            "intertechno-cc1101/+/set"
         )
-    ]
-
-
-@pytest.mark.parametrize(
-    "topic", (b"intertechno-cc1101/123456789/0/set", b"intertechno-cc1101/-21/0/set")
-)
-def test__mqtt_on_message_remote_init_failed(caplog, topic):
-    message = MQTTMessage(topic=topic)
-    message.payload = b"ON"
-    with caplog.at_level(logging.WARNING):
-        intertechno_cc1101_mqtt._mqtt_on_message("dummy", None, message)
-    assert len(caplog.records) == 1
-    assert caplog.records[0].levelno == logging.WARNING
-    assert (
-        caplog.records[0].message
-        == "failed to initialize remote control, invalid address? ignoring message"
-    )
-    assert isinstance(caplog.records[0].exc_info[1], AssertionError)
-
-
-def test__mqtt_on_message_transmission_failed(caplog):
-    message = MQTTMessage(topic=b"intertechno-cc1101/12345678/3/set")
-    message.payload = b"ON"
-    with unittest.mock.patch(
-        "cc1101.CC1101.__enter__",
-        side_effect=FileNotFoundError("[Errno 2] No such file or directory"),
-    ), caplog.at_level(logging.ERROR):
-        intertechno_cc1101_mqtt._mqtt_on_message("dummy", None, message)
-    assert len(caplog.records) == 1
-    assert caplog.records[0].levelno == logging.ERROR
-    assert caplog.records[0].message == "failed to send signal"
-    assert isinstance(caplog.records[0].exc_info[1], FileNotFoundError)
-
-
-@pytest.mark.parametrize("payload", (b"EIN", b"aus", b""))
-def test__mqtt_on_message_invalid_payload(caplog, payload):
-    message = MQTTMessage(topic=b"intertechno-cc1101/1234/7/set")
-    message.payload = payload
-    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
-        intertechno_cc1101_mqtt._mqtt_on_message("dummy", None, message)
-    remote_control_mock().turn_off.assert_not_called()
-    remote_control_mock().turn_on.assert_not_called()
-    assert caplog.record_tuples == [
-        (
+        assert caplog.record_tuples[1] == (
             "intertechno_cc1101_mqtt",
-            30,
-            "unexpected payload {!r}; expected 'ON' or 'OFF'".format(payload),
+            logging.INFO,
+            "subscribing to MQTT topic 'intertechno-cc1101/+/set' (alias)",
         )
-    ]

+ 236 - 0
tests/test_mqtt_message.py

@@ -0,0 +1,236 @@
+import logging
+import unittest.mock
+
+import pytest
+from paho.mqtt.client import MQTTMessage
+
+import intertechno_cc1101_mqtt
+
+# pylint: disable=protected-access
+
+
+def test__mqtt_on_message_retained(caplog):
+    message = MQTTMessage(topic=b"intertechno-cc1101/12345678/0/set")
+    message.payload = b"ON"
+    message.retain = True
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        with caplog.at_level(logging.DEBUG):
+            intertechno_cc1101_mqtt._mqtt_on_message("dummy", {}, message)
+    remote_control_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "intertechno_cc1101_mqtt",
+            logging.DEBUG,
+            "received topic=intertechno-cc1101/12345678/0/set payload=b'ON'",
+        ),
+        ("intertechno_cc1101_mqtt", logging.WARNING, "ignoring retained message"),
+    ]
+
+
+@pytest.mark.parametrize(
+    ("topic", "address"),
+    (
+        (b"intertechno-cc1101/12345678/0/set", 12345678),
+        (b"intertechno-cc1101/1234/0/set", 1234),
+    ),
+)
+def test__mqtt_on_message_address(topic, address):
+    message = MQTTMessage(topic=topic)
+    message.payload = b"ON"
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        intertechno_cc1101_mqtt._mqtt_on_message("dummy", {}, message)
+    remote_control_mock.assert_called_once_with(address=address)
+
+
+@pytest.mark.parametrize(
+    ("topic", "address_str"),
+    (
+        (b"intertechno-cc1101/abcdef/0/set", "abcdef"),
+        (b"intertechno-cc1101//0/set", ""),
+    ),
+)
+def test__mqtt_on_message_invalid_address(caplog, topic, address_str):
+    message = MQTTMessage(topic=topic)
+    message.payload = b"ON"
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        with caplog.at_level(logging.WARNING):
+            intertechno_cc1101_mqtt._mqtt_on_message("dummy", {}, message)
+    remote_control_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "intertechno_cc1101_mqtt",
+            logging.WARNING,
+            "failed to parse address {!r}, expected integer; ignoring message".format(
+                address_str
+            ),
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    ("topic", "button_index"),
+    ((b"intertechno-cc1101/12345678/0/set", 0), (b"intertechno-cc1101/1234/7/set", 7)),
+)
+@pytest.mark.parametrize(
+    ("payload", "turn_on"),
+    ((b"ON", True), (b"On", True), (b"on", True), (b"OFF", False), (b"off", False)),
+)
+def test__mqtt_on_message_button_index_action(topic, button_index, payload, turn_on):
+    message = MQTTMessage(topic=topic)
+    message.payload = payload
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        intertechno_cc1101_mqtt._mqtt_on_message("dummy", {}, message)
+    if turn_on:
+        remote_control_mock().turn_on.assert_called_once_with(button_index=button_index)
+        remote_control_mock().turn_off.assert_not_called()
+    else:
+        remote_control_mock().turn_off.assert_called_once_with(
+            button_index=button_index
+        )
+        remote_control_mock().turn_on.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    ("topic", "button_index_str"),
+    (
+        (b"intertechno-cc1101/12345678/abc/set", "abc"),
+        (b"intertechno-cc1101/12345678//set", ""),
+    ),
+)
+def test__mqtt_on_message_invalid_button_index(caplog, topic, button_index_str):
+    message = MQTTMessage(topic=topic)
+    message.payload = b"ON"
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        with caplog.at_level(logging.WARNING):
+            intertechno_cc1101_mqtt._mqtt_on_message("dummy", {}, message)
+    remote_control_mock().turn_on.assert_not_called()
+    remote_control_mock().turn_off.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "intertechno_cc1101_mqtt",
+            logging.WARNING,
+            "failed to parse button index {!r}, expected integer; ignoring message".format(
+                button_index_str
+            ),
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    ("topic", "address", "button_index"),
+    (
+        (b"intertechno-cc1101/some-name/set", 21, 0),
+        (b"intertechno-cc1101/another-name/set", 42, 7),
+    ),
+)
+def test__mqtt_on_message_alias(topic, address, button_index):
+    message = MQTTMessage(topic=topic)
+    message.payload = b"ON"
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        intertechno_cc1101_mqtt._mqtt_on_message(
+            "dummy",
+            {
+                "some-name": {"address": 21, "button-index": 0},
+                "another-name": {"address": "42", "button-index": "7"},  # :O
+            },
+            message,
+        )
+    remote_control_mock.assert_called_once_with(address=address)
+    remote_control_mock().turn_on.assert_called_once_with(button_index=button_index)
+
+
+@pytest.mark.parametrize(
+    ("topic", "alias"),
+    (
+        (b"intertechno-cc1101//set", ""),
+        (b"intertechno-cc1101/unknown-name/set", "unknown-name"),
+    ),
+)
+def test__mqtt_on_message_undefined_alias(caplog, topic, alias):
+    message = MQTTMessage(topic=topic)
+    message.payload = b"ON"
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        with caplog.at_level(logging.WARNING):
+            intertechno_cc1101_mqtt._mqtt_on_message(
+                "dummy", {"some-name": {"address": 21, "button-index": 0}}, message
+            )
+    remote_control_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "intertechno_cc1101_mqtt",
+            logging.WARNING,
+            "unknown alias {!r}; ignoring message".format(alias),
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "aliases",
+    (
+        {"some-name": {"address": 21}},
+        {"some-name": {"button-index": 0}},
+        {"some-name": {"adresse": 21, "button-index": 0}},
+    ),
+)
+def test__mqtt_on_message_alias_missing_attrs(caplog, aliases):
+    message = MQTTMessage(topic=b"intertechno-cc1101/some-name/set")
+    message.payload = b"ON"
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        with caplog.at_level(logging.WARNING):
+            intertechno_cc1101_mqtt._mqtt_on_message("dummy", aliases, message)
+    remote_control_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "intertechno_cc1101_mqtt",
+            logging.ERROR,
+            "alias file must provide fields 'address' and 'button-index' for each alias",
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "topic", (b"intertechno-cc1101/123456789/0/set", b"intertechno-cc1101/-21/0/set")
+)
+def test__mqtt_on_message_remote_init_failed(caplog, topic):
+    message = MQTTMessage(topic=topic)
+    message.payload = b"ON"
+    with caplog.at_level(logging.WARNING):
+        intertechno_cc1101_mqtt._mqtt_on_message("dummy", {}, message)
+    assert len(caplog.records) == 1
+    assert caplog.records[0].levelno == logging.WARNING
+    assert (
+        caplog.records[0].message
+        == "failed to initialize remote control, invalid address? ignoring message"
+    )
+    assert isinstance(caplog.records[0].exc_info[1], AssertionError)
+
+
+def test__mqtt_on_message_transmission_failed(caplog):
+    message = MQTTMessage(topic=b"intertechno-cc1101/12345678/3/set")
+    message.payload = b"ON"
+    with unittest.mock.patch(
+        "cc1101.CC1101.__enter__",
+        side_effect=FileNotFoundError("[Errno 2] No such file or directory"),
+    ), caplog.at_level(logging.ERROR):
+        intertechno_cc1101_mqtt._mqtt_on_message("dummy", {}, message)
+    assert len(caplog.records) == 1
+    assert caplog.records[0].levelno == logging.ERROR
+    assert caplog.records[0].message == "failed to send signal"
+    assert isinstance(caplog.records[0].exc_info[1], FileNotFoundError)
+
+
+@pytest.mark.parametrize("payload", (b"EIN", b"aus", b""))
+def test__mqtt_on_message_invalid_payload(caplog, payload):
+    message = MQTTMessage(topic=b"intertechno-cc1101/1234/7/set")
+    message.payload = payload
+    with unittest.mock.patch("intertechno_cc1101.RemoteControl") as remote_control_mock:
+        intertechno_cc1101_mqtt._mqtt_on_message("dummy", {}, message)
+    remote_control_mock().turn_off.assert_not_called()
+    remote_control_mock().turn_on.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "intertechno_cc1101_mqtt",
+            30,
+            "unexpected payload {!r}; expected 'ON' or 'OFF'".format(payload),
+        )
+    ]