diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index cb8b92cad0..7607a4e817 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -27,20 +27,23 @@ inline char *append_char(char *p, char c) { // 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. -// MQTT_COMPONENT_TYPE_MAX_LEN and MQTT_SUFFIX_MAX_LEN are defined in mqtt_component.h. +// MQTT_COMPONENT_TYPE_MAX_LEN, MQTT_SUFFIX_MAX_LEN, and MQTT_DEFAULT_TOPIC_MAX_LEN are in mqtt_component.h. // ESPHOME_DEVICE_NAME_MAX_LEN and OBJECT_ID_MAX_LEN are defined in entity_base.h. // This ensures the stack buffers below are always large enough. -static constexpr size_t TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) static constexpr size_t DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) - -// Stack buffer sizes - safe because all inputs are length-validated at config time -// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null -static constexpr size_t DEFAULT_TOPIC_MAX_LEN = - TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1; // Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null static constexpr size_t DISCOVERY_TOPIC_MAX_LEN = DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1; +// Function implementation of LOG_MQTT_COMPONENT macro to reduce code size +void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) { + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + if (state_topic) + ESP_LOGCONFIG(tag, " State Topic: '%s'", obj->get_state_topic_to_(buf).c_str()); + if (command_topic) + ESP_LOGCONFIG(tag, " Command Topic: '%s'", obj->get_command_topic_to_(buf).c_str()); +} + void MQTTComponent::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTComponent::set_subscribe_qos(uint8_t qos) { this->subscribe_qos_ = qos; } @@ -69,19 +72,18 @@ std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discove return std::string(buf, p - buf); } -std::string MQTTComponent::get_default_topic_for_(const std::string &suffix) const { +StringRef MQTTComponent::get_default_topic_for_to_(std::span buf, const char *suffix, + size_t suffix_len) const { const std::string &topic_prefix = global_mqtt_client->get_topic_prefix(); if (topic_prefix.empty()) { - // If the topic_prefix is null, the default topic should be null - return ""; + return StringRef(); // Empty topic_prefix means no default topic } const char *comp_type = this->component_type(); char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = this->get_default_object_id_to_(object_id_buf); - char buf[DEFAULT_TOPIC_MAX_LEN]; - char *p = buf; + char *p = buf.data(); p = append_str(p, topic_prefix.data(), topic_prefix.size()); p = append_char(p, '/'); @@ -89,21 +91,44 @@ std::string MQTTComponent::get_default_topic_for_(const std::string &suffix) con p = append_char(p, '/'); p = append_str(p, object_id.c_str(), object_id.size()); p = append_char(p, '/'); - p = append_str(p, suffix.data(), suffix.size()); + p = append_str(p, suffix, suffix_len); + *p = '\0'; - return std::string(buf, p - buf); + return StringRef(buf.data(), p - buf.data()); +} + +std::string MQTTComponent::get_default_topic_for_(const std::string &suffix) const { + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + StringRef ref = this->get_default_topic_for_to_(buf, suffix.data(), suffix.size()); + return std::string(ref.c_str(), ref.size()); +} + +StringRef MQTTComponent::get_state_topic_to_(std::span buf) const { + if (this->custom_state_topic_.has_value()) { + // Returns ref to existing data for static/value, uses buf only for lambda case + return this->custom_state_topic_.ref_or_copy_to(buf.data(), buf.size()); + } + return this->get_default_topic_for_to_(buf, "state", 5); +} + +StringRef MQTTComponent::get_command_topic_to_(std::span buf) const { + if (this->custom_command_topic_.has_value()) { + // Returns ref to existing data for static/value, uses buf only for lambda case + return this->custom_command_topic_.ref_or_copy_to(buf.data(), buf.size()); + } + return this->get_default_topic_for_to_(buf, "command", 7); } std::string MQTTComponent::get_state_topic_() const { - if (this->custom_state_topic_.has_value()) - return this->custom_state_topic_.value(); - return this->get_default_topic_for_("state"); + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + StringRef ref = this->get_state_topic_to_(buf); + return std::string(ref.c_str(), ref.size()); } std::string MQTTComponent::get_command_topic_() const { - if (this->custom_command_topic_.has_value()) - return this->custom_command_topic_.value(); - return this->get_default_topic_for_("command"); + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + StringRef ref = this->get_command_topic_to_(buf); + return std::string(ref.c_str(), ref.size()); } bool MQTTComponent::publish(const std::string &topic, const std::string &payload) { @@ -168,10 +193,14 @@ bool MQTTComponent::send_discovery_() { break; } - if (config.state_topic) - root[MQTT_STATE_TOPIC] = this->get_state_topic_(); - if (config.command_topic) - root[MQTT_COMMAND_TOPIC] = this->get_command_topic_(); + if (config.state_topic) { + char state_topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + root[MQTT_STATE_TOPIC] = this->get_state_topic_to_(state_topic_buf); + } + if (config.command_topic) { + char command_topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + root[MQTT_COMMAND_TOPIC] = this->get_command_topic_to_(command_topic_buf); + } if (this->command_retain_) root[MQTT_COMMAND_RETAIN] = true; @@ -309,7 +338,9 @@ void MQTTComponent::set_availability(std::string topic, std::string payload_avai } void MQTTComponent::disable_availability() { this->set_availability("", "", ""); } void MQTTComponent::call_setup() { - if (this->is_internal()) + // Cache is_internal result once during setup - topics don't change after this + this->is_internal_ = this->compute_is_internal_(); + if (this->is_internal_) return; this->setup(); @@ -361,26 +392,28 @@ StringRef MQTTComponent::get_default_object_id_to_(std::spanget_entity()->get_icon_ref(); } bool MQTTComponent::is_disabled_by_default_() const { return this->get_entity()->is_disabled_by_default(); } -bool MQTTComponent::is_internal() { +bool MQTTComponent::compute_is_internal_() { if (this->custom_state_topic_.has_value()) { - // If the custom state_topic is null, return true as it is internal and should not publish + // If the custom state_topic is empty, return true as it is internal and should not publish // else, return false, as it is explicitly set to a topic, so it is not internal and should publish - return this->get_state_topic_().empty(); + // Using is_empty() avoids heap allocation for non-lambda cases + return this->custom_state_topic_.is_empty(); } if (this->custom_command_topic_.has_value()) { - // If the custom command_topic is null, return true as it is internal and should not publish + // If the custom command_topic is empty, return true as it is internal and should not publish // else, return false, as it is explicitly set to a topic, so it is not internal and should publish - return this->get_command_topic_().empty(); + // Using is_empty() avoids heap allocation for non-lambda cases + return this->custom_command_topic_.is_empty(); } - // No custom topics have been set - if (this->get_default_topic_for_("").empty()) { - // If the default topic prefix is null, then the component, by default, is internal and should not publish + // No custom topics have been set - check topic_prefix directly to avoid allocation + if (global_mqtt_client->get_topic_prefix().empty()) { + // If the default topic prefix is empty, then the component, by default, is internal and should not publish return true; } - // Use ESPHome's component internal state if topic_prefix is not null with no custom state_topic or command_topic + // Use ESPHome's component internal state if topic_prefix is not empty with no custom state_topic or command_topic return this->get_entity()->is_internal(); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index dea91e3d5a..eea0424c8d 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -20,17 +20,22 @@ struct SendDiscoveryConfig { bool command_topic{true}; ///< If the command topic should be included. Default to true. }; -// Max lengths for stack-based topic building (must match mqtt_component.cpp) +// 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; -#define LOG_MQTT_COMPONENT(state_topic, command_topic) \ - if (state_topic) { \ - ESP_LOGCONFIG(TAG, " State Topic: '%s'", this->get_state_topic_().c_str()); \ - } \ - if (command_topic) { \ - ESP_LOGCONFIG(TAG, " Command Topic: '%s'", this->get_command_topic_().c_str()); \ - } +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) // Macro to define component_type() with compile-time length verification // Usage: MQTT_COMPONENT_TYPE(MQTTSensorComponent, "sensor") @@ -74,6 +79,8 @@ static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32; * a clean separation. */ class MQTTComponent : public Component { + friend void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic); + public: /// Constructs a MQTTComponent. explicit MQTTComponent(); @@ -88,7 +95,8 @@ class MQTTComponent : public Component { virtual bool send_initial_state() = 0; - virtual bool is_internal(); + /// Returns cached is_internal result (computed once during setup). + bool is_internal() const { return this->is_internal_; } /// Set QOS for state messages. void set_qos(uint8_t qos); @@ -179,7 +187,16 @@ class MQTTComponent : public Component { /// Helper method to get the discovery topic for this component. std::string get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const; - /** Get this components state/command/... topic. + /** Get this components state/command/... topic into a buffer. + * + * @param buf The buffer to write to (must be at least DEFAULT_TOPIC_MAX_LEN). + * @param suffix The suffix/key such as "state" or "command". + * @return StringRef pointing to the buffer with the topic. + */ + StringRef get_default_topic_for_to_(std::span buf, const char *suffix, + size_t suffix_len) const; + + /** Get this components state/command/... topic (allocates std::string). * * @param suffix The suffix/key such as "state" or "command". * @return The full topic. @@ -200,10 +217,20 @@ class MQTTComponent : public Component { /// Get whether the underlying Entity is disabled by default bool is_disabled_by_default_() const; - /// Get the MQTT topic that new states will be shared to. + /// Get the MQTT state topic into a buffer (no heap allocation for non-lambda custom topics). + /// @param buf Buffer of exactly MQTT_DEFAULT_TOPIC_MAX_LEN bytes. + /// @return StringRef pointing to the topic in the buffer. + StringRef get_state_topic_to_(std::span buf) const; + + /// Get the MQTT command topic into a buffer (no heap allocation for non-lambda custom topics). + /// @param buf Buffer of exactly MQTT_DEFAULT_TOPIC_MAX_LEN bytes. + /// @return StringRef pointing to the topic in the buffer. + StringRef get_command_topic_to_(std::span buf) const; + + /// Get the MQTT topic that new states will be shared to (allocates std::string). std::string get_state_topic_() const; - /// Get the MQTT topic for listening to commands. + /// Get the MQTT topic for listening to commands (allocates std::string). std::string get_command_topic_() const; bool is_connected_() const; @@ -221,12 +248,18 @@ class MQTTComponent : public Component { std::unique_ptr availability_; - bool command_retain_{false}; - bool retain_{true}; - uint8_t qos_{0}; - uint8_t subscribe_qos_{0}; - bool discovery_enabled_{true}; - bool resend_state_{false}; + // Packed bitfields - QoS values are 0-2, bools are flags + uint8_t qos_ : 2 {0}; + uint8_t subscribe_qos_ : 2 {0}; + bool command_retain_ : 1 {false}; + bool retain_ : 1 {true}; + bool discovery_enabled_ : 1 {true}; + bool resend_state_ : 1 {false}; + bool is_internal_ : 1 {false}; ///< Cached result of compute_is_internal_(), set during setup + + /// Compute is_internal status based on topics and entity state. + /// Called once during setup to cache the result. + bool compute_is_internal_(); }; } // namespace esphome::mqtt diff --git a/esphome/core/automation.h b/esphome/core/automation.h index eac469d0fc..31a2fc06f4 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -4,6 +4,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include #include #include @@ -190,15 +191,55 @@ template class TemplatableValue { /// Get the static string pointer (only valid if is_static_string() returns true) const char *get_static_string() const { return this->static_str_; } - protected: - enum : uint8_t { - NONE, - VALUE, - LAMBDA, - STATELESS_LAMBDA, - STATIC_STRING, // For const char* when T is std::string - avoids heap allocation - } type_; + /// Check if the string value is empty without allocating (for std::string specialization). + /// For NONE, returns true. For STATIC_STRING/VALUE, checks without allocation. + /// For LAMBDA/STATELESS_LAMBDA, must call value() which may allocate. + bool is_empty() const requires std::same_as { + switch (this->type_) { + case NONE: + return true; + case STATIC_STRING: + return this->static_str_ == nullptr || this->static_str_[0] == '\0'; + case VALUE: + return this->value_->empty(); + default: // LAMBDA/STATELESS_LAMBDA - must call value() + return this->value().empty(); + } + } + /// Get a StringRef to the string value without heap allocation when possible. + /// For STATIC_STRING/VALUE, returns reference to existing data (no allocation). + /// For LAMBDA/STATELESS_LAMBDA, calls value(), copies to provided buffer, returns ref to buffer. + /// @param lambda_buf Buffer used only for lambda case (must remain valid while StringRef is used). + /// @param lambda_buf_size Size of the buffer. + /// @return StringRef pointing to the string data. + StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const requires std::same_as { + switch (this->type_) { + case NONE: + return StringRef(); + case STATIC_STRING: + if (this->static_str_ == nullptr) + return StringRef(); + return StringRef(this->static_str_, strlen(this->static_str_)); + case VALUE: + return StringRef(this->value_->data(), this->value_->size()); + default: { // LAMBDA/STATELESS_LAMBDA - must call value() and copy + std::string result = this->value(); + size_t copy_len = std::min(result.size(), lambda_buf_size - 1); + memcpy(lambda_buf, result.data(), copy_len); + lambda_buf[copy_len] = '\0'; + return StringRef(lambda_buf, copy_len); + } + } + } + + protected : enum : uint8_t { + NONE, + VALUE, + LAMBDA, + STATELESS_LAMBDA, + STATIC_STRING, // For const char* when T is std::string - avoids heap allocation + } type_; // For std::string, use heap pointer to minimize union size (4 bytes vs 12+). // For other types, store value inline as before. using ValueStorage = std::conditional_t;