diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index fe426827ce6..a062a223ef6 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator + from esphome import automation import esphome.codegen as cg from esphome.components import logger, socket @@ -23,6 +25,7 @@ from esphome.const import ( CONF_CLIENT_ID, CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, + CONF_DEVICE_ID, CONF_DISCOVER_IP, CONF_DISCOVERY, CONF_DISCOVERY_OBJECT_ID_GENERATOR, @@ -30,10 +33,17 @@ from esphome.const import ( CONF_DISCOVERY_RETAIN, CONF_DISCOVERY_UNIQUE_ID_GENERATOR, CONF_ENABLE_ON_BOOT, + CONF_ESPHOME, CONF_ID, + CONF_INTERNAL, CONF_KEEPALIVE, CONF_LEVEL, CONF_LOG_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_MQTT, + CONF_MQTT_ID, + CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_ON_JSON_MESSAGE, @@ -42,6 +52,7 @@ from esphome.const import ( CONF_PAYLOAD, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, + CONF_PLATFORM, CONF_PORT, CONF_PUBLISH_NAN_AS_NONE, CONF_QOS, @@ -53,6 +64,7 @@ from esphome.const import ( CONF_SUBSCRIBE_QOS, CONF_TOPIC, CONF_TOPIC_PREFIX, + CONF_TYPE, CONF_USE_ABBREVIATIONS, CONF_USERNAME, CONF_WILL_MESSAGE, @@ -64,6 +76,8 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core.entity_helpers import get_base_entity_object_id +import esphome.final_validate as fv from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -308,6 +322,102 @@ CONFIG_SCHEMA = cv.All( _consume_mqtt_sockets, ) +# AsyncMqttClient, the ESP8266 and LibreTiny backend, drops any inbound message whose +# topic is longer than this (its default maxTopicLength) +ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN = 128 +# name_add_mac_suffix appends "-" plus 6 hex digits to the name at runtime +MAC_SUFFIX_LEN = 7 +# Entity domains whose MQTT component always subscribes to a command topic: +# the topic suffix, and the config key that replaces the whole topic +_COMMAND_TOPICS = { + "alarm_control_panel": ("command", CONF_COMMAND_TOPIC), + "button": ("command", CONF_COMMAND_TOPIC), + "climate": ("mode/command", CONF_MODE_COMMAND_TOPIC), + "cover": ("command", CONF_COMMAND_TOPIC), + "datetime": ("command", CONF_COMMAND_TOPIC), + "fan": ("command", CONF_COMMAND_TOPIC), + "light": ("command", CONF_COMMAND_TOPIC), + "lock": ("command", CONF_COMMAND_TOPIC), + "number": ("command", CONF_COMMAND_TOPIC), + "select": ("command", CONF_COMMAND_TOPIC), + "switch": ("command", CONF_COMMAND_TOPIC), + "text": ("command", CONF_COMMAND_TOPIC), + "update": ("command", CONF_COMMAND_TOPIC), + "valve": ("command", CONF_COMMAND_TOPIC), +} + + +def _entity_command_topic( + domain: str, config: ConfigType, topic_prefix: str, mac_suffix: bool +) -> str | None: + """Return the topic an entity's MQTT component subscribes to, None if it does not.""" + suffix, custom_key = _COMMAND_TOPICS[domain] + if (custom := config.get(custom_key)) is not None: + # A lambda topic is only known at runtime + return custom if isinstance(custom, str) and custom else None + # Mirrors MQTTComponent::compute_is_internal_(): internal components never subscribe + if (state_topic := config.get(CONF_STATE_TOPIC)) is not None: + internal = state_topic == "" + else: + internal = config.get(CONF_INTERNAL, False) + if internal or not topic_prefix: + return None + device = config.get(CONF_DEVICE_ID) + object_id = get_base_entity_object_id( + config.get(CONF_NAME) or "", CORE.friendly_name, device.id if device else None + ) + if mac_suffix and not config.get(CONF_NAME): + object_id += "_" * MAC_SUFFIX_LEN + component_type = config[CONF_TYPE].lower() if domain == "datetime" else domain + return f"{topic_prefix}/{component_type}/{object_id}/{suffix}" + + +def _subscribed_topics(config: ConfigType) -> Iterator[tuple[list, str]]: + """Yield the path and topic of every subscription whose topic is known at build time.""" + full_config = fv.full_config.get() + mac_suffix = full_config[CONF_ESPHOME].get(CONF_NAME_ADD_MAC_SUFFIX, False) + topic_prefix = config[CONF_TOPIC_PREFIX] + if mac_suffix and topic_prefix == CORE.name: + topic_prefix += "-" * MAC_SUFFIX_LEN + for domain in _COMMAND_TOPICS: + for index, entity in enumerate(full_config.get(domain) or []): + if CONF_MQTT_ID in entity and ( + topic := _entity_command_topic(domain, entity, topic_prefix, mac_suffix) + ): + yield [domain, index], topic + for key in (CONF_ON_MESSAGE, CONF_ON_JSON_MESSAGE): + for index, trigger in enumerate(config.get(key, [])): + yield [CONF_MQTT, key, index, CONF_TOPIC], trigger[CONF_TOPIC] + for domain in ("sensor", "text_sensor"): + for index, entity in enumerate(full_config.get(domain) or []): + if entity[CONF_PLATFORM] == "mqtt_subscribe": + yield [domain, index, CONF_TOPIC], entity[CONF_TOPIC] + + +def _validate_topic_lengths(config: ConfigType) -> ConfigType: + """Reject topics that the ESP8266 and LibreTiny MQTT client would silently drop.""" + if not (CORE.is_esp8266 or CORE.is_libretiny): + return config + for path, topic in _subscribed_topics(config): + # A wildcard filter matches topics of any length, so only literals are checked + if ( + "+" in topic + or "#" in topic + or len(topic) <= ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN + ): + continue + raise cv.Invalid( + f"MQTT topic '{topic}' is {len(topic)} characters, but the MQTT client on " + f"this platform drops messages whose topic is longer than " + f"{ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN}, so they would never arrive. Use a " + f"shorter entity name or topic_prefix, or set a shorter command topic.", + path=path, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _validate_topic_lengths + def exp_mqtt_message(config): if config is None: diff --git a/tests/unit_tests/components/test_mqtt_topic_length.py b/tests/unit_tests/components/test_mqtt_topic_length.py new file mode 100644 index 00000000000..00028ecb207 --- /dev/null +++ b/tests/unit_tests/components/test_mqtt_topic_length.py @@ -0,0 +1,122 @@ +"""Tests for the mqtt check that subscribed topics fit the AsyncMqttClient limit.""" + +import pytest + +from esphome.components.mqtt import ( + ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN, + _validate_topic_lengths, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_ESPHOME, + CONF_MQTT_ID, + CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) +from esphome.core import CORE, Lambda +import esphome.final_validate as fv +from esphome.types import ConfigType + +# "dev/switch//command" is 19 characters around the object id +FITS = "a" * (ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN - 19) +TOO_LONG = FITS + "a" + + +def _run( + monkeypatch: pytest.MonkeyPatch, + full_config: dict, + mqtt: ConfigType | None = None, + platform: str = PLATFORM_ESP8266, +) -> ConfigType: + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: platform}) + monkeypatch.setattr(CORE, "name", "dev") + monkeypatch.setattr(CORE, "friendly_name", None) + config = {"topic_prefix": "dev", **(mqtt or {})} + full_config = {CONF_ESPHOME: {}, **full_config} + token = fv.full_config.set(full_config) + try: + return _validate_topic_lengths(config) + finally: + fv.full_config.reset(token) + + +def _switch(name: str, **extra) -> dict: + return {CONF_NAME: name, CONF_MQTT_ID: "mqtt_switch", **extra} + + +def test_command_topic_at_the_limit_passes(monkeypatch: pytest.MonkeyPatch) -> None: + _run(monkeypatch, {"switch": [_switch(FITS)]}) + + +def test_command_topic_over_the_limit_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises(cv.Invalid, match="129 characters"): + _run(monkeypatch, {"switch": [_switch(TOO_LONG)]}) + + +def test_libretiny_is_checked(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises(cv.Invalid): + _run(monkeypatch, {"switch": [_switch(TOO_LONG)]}, platform=PLATFORM_BK72XX) + + +def test_esp32_is_not_checked(monkeypatch: pytest.MonkeyPatch) -> None: + _run(monkeypatch, {"switch": [_switch(TOO_LONG)]}, platform=PLATFORM_ESP32) + + +def test_mac_suffix_counts(monkeypatch: pytest.MonkeyPatch) -> None: + """The default topic_prefix gains a MAC suffix at runtime.""" + with pytest.raises(cv.Invalid): + _run( + monkeypatch, + { + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + "switch": [_switch(FITS)], + }, + ) + + +def test_custom_command_topic_replaces_the_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _run(monkeypatch, {"switch": [_switch(TOO_LONG, command_topic="short/cmd")]}) + with pytest.raises(cv.Invalid): + _run(monkeypatch, {"switch": [_switch("s", command_topic="x" * 129)]}) + + +def test_lambda_command_topic_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None: + lam = Lambda('return "x";') + _run(monkeypatch, {"switch": [_switch(TOO_LONG, command_topic=lam)]}) + + +def test_internal_entity_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None: + _run(monkeypatch, {"switch": [_switch(TOO_LONG, internal=True)]}) + + +def test_entity_without_mqtt_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None: + _run(monkeypatch, {"switch": [{CONF_NAME: TOO_LONG}]}) + + +def test_climate_uses_mode_command(monkeypatch: pytest.MonkeyPatch) -> None: + # "dev/climate//mode/command" is 25 characters around the object id + name = "a" * (ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN - 24) + climate = {CONF_NAME: name, CONF_MQTT_ID: "mqtt_climate"} + with pytest.raises(cv.Invalid): + _run(monkeypatch, {"climate": [climate]}) + + +def test_trigger_and_mqtt_subscribe_literals(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises(cv.Invalid): + _run(monkeypatch, {}, mqtt={"on_message": [{"topic": "t" * 129}]}) + with pytest.raises(cv.Invalid): + _run( + monkeypatch, + {"sensor": [{"platform": "mqtt_subscribe", "topic": "t" * 129}]}, + ) + + +def test_wildcards_are_skipped(monkeypatch: pytest.MonkeyPatch) -> None: + _run(monkeypatch, {}, mqtt={"on_message": [{"topic": "t" * 129 + "/#"}]})