mirror of
https://github.com/esphome/esphome.git
synced 2026-10-18 12:12:48 +00:00
[mqtt] Reject subscribed topics longer than the ESP8266 and LibreTiny MQTT client accepts
This commit is contained in:
@@ -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 + "/#"}]})
|
||||
Reference in New Issue
Block a user