mirror of
https://github.com/esphome/esphome.git
synced 2026-10-18 12:12:48 +00:00
Classify MQTT classes where they are declared and find entities at any depth
This commit is contained in:
@@ -9,6 +9,7 @@ from esphome.components.esp32 import (
|
||||
include_builtin_idf_component,
|
||||
request_tls,
|
||||
)
|
||||
from esphome.config import iter_ids
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_defines,
|
||||
filter_source_files_from_platform,
|
||||
@@ -76,6 +77,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import get_base_entity_object_id
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -133,28 +135,48 @@ MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component)
|
||||
MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action)
|
||||
MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component)
|
||||
|
||||
MQTTAlarmControlPanelComponent = mqtt_ns.class_(
|
||||
"MQTTAlarmControlPanelComponent", MQTTComponent
|
||||
# The topic each entity MQTT class always subscribes to, keyed by class:
|
||||
# (component type, topic suffix, config key that replaces the whole topic)
|
||||
_COMMAND_TOPICS: dict[str, tuple[str, str, str]] = {}
|
||||
|
||||
|
||||
def _entity_class(
|
||||
name: str,
|
||||
component_type: str | None = None,
|
||||
suffix: str = "command",
|
||||
custom_key: str = CONF_COMMAND_TOPIC,
|
||||
) -> MockObjClass:
|
||||
"""Declare an entity MQTT class; a component_type means it subscribes to a command topic."""
|
||||
cls = mqtt_ns.class_(name, MQTTComponent)
|
||||
if component_type is not None:
|
||||
_COMMAND_TOPICS[str(cls)] = (component_type, suffix, custom_key)
|
||||
return cls
|
||||
|
||||
|
||||
MQTTAlarmControlPanelComponent = _entity_class(
|
||||
"MQTTAlarmControlPanelComponent", "alarm_control_panel"
|
||||
)
|
||||
MQTTBinarySensorComponent = mqtt_ns.class_("MQTTBinarySensorComponent", MQTTComponent)
|
||||
MQTTClimateComponent = mqtt_ns.class_("MQTTClimateComponent", MQTTComponent)
|
||||
MQTTCoverComponent = mqtt_ns.class_("MQTTCoverComponent", MQTTComponent)
|
||||
MQTTFanComponent = mqtt_ns.class_("MQTTFanComponent", MQTTComponent)
|
||||
MQTTJSONLightComponent = mqtt_ns.class_("MQTTJSONLightComponent", MQTTComponent)
|
||||
MQTTSensorComponent = mqtt_ns.class_("MQTTSensorComponent", MQTTComponent)
|
||||
MQTTSwitchComponent = mqtt_ns.class_("MQTTSwitchComponent", MQTTComponent)
|
||||
MQTTTextSensor = mqtt_ns.class_("MQTTTextSensor", MQTTComponent)
|
||||
MQTTNumberComponent = mqtt_ns.class_("MQTTNumberComponent", MQTTComponent)
|
||||
MQTTDateComponent = mqtt_ns.class_("MQTTDateComponent", MQTTComponent)
|
||||
MQTTTimeComponent = mqtt_ns.class_("MQTTTimeComponent", MQTTComponent)
|
||||
MQTTDateTimeComponent = mqtt_ns.class_("MQTTDateTimeComponent", MQTTComponent)
|
||||
MQTTTextComponent = mqtt_ns.class_("MQTTTextComponent", MQTTComponent)
|
||||
MQTTSelectComponent = mqtt_ns.class_("MQTTSelectComponent", MQTTComponent)
|
||||
MQTTButtonComponent = mqtt_ns.class_("MQTTButtonComponent", MQTTComponent)
|
||||
MQTTLockComponent = mqtt_ns.class_("MQTTLockComponent", MQTTComponent)
|
||||
MQTTEventComponent = mqtt_ns.class_("MQTTEventComponent", MQTTComponent)
|
||||
MQTTUpdateComponent = mqtt_ns.class_("MQTTUpdateComponent", MQTTComponent)
|
||||
MQTTValveComponent = mqtt_ns.class_("MQTTValveComponent", MQTTComponent)
|
||||
MQTTBinarySensorComponent = _entity_class("MQTTBinarySensorComponent")
|
||||
MQTTClimateComponent = _entity_class(
|
||||
"MQTTClimateComponent", "climate", "mode/command", CONF_MODE_COMMAND_TOPIC
|
||||
)
|
||||
MQTTCoverComponent = _entity_class("MQTTCoverComponent", "cover")
|
||||
MQTTFanComponent = _entity_class("MQTTFanComponent", "fan")
|
||||
MQTTJSONLightComponent = _entity_class("MQTTJSONLightComponent", "light")
|
||||
MQTTSensorComponent = _entity_class("MQTTSensorComponent")
|
||||
MQTTSwitchComponent = _entity_class("MQTTSwitchComponent", "switch")
|
||||
MQTTTextSensor = _entity_class("MQTTTextSensor")
|
||||
MQTTNumberComponent = _entity_class("MQTTNumberComponent", "number")
|
||||
MQTTDateComponent = _entity_class("MQTTDateComponent", "date")
|
||||
MQTTTimeComponent = _entity_class("MQTTTimeComponent", "time")
|
||||
MQTTDateTimeComponent = _entity_class("MQTTDateTimeComponent", "datetime")
|
||||
MQTTTextComponent = _entity_class("MQTTTextComponent", "text")
|
||||
MQTTSelectComponent = _entity_class("MQTTSelectComponent", "select")
|
||||
MQTTButtonComponent = _entity_class("MQTTButtonComponent", "button")
|
||||
MQTTLockComponent = _entity_class("MQTTLockComponent", "lock")
|
||||
MQTTEventComponent = _entity_class("MQTTEventComponent")
|
||||
MQTTUpdateComponent = _entity_class("MQTTUpdateComponent", "update")
|
||||
MQTTValveComponent = _entity_class("MQTTValveComponent", "valve")
|
||||
|
||||
MQTTDiscoveryUniqueIdGenerator = mqtt_ns.enum("MQTTDiscoveryUniqueIdGenerator")
|
||||
MQTT_DISCOVERY_UNIQUE_ID_GENERATOR_OPTIONS = {
|
||||
@@ -326,50 +348,13 @@ CONFIG_SCHEMA = cv.All(
|
||||
ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN = 128
|
||||
# name_add_mac_suffix appends "-" plus 6 hex digits to the name at runtime
|
||||
MAC_SUFFIX = "-xxxxxx"
|
||||
# The topic each entity MQTT class always subscribes to: component type, suffix, and the
|
||||
# config key that replaces the whole topic. Every MQTTComponent subclass above is listed
|
||||
# here or in _MQTT_WITHOUT_COMMAND_TOPIC; a test keeps the two in step.
|
||||
_MQTT_COMMAND_TOPICS = {
|
||||
str(cls): spec
|
||||
for cls, spec in (
|
||||
(
|
||||
MQTTAlarmControlPanelComponent,
|
||||
("alarm_control_panel", "command", CONF_COMMAND_TOPIC),
|
||||
),
|
||||
(MQTTButtonComponent, ("button", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTClimateComponent, ("climate", "mode/command", CONF_MODE_COMMAND_TOPIC)),
|
||||
(MQTTCoverComponent, ("cover", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTDateComponent, ("date", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTDateTimeComponent, ("datetime", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTFanComponent, ("fan", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTJSONLightComponent, ("light", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTLockComponent, ("lock", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTNumberComponent, ("number", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTSelectComponent, ("select", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTSwitchComponent, ("switch", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTTextComponent, ("text", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTTimeComponent, ("time", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTUpdateComponent, ("update", "command", CONF_COMMAND_TOPIC)),
|
||||
(MQTTValveComponent, ("valve", "command", CONF_COMMAND_TOPIC)),
|
||||
)
|
||||
}
|
||||
_MQTT_WITHOUT_COMMAND_TOPIC = (
|
||||
MQTTBinarySensorComponent,
|
||||
MQTTEventComponent,
|
||||
MQTTSensorComponent,
|
||||
MQTTTextSensor,
|
||||
)
|
||||
|
||||
|
||||
def _entity_command_topic(
|
||||
config: ConfigType, topic_prefix: str, mac_suffix: bool, devices: dict[str, str]
|
||||
) -> str | None:
|
||||
"""Return the topic an entity's MQTT component always subscribes to, if known."""
|
||||
if (mqtt_id := config.get(CONF_MQTT_ID)) is None or (
|
||||
spec := _MQTT_COMMAND_TOPICS.get(str(mqtt_id.type))
|
||||
) is None:
|
||||
return None
|
||||
component_type, suffix, custom_key = spec
|
||||
component_type, suffix, custom_key = _COMMAND_TOPICS[str(config[CONF_MQTT_ID].type)]
|
||||
# Mirrors MQTTComponent::compute_is_internal_(): internal components never subscribe
|
||||
if CONF_STATE_TOPIC in config:
|
||||
internal = config[CONF_STATE_TOPIC] == ""
|
||||
@@ -407,14 +392,15 @@ def _subscribed_topics(config: ConfigType) -> Iterator[str]:
|
||||
topic_prefix = config[CONF_TOPIC_PREFIX]
|
||||
if mac_suffix and topic_prefix == CORE.name:
|
||||
topic_prefix += MAC_SUFFIX
|
||||
for value in full_config.values():
|
||||
for entity in value if isinstance(value, list) else ():
|
||||
if isinstance(entity, dict) and (
|
||||
topic := _entity_command_topic(
|
||||
entity, topic_prefix, mac_suffix, devices
|
||||
)
|
||||
):
|
||||
yield topic
|
||||
# Entities can sit at any depth (a platform can declare its own switches), so find them by mqtt_id
|
||||
for id_, path in iter_ids(full_config):
|
||||
if path[-1] != CONF_MQTT_ID or str(id_.type) not in _COMMAND_TOPICS:
|
||||
continue
|
||||
entity = full_config
|
||||
for key in path[:-1]:
|
||||
entity = entity[key]
|
||||
if topic := _entity_command_topic(entity, topic_prefix, mac_suffix, devices):
|
||||
yield topic
|
||||
for key in (CONF_ON_MESSAGE, CONF_ON_JSON_MESSAGE):
|
||||
for trigger in config.get(key, []):
|
||||
yield trigger[CONF_TOPIC]
|
||||
@@ -430,11 +416,9 @@ def _validate_topic_lengths(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
for topic in _subscribed_topics(config):
|
||||
# A wildcard filter matches topics of any length, so only literals are checked
|
||||
if (
|
||||
len(topic) > ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN
|
||||
and "+" not in topic
|
||||
and "#" not in topic
|
||||
):
|
||||
if "+" in topic or "#" in topic:
|
||||
continue
|
||||
if len(topic) > ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN:
|
||||
raise cv.Invalid(
|
||||
f"MQTT topic '{topic}' is {len(topic)} characters; the MQTT client on "
|
||||
f"this platform drops topics longer than {ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN}"
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import mqtt
|
||||
from esphome.components.mqtt import (
|
||||
_MQTT_COMMAND_TOPICS,
|
||||
_MQTT_WITHOUT_COMMAND_TOPIC,
|
||||
ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN,
|
||||
MQTTClimateComponent,
|
||||
MQTTComponent,
|
||||
MQTTSwitchComponent,
|
||||
_validate_topic_lengths,
|
||||
)
|
||||
@@ -123,6 +119,13 @@ def _run(
|
||||
PLATFORM_ESP8266,
|
||||
id="custom_topic_without_prefix",
|
||||
),
|
||||
pytest.param(
|
||||
# A platform that declares its own switches, like sprinkler valves
|
||||
{"sprinkler": [{"valves": [_switch(TOO_LONG)["switch"][0]]}]},
|
||||
None,
|
||||
PLATFORM_ESP8266,
|
||||
id="nested_entity",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rejected(
|
||||
@@ -178,17 +181,3 @@ def test_accepted(
|
||||
def test_message_names_the_topic_and_length(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
with pytest.raises(cv.Invalid, match=rf"'dev/switch/{TOO_LONG}/command' is 129"):
|
||||
_run(monkeypatch, _switch(TOO_LONG))
|
||||
|
||||
|
||||
def test_every_mqtt_entity_class_is_classified() -> None:
|
||||
"""A new MQTT entity class must say whether it subscribes to a command topic."""
|
||||
declared = {
|
||||
str(value)
|
||||
for value in vars(mqtt).values()
|
||||
if isinstance(value, MockObjClass)
|
||||
and value is not MQTTComponent
|
||||
and value.inherits_from(MQTTComponent)
|
||||
}
|
||||
without = {str(cls) for cls in _MQTT_WITHOUT_COMMAND_TOPIC}
|
||||
assert not set(_MQTT_COMMAND_TOPICS) & without
|
||||
assert declared == set(_MQTT_COMMAND_TOPICS) | without
|
||||
|
||||
Reference in New Issue
Block a user