From 940afdbb12e212299edc1ad67481ef9a6d5f568e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Dec 2025 11:18:50 -1000 Subject: [PATCH] [climate] Add zero-copy support for API custom fan mode and preset commands (#12402) --- esphome/components/api/api.proto | 4 +- esphome/components/api/api_connection.cpp | 4 +- esphome/components/api/api_pb2.cpp | 14 +++++-- esphome/components/api/api_pb2.h | 8 ++-- esphome/components/api/api_pb2_dump.cpp | 8 +++- esphome/components/climate/climate.cpp | 45 +++++++++++++++------ esphome/components/climate/climate.h | 6 +++ esphome/components/climate/climate_traits.h | 22 +++++++--- 8 files changed, 80 insertions(+), 31 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 50af5061c0..dd8320bebb 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1091,11 +1091,11 @@ message ClimateCommandRequest { bool has_swing_mode = 14; ClimateSwingMode swing_mode = 15; bool has_custom_fan_mode = 16; - string custom_fan_mode = 17; + string custom_fan_mode = 17 [(pointer_to_buffer) = true]; bool has_preset = 18; ClimatePreset preset = 19; bool has_custom_preset = 20; - string custom_preset = 21; + string custom_preset = 21 [(pointer_to_buffer) = true]; bool has_target_humidity = 22; float target_humidity = 23; uint32 device_id = 24 [(field_ifdef) = "USE_DEVICES"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 85f4566f3c..686fdcba41 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -712,11 +712,11 @@ void APIConnection::climate_command(const ClimateCommandRequest &msg) { if (msg.has_fan_mode) call.set_fan_mode(static_cast(msg.fan_mode)); if (msg.has_custom_fan_mode) - call.set_fan_mode(msg.custom_fan_mode); + call.set_fan_mode(reinterpret_cast(msg.custom_fan_mode), msg.custom_fan_mode_len); if (msg.has_preset) call.set_preset(static_cast(msg.preset)); if (msg.has_custom_preset) - call.set_preset(msg.custom_preset); + call.set_preset(reinterpret_cast(msg.custom_preset), msg.custom_preset_len); if (msg.has_swing_mode) call.set_swing_mode(static_cast(msg.swing_mode)); call.perform(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 52f4b495e9..211f856e3b 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1392,12 +1392,18 @@ bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) } bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 17: - this->custom_fan_mode = value.as_string(); + case 17: { + // Use raw data directly to avoid allocation + this->custom_fan_mode = value.data(); + this->custom_fan_mode_len = value.size(); break; - case 21: - this->custom_preset = value.as_string(); + } + case 21: { + // Use raw data directly to avoid allocation + this->custom_preset = value.data(); + this->custom_preset_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index f23a62fc3c..4e10c63881 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1475,7 +1475,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage { class ClimateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 48; - static constexpr uint8_t ESTIMATED_SIZE = 84; + static constexpr uint8_t ESTIMATED_SIZE = 104; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "climate_command_request"; } #endif @@ -1492,11 +1492,13 @@ class ClimateCommandRequest final : public CommandProtoMessage { bool has_swing_mode{false}; enums::ClimateSwingMode swing_mode{}; bool has_custom_fan_mode{false}; - std::string custom_fan_mode{}; + const uint8_t *custom_fan_mode{nullptr}; + uint16_t custom_fan_mode_len{0}; bool has_preset{false}; enums::ClimatePreset preset{}; bool has_custom_preset{false}; - std::string custom_preset{}; + const uint8_t *custom_preset{nullptr}; + uint16_t custom_preset_len{0}; bool has_target_humidity{false}; float target_humidity{0.0f}; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5e271f41cb..90e8e75c93 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1374,11 +1374,15 @@ void ClimateCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_swing_mode", this->has_swing_mode); dump_field(out, "swing_mode", static_cast(this->swing_mode)); dump_field(out, "has_custom_fan_mode", this->has_custom_fan_mode); - dump_field(out, "custom_fan_mode", this->custom_fan_mode); + out.append(" custom_fan_mode: "); + out.append(format_hex_pretty(this->custom_fan_mode, this->custom_fan_mode_len)); + out.append("\n"); dump_field(out, "has_preset", this->has_preset); dump_field(out, "preset", static_cast(this->preset)); dump_field(out, "has_custom_preset", this->has_custom_preset); - dump_field(out, "custom_preset", this->custom_preset); + out.append(" custom_preset: "); + out.append(format_hex_pretty(this->custom_preset, this->custom_preset_len)); + out.append("\n"); dump_field(out, "has_target_humidity", this->has_target_humidity); dump_field(out, "target_humidity", this->target_humidity); #ifdef USE_DEVICES diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 3bc20a17c6..229862ce01 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -2,6 +2,7 @@ #include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" #include "esphome/core/macros.h" +#include namespace esphome::climate { @@ -190,24 +191,30 @@ ClimateCall &ClimateCall::set_fan_mode(ClimateFanMode fan_mode) { } ClimateCall &ClimateCall::set_fan_mode(const char *custom_fan_mode) { + return this->set_fan_mode(custom_fan_mode, strlen(custom_fan_mode)); +} + +ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { + return this->set_fan_mode(fan_mode.data(), fan_mode.size()); +} + +ClimateCall &ClimateCall::set_fan_mode(const char *custom_fan_mode, size_t len) { // Check if it's a standard enum mode first for (const auto &mode_entry : CLIMATE_FAN_MODES_BY_STR) { - if (str_equals_case_insensitive(custom_fan_mode, mode_entry.str)) { + if (strncasecmp(custom_fan_mode, mode_entry.str, len) == 0 && mode_entry.str[len] == '\0') { return this->set_fan_mode(static_cast(mode_entry.value)); } } // Find the matching pointer from parent climate device - if (const char *mode_ptr = this->parent_->find_custom_fan_mode_(custom_fan_mode)) { + if (const char *mode_ptr = this->parent_->find_custom_fan_mode_(custom_fan_mode, len)) { this->custom_fan_mode_ = mode_ptr; this->fan_mode_.reset(); return *this; } - ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %s", this->parent_->get_name().c_str(), custom_fan_mode); + ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %.*s", this->parent_->get_name().c_str(), (int) len, custom_fan_mode); return *this; } -ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { return this->set_fan_mode(fan_mode.c_str()); } - ClimateCall &ClimateCall::set_fan_mode(optional fan_mode) { if (fan_mode.has_value()) { this->set_fan_mode(fan_mode.value()); @@ -222,24 +229,30 @@ ClimateCall &ClimateCall::set_preset(ClimatePreset preset) { } ClimateCall &ClimateCall::set_preset(const char *custom_preset) { + return this->set_preset(custom_preset, strlen(custom_preset)); +} + +ClimateCall &ClimateCall::set_preset(const std::string &preset) { + return this->set_preset(preset.data(), preset.size()); +} + +ClimateCall &ClimateCall::set_preset(const char *custom_preset, size_t len) { // Check if it's a standard enum preset first for (const auto &preset_entry : CLIMATE_PRESETS_BY_STR) { - if (str_equals_case_insensitive(custom_preset, preset_entry.str)) { + if (strncasecmp(custom_preset, preset_entry.str, len) == 0 && preset_entry.str[len] == '\0') { return this->set_preset(static_cast(preset_entry.value)); } } // Find the matching pointer from parent climate device - if (const char *preset_ptr = this->parent_->find_custom_preset_(custom_preset)) { + if (const char *preset_ptr = this->parent_->find_custom_preset_(custom_preset, len)) { this->custom_preset_ = preset_ptr; this->preset_.reset(); return *this; } - ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), custom_preset); + ESP_LOGW(TAG, "'%s' - Unrecognized preset %.*s", this->parent_->get_name().c_str(), (int) len, custom_preset); return *this; } -ClimateCall &ClimateCall::set_preset(const std::string &preset) { return this->set_preset(preset.c_str()); } - ClimateCall &ClimateCall::set_preset(optional preset) { if (preset.has_value()) { this->set_preset(preset.value()); @@ -688,11 +701,19 @@ bool Climate::set_custom_preset_(const char *preset) { void Climate::clear_custom_preset_() { this->custom_preset_ = nullptr; } const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { - return this->get_traits().find_custom_fan_mode_(custom_fan_mode); + return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); +} + +const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) { + return this->get_traits().find_custom_fan_mode_(custom_fan_mode, len); } const char *Climate::find_custom_preset_(const char *custom_preset) { - return this->get_traits().find_custom_preset_(custom_preset); + return this->find_custom_preset_(custom_preset, strlen(custom_preset)); +} + +const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { + return this->get_traits().find_custom_preset_(custom_preset, len); } void Climate::dump_traits_(const char *tag) { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 82df4b815f..0bae28df5a 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -78,6 +78,8 @@ class ClimateCall { ClimateCall &set_fan_mode(optional fan_mode); /// Set the custom fan mode of the climate device. ClimateCall &set_fan_mode(const char *custom_fan_mode); + /// Set the custom fan mode of the climate device (zero-copy API path). + ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len); /// Set the swing mode of the climate device. ClimateCall &set_swing_mode(ClimateSwingMode swing_mode); /// Set the swing mode of the climate device. @@ -94,6 +96,8 @@ class ClimateCall { ClimateCall &set_preset(optional preset); /// Set the custom preset of the climate device. ClimateCall &set_preset(const char *custom_preset); + /// Set the custom preset of the climate device (zero-copy API path). + ClimateCall &set_preset(const char *custom_preset, size_t len); void perform(); @@ -290,9 +294,11 @@ class Climate : public EntityBase { /// Find and return the matching custom fan mode pointer from traits, or nullptr if not found. const char *find_custom_fan_mode_(const char *custom_fan_mode); + const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len); /// Find and return the matching custom preset pointer from traits, or nullptr if not found. const char *find_custom_preset_(const char *custom_preset); + const char *find_custom_preset_(const char *custom_preset, size_t len); /** Get the default traits of this climate device. * diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index d358293475..80ef0854d5 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -20,18 +20,22 @@ using ClimatePresetMask = FiniteSetMask &vec, const char *value) { +inline bool vector_contains(const std::vector &vec, const char *value, size_t len) { for (const char *item : vec) { - if (strcmp(item, value) == 0) + if (strncmp(item, value, len) == 0 && item[len] == '\0') return true; } return false; } +inline bool vector_contains(const std::vector &vec, const char *value) { + return vector_contains(vec, value, strlen(value)); +} + // Find and return matching pointer from vector, or nullptr if not found -inline const char *vector_find(const std::vector &vec, const char *value) { +inline const char *vector_find(const std::vector &vec, const char *value, size_t len) { for (const char *item : vec) { - if (strcmp(item, value) == 0) + if (strncmp(item, value, len) == 0 && item[len] == '\0') return item; } return nullptr; @@ -257,13 +261,19 @@ class ClimateTraits { /// Find and return the matching custom fan mode pointer from supported modes, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_fan_mode_() instead const char *find_custom_fan_mode_(const char *custom_fan_mode) const { - return vector_find(this->supported_custom_fan_modes_, custom_fan_mode); + return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); + } + const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len) const { + return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); } /// Find and return the matching custom preset pointer from supported presets, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_preset_() instead const char *find_custom_preset_(const char *custom_preset) const { - return vector_find(this->supported_custom_presets_, custom_preset); + return this->find_custom_preset_(custom_preset, strlen(custom_preset)); + } + const char *find_custom_preset_(const char *custom_preset, size_t len) const { + return vector_find(this->supported_custom_presets_, custom_preset, len); } uint32_t feature_flags_{0};