mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
240 lines
8.2 KiB
Python
240 lines
8.2 KiB
Python
"""Tests for the MQTT object_id conflict filter.
|
|
|
|
MQTT still builds default topics and discovery topics from the sanitized
|
|
object_id, so entity names that only differ in characters lost during
|
|
sanitizing conflict there; _topics_conflict() exempts entities that never
|
|
use an object_id-derived topic. See https://github.com/esphome/backlog/issues/85
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from esphome.components.mqtt import (
|
|
_COMMAND_TOPIC_PLATFORMS,
|
|
_SUB_TOPIC_PLATFORMS,
|
|
_topics_conflict,
|
|
)
|
|
from esphome.config_validation import Invalid
|
|
from esphome.const import (
|
|
CONF_COMMAND_TOPIC,
|
|
CONF_DISCOVERY,
|
|
CONF_NAME,
|
|
CONF_STATE_TOPIC,
|
|
CONF_TOPIC_PREFIX,
|
|
)
|
|
from esphome.core import CORE
|
|
from esphome.core.entity_helpers import (
|
|
entity_duplicate_validator,
|
|
validate_no_object_id_conflicts,
|
|
)
|
|
|
|
COMPONENTS_DIR = Path(__file__).parents[4] / "esphome" / "components"
|
|
|
|
REASON = "mqtt builds default topics from the entity object_id"
|
|
|
|
|
|
# MQTT infrastructure sources, not entity components
|
|
_NON_ENTITY_MQTT_SOURCES = {"mqtt_client", "mqtt_component"}
|
|
# The date, time and datetime MQTT components all belong to the datetime platform
|
|
_DATETIME_STEMS = {"date", "time", "datetime"}
|
|
|
|
|
|
def test_command_topic_platforms_in_sync() -> None:
|
|
"""Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe.
|
|
|
|
Drift silently reintroduces shared subscribe topics, so this derives the set
|
|
from the C++ components that actually call subscribe(); that also catches
|
|
platforms like text that subscribe a command topic without exposing a
|
|
command_topic key in their schema.
|
|
"""
|
|
expected: set[str] = set()
|
|
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"):
|
|
if path.stem in _NON_ENTITY_MQTT_SOURCES:
|
|
continue
|
|
if "this->subscribe" not in path.read_text(encoding="utf-8"):
|
|
continue
|
|
stem = path.stem.removeprefix("mqtt_")
|
|
expected.add("datetime" if stem in _DATETIME_STEMS else stem)
|
|
assert expected == _COMMAND_TOPIC_PLATFORMS
|
|
|
|
|
|
def test_sub_topic_platforms_in_sync() -> None:
|
|
"""Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics.
|
|
|
|
Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra
|
|
topics such as position/command from the object_id.
|
|
"""
|
|
expected = {
|
|
path.stem.removeprefix("mqtt_")
|
|
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h")
|
|
if path.stem != "mqtt_component"
|
|
and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8")
|
|
}
|
|
assert expected == _SUB_TOPIC_PLATFORMS
|
|
|
|
|
|
def test_conflict_filter_exempts_custom_topics() -> None:
|
|
"""Test that custom state topics with discovery off avoid the conflict."""
|
|
validator = entity_duplicate_validator("sensor")
|
|
# Both entities have custom state topics and discovery disabled per entity,
|
|
# so no object_id-derived MQTT topic is used
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик открытия",
|
|
CONF_STATE_TOPIC: "custom/topic/a",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик закрытия",
|
|
CONF_STATE_TOPIC: "custom/topic/b",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
|
|
component_validator = validate_no_object_id_conflicts(
|
|
REASON, conflict_filter=_topics_conflict
|
|
)
|
|
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
|
|
assert component_validator(config) is config
|
|
|
|
# Without the filter the same conflicts are fatal
|
|
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
|
|
validate_no_object_id_conflicts(REASON)({})
|
|
|
|
|
|
def test_conflict_on_default_command_topic() -> None:
|
|
"""Test that commandable platforms conflict through their default command topic.
|
|
|
|
Custom state topics with discovery off are not enough for platforms that also
|
|
subscribe to an object_id-derived command topic.
|
|
"""
|
|
validator = entity_duplicate_validator("switch")
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик открытия",
|
|
CONF_STATE_TOPIC: "custom/topic/a",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик закрытия",
|
|
CONF_STATE_TOPIC: "custom/topic/b",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
|
|
component_validator = validate_no_object_id_conflicts(
|
|
REASON, conflict_filter=_topics_conflict
|
|
)
|
|
mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
|
|
# Both switches share the default command topic: rejected
|
|
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
|
|
component_validator(mqtt_config)
|
|
|
|
# With custom command topics as well, nothing derives from the object_id
|
|
CORE.reset()
|
|
validator = entity_duplicate_validator("switch")
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик открытия",
|
|
CONF_STATE_TOPIC: "custom/topic/a",
|
|
CONF_COMMAND_TOPIC: "custom/cmd/a",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик закрытия",
|
|
CONF_STATE_TOPIC: "custom/topic/b",
|
|
CONF_COMMAND_TOPIC: "custom/cmd/b",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
assert component_validator(mqtt_config) is mqtt_config
|
|
|
|
|
|
def test_conflict_on_sub_topic_platforms() -> None:
|
|
"""Test that platforms with extra object_id sub-topics always conflict.
|
|
|
|
Covers derive topics like position/command from the object_id through their
|
|
own config keys, so custom state and command topics cannot exempt them.
|
|
"""
|
|
validator = entity_duplicate_validator("cover")
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик открытия",
|
|
CONF_STATE_TOPIC: "custom/topic/a",
|
|
CONF_COMMAND_TOPIC: "custom/cmd/a",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик закрытия",
|
|
CONF_STATE_TOPIC: "custom/topic/b",
|
|
CONF_COMMAND_TOPIC: "custom/cmd/b",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
|
|
component_validator = validate_no_object_id_conflicts(
|
|
REASON, conflict_filter=_topics_conflict
|
|
)
|
|
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
|
|
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"})
|
|
|
|
|
|
def test_no_conflict_on_disjoint_default_topics() -> None:
|
|
"""Test that entities whose default topics are disjoint do not conflict.
|
|
|
|
One entity uses only the default command topic and the other only the default
|
|
state topic, so they never share a topic.
|
|
"""
|
|
validator = entity_duplicate_validator("switch")
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик открытия",
|
|
CONF_STATE_TOPIC: "custom/topic/a",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
validator(
|
|
{
|
|
CONF_NAME: "Датчик закрытия",
|
|
CONF_COMMAND_TOPIC: "custom/cmd/b",
|
|
CONF_DISCOVERY: False,
|
|
}
|
|
)
|
|
|
|
component_validator = validate_no_object_id_conflicts(
|
|
REASON, conflict_filter=_topics_conflict
|
|
)
|
|
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
|
|
assert component_validator(config) is config
|
|
|
|
|
|
def test_no_conflict_on_empty_topic_prefix() -> None:
|
|
"""Test that an empty topic_prefix disables the default topic conflict.
|
|
|
|
With topic_prefix set to null no default topics exist at runtime, so entities
|
|
without custom state topics cannot conflict; only discovery still matters.
|
|
"""
|
|
validator = entity_duplicate_validator("sensor")
|
|
validator({CONF_NAME: "Датчик открытия"})
|
|
validator({CONF_NAME: "Датчик закрытия"})
|
|
|
|
component_validator = validate_no_object_id_conflicts(
|
|
REASON, conflict_filter=_topics_conflict
|
|
)
|
|
# No default topics and no discovery: valid
|
|
config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""}
|
|
assert component_validator(config) is config
|
|
|
|
# Discovery still uses object_id-derived config topics: rejected
|
|
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
|
|
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""})
|