From 0b2f79480be3f703b84ea616425987c6bdab93eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Feb 2026 19:51:39 -0600 Subject: [PATCH] [api] Use StringRef for user service string arguments Replace std::string with StringRef (non-owning string view) for user service string arguments in YAML-generated services. This avoids unnecessary heap allocation when the protobuf decode buffer already contains the string data. Key changes: - Frame helpers reserve +1 byte in rx_buf_ so string fields can be safely null-terminated in-place after decode - Add (null_terminate) protobuf field option to target only fields that need it (ExecuteServiceArgument.string_ and HomeAssistantStateResponse.state) - Add StringRef template specializations for get_execute_arg_value and to_service_arg_type - Python codegen uses StringRef for string service args, with automatic std::string fallback when deferred actions (delay, wait_until, script.wait) are present in the action chain - Add deferred flag to action registry for detecting actions that store trigger args for later execution - Simplify HomeAssistantStateResponse handler by removing SmallBufferWithHeapFallback copy (state is null-terminated in-place) - Add compare() method to StringRef for external component compatibility Saves ~240 bytes of flash on ESP8266 by eliminating std::string template instantiations for user service string arguments. --- esphome/automation.py | 37 +++++++++++++++++-- esphome/components/api/__init__.py | 9 ++++- esphome/components/api/api.proto | 4 +- esphome/components/api/api_connection.cpp | 23 +++--------- .../components/api/api_frame_helper_noise.cpp | 7 ++-- .../api/api_frame_helper_plaintext.cpp | 7 ++-- esphome/components/api/api_options.proto | 9 +++++ esphome/components/api/api_pb2.cpp | 9 +++++ esphome/components/api/api_pb2.h | 1 + esphome/components/api/user_services.cpp | 5 +++ esphome/components/script/__init__.py | 1 + esphome/core/string_ref.h | 13 +++++++ esphome/util.py | 14 ++++++- script/api_protobuf/api_protobuf.py | 17 ++++++++- 14 files changed, 121 insertions(+), 35 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 2439b1ddc4..c937470eee 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -57,8 +57,14 @@ def maybe_conf(conf, *validators): return validate -def register_action(name: str, action_type: MockObjClass, schema: cv.Schema): - return ACTION_REGISTRY.register(name, action_type, schema) +def register_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + deferred: bool = False, +): + return ACTION_REGISTRY.register(name, action_type, schema, deferred=deferred) def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schema): @@ -335,7 +341,10 @@ async def component_is_idle_condition_to_code( @register_action( - "delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds) + "delay", + DelayAction, + cv.templatable(cv.positive_time_period_milliseconds), + deferred=True, ) async def delay_action_to_code( config: ConfigType, @@ -445,7 +454,7 @@ _validate_wait_until = cv.maybe_simple_value( ) -@register_action("wait_until", WaitUntilAction, _validate_wait_until) +@register_action("wait_until", WaitUntilAction, _validate_wait_until, deferred=True) async def wait_until_action_to_code( config: ConfigType, action_id: ID, @@ -578,6 +587,26 @@ async def build_condition_list( return conditions +def has_deferred_actions(actions: ConfigType) -> bool: + """Check if a validated action list contains any deferred actions. + + Deferred actions (delay, wait_until, script.wait) store trigger args + for later execution, making non-owning types like StringRef unsafe. + """ + if isinstance(actions, list): + return any(has_deferred_actions(item) for item in actions) + if isinstance(actions, dict): + for key in actions: + if key in ACTION_REGISTRY and ACTION_REGISTRY[key].deferred: + return True + return any( + has_deferred_actions(v) + for v in actions.values() + if isinstance(v, (list, dict)) + ) + return False + + async def build_automation( trigger: MockObj, args: TemplateArgsType, config: ConfigType ) -> MockObj: diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9bff9f5635..7df23ae1ba 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -76,7 +76,7 @@ SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = { "bool": cg.bool_, "int": cg.int32, "float": cg.float_, - "string": cg.std_string, + "string": cg.StringRef, "bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"), "int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"), "float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"), @@ -380,9 +380,16 @@ async def to_code(config: ConfigType) -> None: if is_optional: func_args.append((cg.bool_, "return_response")) + # Check if action chain has deferred actions that would make + # non-owning StringRef dangle (rx_buf_ reused after delay) + has_deferred = automation.has_deferred_actions(conf.get(CONF_THEN, [])) + service_arg_names: list[str] = [] for name, var_ in conf[CONF_VARIABLES].items(): native = SERVICE_ARG_NATIVE_TYPES[var_] + # Fall back to std::string for string args if deferred actions exist + if has_deferred and native is cg.StringRef: + native = cg.std_string service_template_args.append(native) func_args.append((native, name)) service_arg_names.append(name) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 18dac6a2d1..b8dfb71a6a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -824,7 +824,7 @@ message HomeAssistantStateResponse { option (ifdef) = "USE_API_HOMEASSISTANT_STATES"; string entity_id = 1; - string state = 2; + string state = 2 [(null_terminate) = true]; string attribute = 3; } @@ -882,7 +882,7 @@ message ExecuteServiceArgument { bool bool_ = 1; int32 legacy_int = 2; float float_ = 3; - string string_ = 4; + string string_ = 4 [(null_terminate) = true]; // ESPHome 1.14 (api v1.3) make int a signed value sint32 int_ = 5; repeated bool bool_array = 6 [packed=false, (fixed_vector) = true]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4d564af9e2..bffcd490ac 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1683,31 +1683,18 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes } for (auto &it : this->parent_->get_state_subs()) { - // Compare entity_id: check length matches and content matches - size_t entity_id_len = strlen(it.entity_id); - if (entity_id_len != msg.entity_id.size() || - memcmp(it.entity_id, msg.entity_id.c_str(), msg.entity_id.size()) != 0) { + if (msg.entity_id != it.entity_id) { continue; } // Compare attribute: either both have matching attribute, or both have none - size_t sub_attr_len = it.attribute != nullptr ? strlen(it.attribute) : 0; - if (sub_attr_len != msg.attribute.size() || - (sub_attr_len > 0 && memcmp(it.attribute, msg.attribute.c_str(), sub_attr_len) != 0)) { + // it.attribute can be nullptr (meaning no attribute filter) + if (it.attribute != nullptr ? msg.attribute != it.attribute : !msg.attribute.empty()) { continue; } - // Create null-terminated state for callback (parse_number needs null-termination) - // HA state max length is 255 characters, but attributes can be much longer - // Use stack buffer for common case (states), heap fallback for large attributes - size_t state_len = msg.state.size(); - SmallBufferWithHeapFallback state_buf_alloc(state_len + 1); - char *state_buf = reinterpret_cast(state_buf_alloc.get()); - if (state_len > 0) { - memcpy(state_buf, msg.state.c_str(), state_len); - } - state_buf[state_len] = '\0'; - it.callback(StringRef(state_buf, state_len)); + // msg.state is already null-terminated in-place after protobuf decode + it.callback(msg.state); } } #endif diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 1ae848dead..a6928bb936 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -201,9 +201,10 @@ APIError APINoiseFrameHelper::try_read_frame_() { return (state_ == State::DATA) ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN; } - // Reserve space for body - if (this->rx_buf_.size() != msg_size) { - this->rx_buf_.resize(msg_size); + // Reserve space for body (+1 for null terminator so protobuf StringRef fields + // can be safely null-terminated in-place after decode) + if (this->rx_buf_.size() != msg_size + 1) { + this->rx_buf_.resize(msg_size + 1); } if (rx_buf_len_ < msg_size) { diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 5069dbf68b..b721843d07 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -163,9 +163,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { } // header reading done - // Reserve space for body - if (this->rx_buf_.size() != this->rx_header_parsed_len_) { - this->rx_buf_.resize(this->rx_header_parsed_len_); + // Reserve space for body (+1 for null terminator so protobuf StringRef fields + // can be safely null-terminated in-place after decode) + if (this->rx_buf_.size() != this->rx_header_parsed_len_ + 1) { + this->rx_buf_.resize(this->rx_header_parsed_len_ + 1); } if (rx_buf_len_ < rx_header_parsed_len_) { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index a863f2c7a8..163a170fb9 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -90,4 +90,13 @@ extend google.protobuf.FieldOptions { // - uint16_t _length_{0}; // - uint16_t _count_{0}; optional bool packed_buffer = 50015 [default=false]; + + // null_terminate: Write a null byte after string data in the decode buffer. + // When set on a string field in a SOURCE_CLIENT (decodable) message, the + // generated decode() override writes '\0' at data[length] after decoding. + // This makes the StringRef safe for c_str() usage without copying. + // Safe because: (1) frame helpers reserve +1 byte in rx_buf_, and + // (2) the overwritten byte was already consumed during decode. + // Only mark fields that actually need null-terminated access. + optional bool null_terminate = 50016 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 743f51dac7..015dae37ab 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -953,6 +953,12 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel } return true; } +void HomeAssistantStateResponse::decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode(buffer, length); + if (!this->state.empty()) { + const_cast(this->state.c_str())[this->state.size()] = '\0'; + } +} #endif bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -1057,6 +1063,9 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { uint32_t count_string_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 9); this->string_array.init(count_string_array); ProtoDecodableMessage::decode(buffer, length); + if (!this->string_.empty()) { + const_cast(this->string_.c_str())[this->string_.size()] = '\0'; + } } bool ExecuteServiceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d001f869c5..6cf60bee0a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1095,6 +1095,7 @@ class HomeAssistantStateResponse final : public ProtoDecodableMessage { StringRef entity_id{}; StringRef state{}; StringRef attribute{}; + void decode(const uint8_t *buffer, size_t length) override; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/user_services.cpp b/esphome/components/api/user_services.cpp index 9c2b4aa79a..28a43c656c 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -1,5 +1,6 @@ #include "user_services.h" #include "esphome/core/log.h" +#include "esphome/core/string_ref.h" namespace esphome::api { @@ -11,6 +12,8 @@ template<> int32_t get_execute_arg_value(const ExecuteServiceArgument & } template<> float get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.float_; } template<> std::string get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.string_; } +// Zero-copy StringRef version for YAML-generated services (string_ is null-terminated after decode) +template<> StringRef get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.string_; } // Legacy std::vector versions for external components using custom_api_device.h - optimized with reserve template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { @@ -61,6 +64,8 @@ template<> enums::ServiceArgType to_service_arg_type() { return enums::SER template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_INT; } template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_FLOAT; } template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_STRING; } +// Zero-copy StringRef version for YAML-generated services +template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_STRING; } // Legacy std::vector versions for external components using custom_api_device.h template<> enums::ServiceArgType to_service_arg_type>() { return enums::SERVICE_ARG_TYPE_BOOL_ARRAY; } diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 8d69981db0..0a9e289511 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -219,6 +219,7 @@ async def script_stop_action_to_code(config, action_id, template_arg, args): "script.wait", ScriptWaitAction, maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + deferred=True, ) async def script_wait_action_to_code(config, action_id, template_arg, args): full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index d502c4d27f..89ea9dd797 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -81,6 +81,19 @@ class StringRef { operator std::string() const { return str(); } + /// Compare with a null-terminated C string (compatible with std::string::compare) + int compare(const char *s) const { + size_t s_len = std::strlen(s); + int result = std::memcmp(base_, s, std::min(len_, s_len)); + if (result != 0) + return result; + if (len_ < s_len) + return -1; + if (len_ > s_len) + return 1; + return 0; + } + /// Find first occurrence of substring, returns std::string::npos if not found. /// Note: Requires the underlying string to be null-terminated. size_type find(const char *s, size_type pos = 0) const { diff --git a/esphome/util.py b/esphome/util.py index 7b896de27e..9fb7ef6227 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -24,11 +24,14 @@ class RegistryEntry: fun: Callable[..., Any], type_id: "MockObjClass", schema: "Schema", + *, + deferred: bool = False, ): self.name = name self.fun = fun self.type_id = type_id self.raw_schema = schema + self.deferred = deferred @property def coroutine_fun(self): @@ -49,9 +52,16 @@ class Registry(dict[str, RegistryEntry]): self.base_schema = base_schema or {} self.type_id_key = type_id_key - def register(self, name: str, type_id: "MockObjClass", schema: "Schema"): + def register( + self, + name: str, + type_id: "MockObjClass", + schema: "Schema", + *, + deferred: bool = False, + ): def decorator(fun: Callable[..., Any]): - self[name] = RegistryEntry(name, fun, type_id, schema) + self[name] = RegistryEntry(name, fun, type_id, schema, deferred=deferred) return fun return decorator diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 4fbee49dae..30e827e9a7 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2020,6 +2020,8 @@ def build_message_type( # Collect fixed_vector fields for custom decode generation fixed_vector_fields = [] + # Collect fields with (null_terminate) = true option + null_terminate_fields = [] for field in desc.field: # Skip deprecated fields completely @@ -2062,6 +2064,10 @@ def build_message_type( ti = create_field_type_info(field, needs_decode, needs_encode) + # Collect fields with (null_terminate) = true for post-decode null-termination + if needs_decode and get_field_opt(field, pb.null_terminate, False): + null_terminate_fields.append(ti.field_name) + # Skip field declarations for fields that are in the base class # but include their encode/decode logic if field.name not in common_field_names: @@ -2168,8 +2174,8 @@ def build_message_type( prot = "bool decode_64bit(uint32_t field_id, Proto64Bit value) override;" protected_content.insert(0, prot) - # Generate custom decode() override for messages with FixedVector fields - if fixed_vector_fields: + # Generate custom decode() override for messages with FixedVector or null_terminate fields + if fixed_vector_fields or null_terminate_fields: # Generate the decode() implementation in cpp o = f"void {desc.name}::decode(const uint8_t *buffer, size_t length) {{\n" # Count and init each FixedVector field @@ -2178,6 +2184,13 @@ def build_message_type( o += f" this->{field_name}.init(count_{field_name});\n" # Call parent decode to populate the fields o += " ProtoDecodableMessage::decode(buffer, length);\n" + # Null-terminate fields marked with (null_terminate) = true in-place. + # Safe: decode is complete, byte after string was already parsed (next field tag) + # or is the +1 reserved byte at end of rx_buf_. + for field_name in null_terminate_fields: + o += f" if (!this->{field_name}.empty()) {{\n" + o += f" const_cast(this->{field_name}.c_str())[this->{field_name}.size()] = '\\0';\n" + o += " }\n" o += "}\n" cpp += o # Generate the decode() declaration in header (public method)