mirror of
https://github.com/esphome/esphome.git
synced 2026-10-03 17:55:57 +00:00
Merge remote-tracking branch 'origin/dev' into jesserockz-2026-584
# Conflicts: # esphome/components/api/__init__.py # platformio.ini
This commit is contained in:
@@ -1,239 +0,0 @@
|
||||
"""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: ""})
|
||||
Reference in New Issue
Block a user