[mqtt] Reject subscribed topics longer than the ESP8266 and LibreTiny MQTT client accepts

This commit is contained in:
J. Nick Koston
2026-10-14 10:20:49 -10:00
parent 3c4cf50cb6
commit fee7225833
2 changed files with 232 additions and 0 deletions
+110
View File
@@ -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:
@@ -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/<object_id>/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/<object_id>/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 + "/#"}]})