Compare commits

...
23 changed files with 321 additions and 141 deletions
+15 -6
View File
@@ -94,10 +94,12 @@ DEFAULT_WS_PATH = "/mqtt"
# Max lengths for stack-based topic building.
# These values are used in cv.Length() validators below to ensure the C++ code
# in mqtt_component.cpp can safely use fixed-size stack buffers without overflow.
# If you change these, update the corresponding constants in mqtt_component.cpp.
# can safely use fixed-size stack buffers without overflow. If you change these,
# update the corresponding constants in mqtt_client.h and mqtt_component.h.
TOPIC_PREFIX_MAX_LEN = 64 # Default is device name, typically short
DISCOVERY_PREFIX_MAX_LEN = 64 # Default is "homeassistant" (13 chars)
# Size of the stack buffer topics are built into (static_assert in mqtt_client.cpp)
MQTT_DEFAULT_TOPIC_MAX_LEN = 248
def validate_message_just_topic(value):
@@ -127,6 +129,15 @@ MQTT_MESSAGE_SCHEMA = cv.Any(
)
mqtt_ns = cg.esphome_ns.namespace("mqtt")
def _topic_source(topic: str) -> cg.MockObj:
# On ESP8266 a topic that fits the buffer stays in flash and is copied out when needed
if CORE.is_esp8266 and len(topic.encode()) < MQTT_DEFAULT_TOPIC_MAX_LEN:
return mqtt_ns.progmem_topic(cg.FlashStringLiteral(topic))
return mqtt_ns.static_topic(topic)
MQTTMessage = mqtt_ns.struct("MQTTMessage")
MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason")
MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component)
@@ -566,9 +577,7 @@ async def to_code(config):
((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),),
),
)
cg.add(
var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS])
)
cg.add(var.subscribe(_topic_source(conf[CONF_TOPIC]), callback, conf[CONF_QOS]))
for conf in config.get(CONF_ON_JSON_MESSAGE, []):
callback = await automation.build_trigger_callback(
@@ -579,7 +588,7 @@ async def to_code(config):
)
cg.add(
var.subscribe_json(
cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]
_topic_source(conf[CONF_TOPIC]), callback, conf[CONF_QOS]
)
)
@@ -50,7 +50,7 @@ static bool apply_command(AlarmControlPanelCall &call, const char *state) {
void MQTTAlarmControlPanelComponent::setup() {
this->alarm_control_panel_->add_on_state_callback(
[this](AlarmControlPanelState /*state*/) { this->publish_state(); });
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
auto call = this->alarm_control_panel_->make_call();
if (!payload.empty() && payload[0] == '{') {
// JSON payload: {"state": "DISARM", "code": "1234"}
+1 -1
View File
@@ -15,7 +15,7 @@ using namespace esphome::button;
MQTTButtonComponent::MQTTButtonComponent(button::Button *button) : button_(button) {}
void MQTTButtonComponent::setup() {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
if (payload == "PRESS") {
this->button_->press();
} else {
+62 -12
View File
@@ -98,12 +98,16 @@ void MQTTClientComponent::setup() {
if (this->is_discovery_ip_enabled()) {
this->subscribe(
"esphome/discover", [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); },
2);
static_topic("esphome/discover"),
[this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2);
// Format topic on stack - subscribe() copies it
char ping_topic[ESPHOME_TOPIC_BUFFER_SIZE];
format_esphome_topic(ping_topic, "ping");
static_assert(sizeof("esphome/ping/") + ESPHOME_DEVICE_NAME_MAX_LEN <= MQTT_DEFAULT_TOPIC_MAX_LEN);
MQTTTopicSource ping_topic{[](const void *, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) {
size_t len = buf_append_printf(buf.data(), buf.size(), 0, "esphome/ping/%s",
App.get_name().c_str());
return StringRef(buf.data(), len);
},
nullptr};
this->subscribe(
ping_topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2);
}
@@ -479,6 +483,12 @@ bool MQTTClientComponent::subscribe_(const char *topic, uint8_t qos) {
}
return ret != 0;
}
// Out of line so the topic buffer is not part of loop()'s stack frame
__attribute__((noinline)) bool MQTTClientComponent::subscribe_topic_(const MQTTTopicSource &topic, uint8_t qos) {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
return this->subscribe_(topic.build(buf).c_str(), qos);
}
void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) {
if (sub->subscribed)
return;
@@ -487,7 +497,7 @@ void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) {
bool do_resub = sub->resubscribe_timeout == 0 || now - sub->resubscribe_timeout > 1000;
if (do_resub) {
sub->subscribed = this->subscribe_(sub->topic.c_str(), sub->qos);
sub->subscribed = this->subscribe_topic_(sub->topic, sub->qos);
sub->resubscribe_timeout = now;
}
}
@@ -510,16 +520,47 @@ void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, co
}
#endif
void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed,
static StringRef static_topic_builder(const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN>) {
return StringRef(static_cast<const char *>(context));
}
// Same as static_topic_builder, but a distinct function so its address marks the topics to free
static StringRef copied_topic_builder(const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN>) {
return StringRef(static_cast<const char *>(context));
}
MQTTTopicSource static_topic(const char *topic) { return {static_topic_builder, topic}; }
static_assert(MQTT_DEFAULT_TOPIC_MAX_LEN == 248, "update MQTT_DEFAULT_TOPIC_MAX_LEN in mqtt/__init__.py");
#ifdef USE_ESP8266
// mqtt/__init__.py picks this source only for topics that fit
static StringRef progmem_topic_builder(const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) {
size_t len = buf_append_str_p(buf.data(), buf.size(), 0, static_cast<const char *>(context));
return StringRef(buf.data(), len);
}
MQTTTopicSource progmem_topic(ProgmemStr topic) {
return {progmem_topic_builder, reinterpret_cast<const char *>(topic)};
}
#endif
MQTTTopicSource MQTTClientComponent::copy_topic(StringRef topic) {
char *copy = new char[topic.size() + 1]; // NOLINT(cppcoreguidelines-owning-memory)
memcpy(copy, topic.c_str(), topic.size());
copy[topic.size()] = '\0';
return {copied_topic_builder, copy};
}
void MQTTClientComponent::add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed,
uint8_t qos) {
auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{
.topic = std::move(topic),
.topic = topic,
.callback = callback,
.resubscribe_timeout = 0,
.qos = qos,
.subscribed = false,
.boxed = boxed,
.removed = false,
.callback = callback,
.resubscribe_timeout = 0,
});
this->resubscribe_subscription_(&subscription);
}
@@ -535,8 +576,9 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) {
this->status_momentary_warning(1000);
}
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
for (auto &subscription : this->subscriptions_) {
if (subscription.topic == topic)
if (subscription.topic.build(buf) == topic)
subscription.removed = true;
}
// A callback may be running; the outermost dispatch erases them once it returns.
@@ -553,6 +595,8 @@ void MQTTClientComponent::erase_removed_subscriptions_() {
if (it->removed) {
if (it->boxed)
it->callback.free_boxed();
if (it->topic.builder == copied_topic_builder)
delete[] static_cast<const char *>(it->topic.context); // NOLINT(cppcoreguidelines-owning-memory)
it = this->subscriptions_.erase(it);
} else {
++it;
@@ -629,6 +673,12 @@ void MQTTClientComponent::disable() {
this->on_shutdown();
}
// Out of line so the topic buffer is off the stack while the matched callback runs
__attribute__((noinline)) static bool topic_matches(const char *message, const MQTTTopicSource &topic) {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
return topic_match(message, topic.build(buf).c_str());
}
void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) {
#ifdef USE_ESP8266
// IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266.
@@ -662,7 +712,7 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string
const size_t count = this->subscriptions_.size();
for (size_t i = 0; i < count; i++) {
const auto &subscription = this->subscriptions_[i];
if (!subscription.removed && topic_match(topic.c_str(), subscription.topic.c_str()))
if (!subscription.removed && topic_matches(topic.c_str(), subscription.topic))
subscription.callback.call(topic, payload);
}
this->dispatching_ = was_dispatching;
+66 -13
View File
@@ -8,8 +8,11 @@
#include "esphome/components/network/ip_address.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -22,6 +25,7 @@
#endif
#include "lwip/ip_addr.h"
#include <span>
#include <vector>
// The crash log replay needs the logger, which mqtt does not depend on
@@ -31,6 +35,18 @@
namespace esphome::mqtt {
// Max lengths for stack-based topic building.
// These limits are enforced at Python config validation time in mqtt/__init__.py
// using cv.Length() validators for topic_prefix.
// This ensures the stack buffers are always large enough.
static constexpr size_t MQTT_COMPONENT_TYPE_MAX_LEN = 20;
static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32;
static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64)
// Stack buffer size - safe because all inputs are length-validated at config time
// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null
static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN =
MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1;
/** Callback for MQTT events.
*/
using mqtt_on_connect_callback_t = std::function<MQTTBackend::on_connect_callback_t>;
@@ -46,15 +62,33 @@ using mqtt_json_callback_t ESPDEPRECATED(
"subscribe_json() takes any callable; pass the lambda directly. Removed in 2027.5.0",
"2026.11.0") = std::function<void(const std::string &, JsonObject)>;
/// Returns a subscription's topic, written into buf or referring to storage that outlives the subscription.
/// Never a null c_str(); an empty topic is "".
using mqtt_topic_builder_t = StringRef (*)(const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf);
/// Where a subscription's topic comes from. The topic is rebuilt whenever it is needed, so nothing is stored.
struct MQTTTopicSource {
mqtt_topic_builder_t builder;
const void *context;
StringRef build(std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) const { return this->builder(this->context, buf); }
};
/// A topic string that outlives the subscription, such as a literal; it is referenced, not copied.
MQTTTopicSource static_topic(const char *topic);
#ifdef USE_ESP8266
/// A topic in flash, copied into the buffer when needed, so at most MQTT_DEFAULT_TOPIC_MAX_LEN - 1 characters.
MQTTTopicSource progmem_topic(ProgmemStr topic);
#endif
/// internal struct for MQTT subscriptions.
struct MQTTSubscription {
std::string topic;
MQTTTopicSource topic;
mqtt_subscription_callback_t callback;
uint32_t resubscribe_timeout;
uint8_t qos;
bool subscribed;
bool boxed; // made by create_boxed(), freed on unsubscribe
bool removed; // unsubscribed while a callback ran, erased once dispatch returns
mqtt_subscription_callback_t callback;
uint32_t resubscribe_timeout;
};
/// internal struct for MQTT credentials.
@@ -183,32 +217,44 @@ class MQTTClientComponent final : public Component {
* A callback that fits in a pointer (capture at most `this`) is stored inline; a larger one is
* boxed on the heap and freed by unsubscribe().
*
* @param topic Where the topic comes from; it is rebuilt when needed instead of stored.
* @param callback The callback function.
* @param qos The QoS of this subscription.
*/
template<typename F> void subscribe(MQTTTopicSource topic, F &&callback, uint8_t qos = 0) {
constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline<F>();
this->add_subscription_(topic,
boxed ? mqtt_subscription_callback_t::create_boxed(std::forward<F>(callback))
: mqtt_subscription_callback_t::create(std::forward<F>(callback)),
boxed, qos);
}
/** Subscribe to an MQTT topic and call callback when a message is received.
*
* The topic is copied to the heap and freed by unsubscribe().
*
* @param topic The topic. Wildcards are currently not supported.
* @param callback The callback function.
* @param qos The QoS of this subscription.
*/
template<typename F> void subscribe(std::string topic, F &&callback, uint8_t qos = 0) {
constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline<F>();
this->add_subscription_(std::move(topic),
boxed ? mqtt_subscription_callback_t::create_boxed(std::forward<F>(callback))
: mqtt_subscription_callback_t::create(std::forward<F>(callback)),
boxed, qos);
template<typename F> void subscribe(const std::string &topic, F &&callback, uint8_t qos = 0) {
this->subscribe(copy_topic(StringRef(topic)), std::forward<F>(callback), qos);
}
/** Subscribe to a MQTT topic and automatically parse JSON payload.
*
* If an invalid JSON payload is received, the callback will not be called.
*
* @param topic The topic. Wildcards are currently not supported.
* @param topic An MQTTTopicSource, or a string that is copied to the heap.
* @param callback The callback with a parsed JsonObject that will be called when a message with matching topic is
* received.
* @param qos The QoS of this subscription.
*/
template<typename F> void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) {
template<typename T, typename F> void subscribe_json(T &&topic, F &&callback, uint8_t qos = 0) {
static_assert(std::is_invocable_v<const std::decay_t<F> &, const std::string &, JsonObject>,
"subscribe_json() needs a callback that does not mutate its captures");
this->subscribe(
std::move(topic),
std::forward<T>(topic),
[cb = std::forward<F>(callback)](const std::string &topic, const std::string &payload) {
call_json(cb, topic, payload);
},
@@ -301,6 +347,12 @@ class MQTTClientComponent final : public Component {
void set_wait_for_connection(bool wait_for_connection) { this->wait_for_connection_ = wait_for_connection; }
protected:
// For the lambda command topic, which is copied once
friend class MQTTComponent;
/// A heap copy of topic, owned by the one subscription it is passed to and freed when that is erased.
static MQTTTopicSource copy_topic(StringRef topic);
void send_device_info_();
/// Reconnect to the MQTT broker if not already connected.
@@ -317,6 +369,7 @@ class MQTTClientComponent final : public Component {
void recalculate_availability_();
bool subscribe_(const char *topic, uint8_t qos);
bool subscribe_topic_(const MQTTTopicSource &topic, uint8_t qos);
void resubscribe_subscription_(MQTTSubscription *sub);
template<typename F> static void call_json(const F &callback, const std::string &topic, const std::string &payload) {
JsonDocument doc = json::parse_json(payload);
@@ -328,7 +381,7 @@ class MQTTClientComponent final : public Component {
#ifdef USE_MQTT_CRASH_LOG
void handle_crash_log_request_(const std::string &topic, const std::string &payload);
#endif
void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
void add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
void resubscribe_subscriptions_();
MQTTCredentials credentials_;
+23 -20
View File
@@ -209,7 +209,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
}
void MQTTClimateComponent::setup() {
auto traits = this->device_->get_traits();
this->subscribe(this->get_mode_command_topic(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_mode_command_topic_source(), [this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_mode(payload);
call.perform();
@@ -217,7 +217,7 @@ void MQTTClimateComponent::setup() {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
this->subscribe(this->get_target_temperature_low_command_topic(),
this->subscribe(this->get_target_temperature_low_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
if (!val.has_value()) {
@@ -228,7 +228,7 @@ void MQTTClimateComponent::setup() {
call.set_target_temperature_low(*val);
call.perform();
});
this->subscribe(this->get_target_temperature_high_command_topic(),
this->subscribe(this->get_target_temperature_high_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
if (!val.has_value()) {
@@ -240,7 +240,7 @@ void MQTTClimateComponent::setup() {
call.perform();
});
} else {
this->subscribe(this->get_target_temperature_command_topic(),
this->subscribe(this->get_target_temperature_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
if (!val.has_value()) {
@@ -254,7 +254,7 @@ void MQTTClimateComponent::setup() {
}
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
this->subscribe(this->get_target_humidity_command_topic(),
this->subscribe(this->get_target_humidity_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
if (!val.has_value()) {
@@ -268,27 +268,30 @@ void MQTTClimateComponent::setup() {
}
if (traits.get_supports_presets() || !traits.get_supported_custom_presets().empty()) {
this->subscribe(this->get_preset_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_preset(payload);
call.perform();
});
this->subscribe(this->get_preset_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_preset(payload);
call.perform();
});
}
if (traits.get_supports_fan_modes()) {
this->subscribe(this->get_fan_mode_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_fan_mode(payload);
call.perform();
});
this->subscribe(this->get_fan_mode_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_fan_mode(payload);
call.perform();
});
}
if (traits.get_supports_swing_modes()) {
this->subscribe(this->get_swing_mode_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_swing_mode(payload);
call.perform();
});
this->subscribe(this->get_swing_mode_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_swing_mode(payload);
call.perform();
});
}
this->device_->add_on_state_callback([this](Climate & /*unused*/) { this->publish_state_(); });
@@ -124,6 +124,14 @@ StringRef MQTTComponent::get_command_topic_to_(std::span<char, MQTT_DEFAULT_TOPI
return this->get_default_topic_for_to_(buf, "command", 7);
}
MQTTTopicSource MQTTComponent::command_topic_source_() const {
if (this->custom_command_topic_.is_lambda()) {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
return MQTTClientComponent::copy_topic(this->get_command_topic_to_(buf));
}
return this->topic_source_<MQTTComponent, &MQTTComponent::get_command_topic_to_>();
}
std::string MQTTComponent::get_state_topic_() const {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
StringRef ref = this->get_state_topic_to_(buf);
+30 -20
View File
@@ -21,23 +21,14 @@ struct SendDiscoveryConfig {
bool command_topic{true}; ///< If the command topic should be included. Default to true.
};
// Max lengths for stack-based topic building.
// These limits are enforced at Python config validation time in mqtt/__init__.py
// using cv.Length() validators for topic_prefix and discovery_prefix.
// This ensures the stack buffers are always large enough.
static constexpr size_t MQTT_COMPONENT_TYPE_MAX_LEN = 20;
static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32;
static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64)
// Stack buffer size - safe because all inputs are length-validated at config time
// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null
static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN =
MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1;
static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64)
class MQTTComponent; // Forward declaration
// Validated in Python: cv.Length(max=64), DISCOVERY_PREFIX_MAX_LEN in mqtt/__init__.py
static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64;
// Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null
static constexpr size_t MQTT_DISCOVERY_TOPIC_MAX_LEN = MQTT_DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN +
1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1;
class MQTTComponent; // Forward declaration
void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic);
#define LOG_MQTT_COMPONENT(state_topic, command_topic) log_mqtt_component(TAG, this, state_topic, command_topic)
@@ -49,7 +40,9 @@ void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, b
static_assert(sizeof(type_str) - 1 <= MQTT_COMPONENT_TYPE_MAX_LEN, \
#class_name "::component_type() exceeds MQTT_COMPONENT_TYPE_MAX_LEN");
// Macro to define custom topic getter/setter with compile-time suffix length verification
// Macro to define custom topic getter/setter with compile-time suffix length verification.
// Set the topic before setup(): subscriptions rebuild it when needed, so a later change only reaches the broker
// after a reconnect.
#define MQTT_COMPONENT_CUSTOM_TOPIC_(name, type) \
static_assert(sizeof(#name "/" #type) - 1 <= MQTT_SUFFIX_MAX_LEN, \
"topic suffix " #name "/" #type " exceeds MQTT_SUFFIX_MAX_LEN"); \
@@ -64,6 +57,10 @@ void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, b
return StringRef(this->custom_##name##_##type##_topic_.data(), this->custom_##name##_##type##_topic_.size()); \
return this->get_default_topic_for_to_(buf, #name "/" #type, sizeof(#name "/" #type) - 1); \
} \
MQTTTopicSource get_##name##_##type##_topic_source() const { \
using Self = std::remove_cvref_t<decltype(*this)>; \
return this->topic_source_<Self, &Self::get_##name##_##type##_topic_to>(); \
} \
std::string get_##name##_##type##_topic() const { \
if (this->custom_##name##_##type##_topic_.empty()) \
return this->get_default_topic_for_(#name "/" #type); \
@@ -128,6 +125,7 @@ class MQTTComponent : public Component {
template<typename T> void set_custom_state_topic(T &&custom_state_topic) {
this->custom_state_topic_ = std::forward<T>(custom_state_topic);
}
/// Set a custom command topic before setup(); a later change only reaches the broker after a reconnect.
template<typename T> void set_custom_command_topic(T &&custom_command_topic) {
this->custom_command_topic_ = std::forward<T>(custom_command_topic);
}
@@ -255,25 +253,25 @@ class MQTTComponent : public Component {
/** Subscribe to a MQTT topic.
*
* @param topic The topic. Wildcards are currently not supported.
* @param topic The topic: an MQTTTopicSource, or a string that is copied to the heap.
* @param callback The callback that will be called when a message with matching topic is received.
* @param qos The MQTT quality of service. Defaults to 0.
*/
template<typename F> void subscribe(std::string topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe(std::move(topic), std::forward<F>(callback), qos);
template<typename T, typename F> void subscribe(T &&topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe(std::forward<T>(topic), std::forward<F>(callback), qos);
}
/** Subscribe to a MQTT topic and automatically parse JSON payload.
*
* If an invalid JSON payload is received, the callback will not be called.
*
* @param topic The topic. Wildcards are currently not supported.
* @param topic The topic: an MQTTTopicSource, or a string that is copied to the heap.
* @param callback The callback with a parsed JsonObject that will be called when a message with matching topic is
* received.
* @param qos The MQTT quality of service. Defaults to 0.
*/
template<typename F> void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe_json(std::move(topic), std::forward<F>(callback), qos);
template<typename T, typename F> void subscribe_json(T &&topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe_json(std::forward<T>(topic), std::forward<F>(callback), qos);
}
protected:
@@ -325,6 +323,18 @@ class MQTTComponent : public Component {
/// @return StringRef pointing to the topic in the buffer.
StringRef get_command_topic_to_(std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) const;
/// The command topic as a subscription source. A lambda topic is read once and copied, as it may change.
MQTTTopicSource command_topic_source_() const;
/// A topic built by one of this component's `get_*_topic_to` getters, rebuilt when needed.
template<typename C, StringRef (C::*Getter)(std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN>) const>
MQTTTopicSource topic_source_() const {
return {[](const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) {
return (static_cast<const C *>(context)->*Getter)(buf);
},
static_cast<const C *>(this)};
}
/// Get the MQTT topic that new states will be shared to (allocates std::string).
std::string get_state_topic_() const;
+23 -21
View File
@@ -31,34 +31,36 @@ MQTTCoverComponent::MQTTCoverComponent(Cover *cover) : cover_(cover) {}
void MQTTCoverComponent::setup() {
auto traits = this->cover_->get_traits();
this->cover_->add_on_state_callback([this]() { this->publish_state(); });
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
auto call = this->cover_->make_call();
call.set_command(payload.c_str());
call.perform();
});
if (traits.get_supports_position()) {
this->subscribe(this->get_position_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str());
return;
}
auto call = this->cover_->make_call();
call.set_position(*value / 100.0f);
call.perform();
});
this->subscribe(this->get_position_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str());
return;
}
auto call = this->cover_->make_call();
call.set_position(*value / 100.0f);
call.perform();
});
}
if (traits.get_supports_tilt()) {
this->subscribe(this->get_tilt_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str());
return;
}
auto call = this->cover_->make_call();
call.set_tilt(*value / 100.0f);
call.perform();
});
this->subscribe(this->get_tilt_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str());
return;
}
auto call = this->cover_->make_call();
call.set_tilt(*value / 100.0f);
call.perform();
});
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ using namespace esphome::datetime;
MQTTDateComponent::MQTTDateComponent(DateEntity *date) : date_(date) {}
void MQTTDateComponent::setup() {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) {
auto call = this->date_->make_call();
if (root[ESPHOME_F("year")].is<uint16_t>()) {
call.set_year(root[ESPHOME_F("year")]);
+1 -1
View File
@@ -17,7 +17,7 @@ using namespace esphome::datetime;
MQTTDateTimeComponent::MQTTDateTimeComponent(DateTimeEntity *datetime) : datetime_(datetime) {}
void MQTTDateTimeComponent::setup() {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) {
auto call = this->datetime_->make_call();
if (root[ESPHOME_F("year")].is<uint16_t>()) {
call.set_year(root[ESPHOME_F("year")]);
+27 -26
View File
@@ -28,7 +28,7 @@ MQTT_COMPONENT_TYPE(MQTTFanComponent, "fan")
const EntityBase *MQTTFanComponent::get_entity() const { return this->state_; }
void MQTTFanComponent::setup() {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
auto val = parse_on_off(payload.c_str());
switch (val) {
case PARSE_ON:
@@ -52,33 +52,34 @@ void MQTTFanComponent::setup() {
});
if (this->state_->get_traits().supports_direction()) {
this->subscribe(this->get_direction_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto val = parse_on_off(payload.c_str(), "forward", "reverse");
switch (val) {
case PARSE_ON:
ESP_LOGD(TAG, "'%s': Setting direction FORWARD", LOG_STR_ARG(this->log_name_()));
this->state_->make_call().set_direction(fan::FanDirection::FORWARD).perform();
break;
case PARSE_OFF:
ESP_LOGD(TAG, "'%s': Setting direction REVERSE", LOG_STR_ARG(this->log_name_()));
this->state_->make_call().set_direction(fan::FanDirection::REVERSE).perform();
break;
case PARSE_TOGGLE:
this->state_->make_call()
.set_direction(this->state_->direction == fan::FanDirection::FORWARD ? fan::FanDirection::REVERSE
: fan::FanDirection::FORWARD)
.perform();
break;
case PARSE_NONE:
ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str());
this->status_momentary_warning(5000);
break;
}
});
this->subscribe(
this->get_direction_command_topic_source(), [this](const std::string &topic, const std::string &payload) {
auto val = parse_on_off(payload.c_str(), "forward", "reverse");
switch (val) {
case PARSE_ON:
ESP_LOGD(TAG, "'%s': Setting direction FORWARD", LOG_STR_ARG(this->log_name_()));
this->state_->make_call().set_direction(fan::FanDirection::FORWARD).perform();
break;
case PARSE_OFF:
ESP_LOGD(TAG, "'%s': Setting direction REVERSE", LOG_STR_ARG(this->log_name_()));
this->state_->make_call().set_direction(fan::FanDirection::REVERSE).perform();
break;
case PARSE_TOGGLE:
this->state_->make_call()
.set_direction(this->state_->direction == fan::FanDirection::FORWARD ? fan::FanDirection::REVERSE
: fan::FanDirection::FORWARD)
.perform();
break;
case PARSE_NONE:
ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str());
this->status_momentary_warning(5000);
break;
}
});
}
if (this->state_->get_traits().supports_oscillation()) {
this->subscribe(this->get_oscillation_command_topic(),
this->subscribe(this->get_oscillation_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_on_off(payload.c_str(), "oscillate_on", "oscillate_off");
switch (val) {
@@ -102,7 +103,7 @@ void MQTTFanComponent::setup() {
}
if (this->state_->get_traits().supports_speed()) {
this->subscribe(this->get_speed_level_command_topic(),
this->subscribe(this->get_speed_level_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
optional<int> speed_level_opt = parse_number<int>(payload);
if (speed_level_opt.has_value()) {
+1 -1
View File
@@ -18,7 +18,7 @@ MQTT_COMPONENT_TYPE(MQTTJSONLightComponent, "light")
const EntityBase *MQTTJSONLightComponent::get_entity() const { return this->state_; }
void MQTTJSONLightComponent::setup() {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) {
LightCall call = this->state_->make_call();
LightJSONSchema::parse_json(*this->state_, call, root);
call.perform();
+1 -1
View File
@@ -16,7 +16,7 @@ using namespace esphome::lock;
MQTTLockComponent::MQTTLockComponent(lock::Lock *a_lock) : lock_(a_lock) {}
void MQTTLockComponent::setup() {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("LOCK")) == 0) {
this->lock_->lock();
} else if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("UNLOCK")) == 0) {
+1 -1
View File
@@ -19,7 +19,7 @@ PROGMEM_STRING_TABLE(NumberMqttModeStrings, "", "box", "slider");
MQTTNumberComponent::MQTTNumberComponent(Number *number) : number_(number) {}
void MQTTNumberComponent::setup() {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) {
auto val = parse_number<float>(state);
if (!val.has_value()) {
ESP_LOGW(TAG, "Can't convert '%s' to number!", state.c_str());
+1 -1
View File
@@ -15,7 +15,7 @@ using namespace esphome::select;
MQTTSelectComponent::MQTTSelectComponent(Select *select) : select_(select) {}
void MQTTSelectComponent::setup() {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) {
auto call = this->select_->make_call();
call.set_option(state);
call.perform();
+1 -1
View File
@@ -15,7 +15,7 @@ using namespace esphome::switch_;
MQTTSwitchComponent::MQTTSwitchComponent(switch_::Switch *a_switch) : switch_(a_switch) {}
void MQTTSwitchComponent::setup() {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
switch (parse_on_off(payload.c_str())) {
case PARSE_ON:
this->switch_->turn_on();
+1 -1
View File
@@ -19,7 +19,7 @@ PROGMEM_STRING_TABLE(TextMqttModeStrings, "text", "password");
MQTTTextComponent::MQTTTextComponent(Text *text) : text_(text) {}
void MQTTTextComponent::setup() {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) {
auto call = this->text_->make_call();
call.set_value(state);
call.perform();
+1 -1
View File
@@ -17,7 +17,7 @@ using namespace esphome::datetime;
MQTTTimeComponent::MQTTTimeComponent(TimeEntity *time) : time_(time) {}
void MQTTTimeComponent::setup() {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) {
auto call = this->time_->make_call();
if (root[ESPHOME_F("hour")].is<uint8_t>()) {
call.set_hour(root[ESPHOME_F("hour")]);
+1 -1
View File
@@ -15,7 +15,7 @@ using namespace esphome::update;
MQTTUpdateComponent::MQTTUpdateComponent(UpdateEntity *update) : update_(update) {}
void MQTTUpdateComponent::setup() {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
if (payload == "INSTALL") {
this->update_->perform();
} else {
+12 -11
View File
@@ -31,22 +31,23 @@ MQTTValveComponent::MQTTValveComponent(Valve *valve) : valve_(valve) {}
void MQTTValveComponent::setup() {
auto traits = this->valve_->get_traits();
this->valve_->add_on_state_callback([this]() { this->publish_state(); });
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
auto call = this->valve_->make_call();
call.set_command(payload.c_str());
call.perform();
});
if (traits.get_supports_position()) {
this->subscribe(this->get_position_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str());
return;
}
auto call = this->valve_->make_call();
call.set_position(*value / 100.0f);
call.perform();
});
this->subscribe(this->get_position_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str());
return;
}
auto call = this->valve_->make_call();
call.set_position(*value / 100.0f);
call.perform();
});
}
}
+2
View File
@@ -392,6 +392,8 @@ template<typename... X> class TemplatableValue<std::string, X...> {
}
bool has_value() const { return this->type_ != NONE; }
/// Whether the value comes from a lambda, so it may differ between reads.
bool is_lambda() const { return this->type_ == LAMBDA || this->type_ == STATELESS_LAMBDA; }
std::string value(X... x) const {
switch (this->type_) {
@@ -0,0 +1,41 @@
"""Tests for choosing where an mqtt trigger topic is stored."""
import pytest
from esphome.components.mqtt import MQTT_DEFAULT_TOPIC_MAX_LEN, _topic_source
from esphome.const import (
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
)
from esphome.core import CORE
# "é" is two bytes in UTF-8, so these topics are one character shorter than their byte length
LIMIT = MQTT_DEFAULT_TOPIC_MAX_LEN
@pytest.mark.parametrize(
("topic", "platform", "source"),
[
pytest.param("t" * (LIMIT - 1), PLATFORM_ESP8266, "progmem_topic", id="fits"),
pytest.param("t" * LIMIT, PLATFORM_ESP8266, "static_topic", id="too_long"),
pytest.param(
"é" + "t" * (LIMIT - 3), PLATFORM_ESP8266, "progmem_topic", id="utf8_fits"
),
pytest.param(
"é" + "t" * (LIMIT - 2),
PLATFORM_ESP8266,
"static_topic",
id="utf8_bytes_too_long",
),
pytest.param("t" * (LIMIT - 1), PLATFORM_ESP32, "static_topic", id="esp32"),
pytest.param("é" * 10, PLATFORM_ESP32, "static_topic", id="esp32_utf8"),
],
)
def test_topic_source(
monkeypatch: pytest.MonkeyPatch, topic: str, platform: str, source: str
) -> None:
"""ESP8266 keeps a topic in flash only when its UTF-8 bytes fit the copy buffer."""
monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: platform})
assert str(_topic_source(topic)).startswith(f"mqtt::{source}(")