From b63b74f236d9622f6e467688b54450c440977e5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 20:10:41 -1000 Subject: [PATCH 01/12] [api] Allow the progmem option on string fields Mirrors the helper from the entity names change, so either PR can merge first. --- esphome/components/api/api_options.proto | 2 +- esphome/components/api/proto.h | 17 ++++++ esphome/core/string_ref.h | 52 +++++++++++++++++++ script/api_protobuf/api_protobuf.py | 52 +++++++++++++++---- .../core/test_progmem_string_ref.cpp | 49 +++++++++++++++++ .../api/test_api_protobuf_generator.py | 35 ++++++++++++- 6 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 tests/components/core/test_progmem_string_ref.cpp diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 8e8fa99833d..052b0c36bc7 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -123,7 +123,7 @@ extend google.protobuf.FieldOptions { // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). optional bool track_presence = 50020 [default=false]; - // progmem: The pointer_to_buffer bytes field points into flash (PROGMEM). + // progmem: The string field, or pointer_to_buffer bytes field, points into flash (PROGMEM). // The generated encode and dump copy it with progmem_memcpy, as ESP8266 // cannot read flash with ordinary loads. optional bool progmem = 50021 [default=false]; diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 0fdda5067ea..04ce317caca 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -409,6 +409,10 @@ class ProtoEncode { /// Tag must be a single-byte varint (< 128). Always encodes (no zero check). [[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const StringRef &ref) { +#ifdef USE_ESP8266 + // memcpy_P reads RAM too, so RAM strings share the outlined copy + return encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, tag, ProgmemStringRef(ref)); +#else #ifdef ESPHOME_DEBUG_API assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128"); #endif @@ -417,6 +421,19 @@ class ProtoEncode { pos[1] = static_cast(ref.size()); std::memcpy(pos + 2, ref.c_str(), ref.size()); return pos + 2 + ref.size(); +#endif + } + /// encode_short_string_force for a string that may be in flash (PROGMEM). + [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *encode_short_progmem_string_force( + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) { +#ifdef ESPHOME_DEBUG_API + assert(ref.size() < 128 && "encode_short_progmem_string_force: string exceeds max_data_length < 128"); +#endif + PROTO_ENCODE_CHECK_BOUNDS(pos, 2 + ref.size()); + pos[0] = tag; + pos[1] = static_cast(ref.size()); + progmem_memcpy(pos + 2, ref.progmem_ptr(), ref.size()); + return pos + 2 + ref.size(); } /// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field. [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32( diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 4c0ce28a5ac..f9e2e7ed471 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -222,6 +223,57 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { str.append(rhs.c_str(), rhs.size()); return str; } + +/// A string that may live in flash (on ESP8266): pointer and length, with no byte-reading operations. +/// progmem_ptr() is safe as a "%s" log argument; read the contents with write_to() or equals(). +class ProgmemStringRef { + public: + constexpr ProgmemStringRef() = default; + constexpr ProgmemStringRef(const char *s, size_t len) : ref_(s, len) {} + constexpr explicit ProgmemStringRef(const StringRef &s) : ref_(s) {} + + constexpr const char *progmem_ptr() const { return this->ref_.c_str(); } + constexpr size_t size() const { return this->ref_.size(); } + constexpr bool empty() const { return this->ref_.empty(); } + + /// Copy into buf (null terminated, truncated to fit), returns the length copied. + size_t write_to(char *buf, size_t buf_size) const { + if (buf_size == 0) + return 0; + size_t len = std::min(this->ref_.size(), buf_size - 1); +#ifdef USE_ESP8266 + memcpy_P(buf, this->ref_.c_str(), len); +#else + std::memcpy(buf, this->ref_.c_str(), len); +#endif + buf[len] = '\0'; + return len; + } + + bool equals(const StringRef &other) const { +#ifdef USE_ESP8266 + return other.size() == this->ref_.size() && memcmp_P(other.c_str(), this->ref_.c_str(), other.size()) == 0; +#else + return other == this->ref_; +#endif + } + +#ifndef USE_ESP8266 + /// The string is in RAM here, so it can be viewed directly. + constexpr const StringRef &ram_ref() const { return this->ref_; } +#endif + + // Remove before 2027.5.0 + [[deprecated( + "May point to flash; use progmem_ptr() or the owner's accessors. Removed in 2027.5.0")]] constexpr const char * + c_str() const { + return this->ref_.c_str(); + } + + private: + StringRef ref_; +}; + // String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef) // Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation. namespace internal { diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index a143a850326..5c29733f342 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -536,16 +536,19 @@ def create_field_type_info( f"track_presence on field '{field.name}' has no effect; it requires " "a non-repeated message field in a message that is decoded" ) - if get_field_opt(field, pb.progmem, False) and ( - field.label == FieldDescriptorProto.LABEL_REPEATED - or field.type != 12 - or not get_field_opt(field, pb.pointer_to_buffer, False) - or get_field_opt(field, pb.fixed_array_size) is not None - ): - raise ValueError( - f"progmem on field '{field.name}' requires a non-repeated bytes field " - "with pointer_to_buffer" + if get_field_opt(field, pb.progmem, False): + is_pointer_bytes = ( + field.type == FieldDescriptorProto.TYPE_BYTES + and get_field_opt(field, pb.pointer_to_buffer, False) + and get_field_opt(field, pb.fixed_array_size) is None ) + if field.label == FieldDescriptorProto.LABEL_REPEATED or not ( + field.type == FieldDescriptorProto.TYPE_STRING or is_pointer_bytes + ): + raise ValueError( + f"progmem on field '{field.name}' requires a non-repeated string field, " + "or a bytes field with pointer_to_buffer" + ) if field.label == FieldDescriptorProto.LABEL_REPEATED: # Check if this is a packed_buffer field (zero-copy packed repeated) if get_field_opt(field, pb.packed_buffer, False): @@ -1250,8 +1253,15 @@ class PointerToStringBufferType(PointerToBufferTypeBase): one zero fill. Any encode path that copies unconditionally must check this.""" return not self._needs_decode and not self.force + @property + def progmem(self) -> bool: + """Whether the string is in flash, so encode and dump copy it with progmem_memcpy.""" + return get_field_opt(self._field, pb.progmem, False) + @property def public_content(self) -> list[str]: + if self.progmem: + return [f"ProgmemStringRef {self.field_name}{{}};"] if self._starts_null: return [ f"StringRef {self.field_name}{{nullptr, 0}}; // null until set, encode only" @@ -1267,9 +1277,20 @@ class PointerToStringBufferType(PointerToBufferTypeBase): ) tag = self.calculate_tag() if tag < 128: - return _encode_call( - "encode_short_string_force", str(tag), f"this->{self.field_name}" + encode_fn = ( + "encode_short_progmem_string_force" + if self.progmem + else "encode_short_string_force" ) + return _encode_call(encode_fn, str(tag), f"this->{self.field_name}") + if self.progmem: + return _encode_call( + "encode_progmem_bytes", + str(self.number), + f"reinterpret_cast(this->{self.field_name}.progmem_ptr())", + f"this->{self.field_name}.size()", + force=self.force, + ) if result := self._encode_bytes_with_precomputed_tag( f"this->{self.field_name}.c_str()", f"this->{self.field_name}.size()", @@ -1297,6 +1318,8 @@ class PointerToStringBufferType(PointerToBufferTypeBase): @property def dump_content(self) -> str: + if self.progmem: + return f'dump_progmem_string_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});' return f'dump_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});' def get_size_calculation(self, name: str, force: bool = False) -> str: @@ -3368,6 +3391,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co progmem_memcpy(data_buf, data, len); dump_bytes_field(out, field_name, data_buf, len, indent); } + +// Helper for string fields in flash: copies the shown characters out with progmem_memcpy first +static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, + int indent = 2) { + char buf[DUMP_BYTES_MAX]; + dump_field(out, field_name, StringRef(buf, value.write_to(buf, sizeof(buf))), indent); +} #pragma GCC diagnostic pop """ diff --git a/tests/components/core/test_progmem_string_ref.cpp b/tests/components/core/test_progmem_string_ref.cpp new file mode 100644 index 00000000000..fe6dd0e24a2 --- /dev/null +++ b/tests/components/core/test_progmem_string_ref.cpp @@ -0,0 +1,49 @@ +#include + +#include "esphome/core/string_ref.h" + +#include +#include + +namespace esphome::core::testing { + +// ram_ref() must return a reference so callers can hand it out as `const StringRef &` +static_assert(std::is_same_v().ram_ref()), const StringRef &>); + +TEST(ProgmemStringRef, WriteToCopiesAndTerminates) { + ProgmemStringRef ref("Kitchen", 7); + char buf[16]; + EXPECT_EQ(ref.write_to(buf, sizeof(buf)), 7u); + EXPECT_STREQ(buf, "Kitchen"); +} + +TEST(ProgmemStringRef, WriteToTruncatesToFit) { + ProgmemStringRef ref("Kitchen", 7); + char buf[4]; + EXPECT_EQ(ref.write_to(buf, sizeof(buf)), 3u); + EXPECT_STREQ(buf, "Kit"); +} + +TEST(ProgmemStringRef, WriteToZeroSizeWritesNothing) { + ProgmemStringRef ref("Kitchen", 7); + char buf[1] = {'x'}; + EXPECT_EQ(ref.write_to(buf, 0), 0u); + EXPECT_EQ(buf[0], 'x'); +} + +TEST(ProgmemStringRef, EqualsComparesLengthAndContent) { + ProgmemStringRef ref("Kitchen", 7); + EXPECT_TRUE(ref.equals(StringRef("Kitchen"))); + EXPECT_FALSE(ref.equals(StringRef("Kitche"))); + EXPECT_FALSE(ref.equals(StringRef("Kitchens"))); + EXPECT_FALSE(ref.equals(StringRef("Bedroom"))); +} + +TEST(ProgmemStringRef, DefaultIsEmpty) { + ProgmemStringRef ref; + EXPECT_TRUE(ref.empty()); + EXPECT_EQ(ref.size(), 0u); + EXPECT_TRUE(ref.equals(StringRef(""))); +} + +} // namespace esphome::core::testing diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 5e29b06ef07..4927b620205 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -408,8 +408,39 @@ def test_progmem_rejected_in_a_decoded_message() -> None: def test_progmem_rejected_on_non_bytes_field() -> None: - """(progmem) only applies to bytes fields.""" - field = _field(9) + """(progmem) only applies to string and bytes fields.""" + field = _field(13) + field.options.Extensions[pb.progmem] = True + with pytest.raises(ValueError, match="progmem on field 'value'"): + create_field_type_info(field, needs_decode=False) + + +@pytest.mark.parametrize( + ("force", "max_len", "encode_fn"), + [ + (True, 120, "encode_short_progmem_string_force("), + (False, None, "encode_progmem_bytes("), + (True, None, "encode_progmem_bytes_force("), + ], +) +def test_progmem_string_field_copies_from_flash( + force: bool, max_len: int | None, encode_fn: str +) -> None: + """A (progmem) string field is a ProgmemStringRef that encodes and dumps through the progmem helpers.""" + field = _field(9, force=force) + field.options.Extensions[pb.progmem] = True + if max_len is not None: + field.options.Extensions[pb.max_data_length] = max_len + ti = create_field_type_info(field, needs_decode=False) + assert "ProgmemStringRef value{};" in ti.public_content + assert encode_fn in ti.encode_content + assert "encode_string" not in ti.encode_content + assert "dump_progmem_string_field(" in ti.dump_content + + +def test_progmem_rejected_on_repeated_string() -> None: + """(progmem) on a repeated string field fails instead of silently using memcpy.""" + field = _field(9, repeated=True) field.options.Extensions[pb.progmem] = True with pytest.raises(ValueError, match="progmem on field 'value'"): create_field_type_info(field, needs_decode=False) From 7b5a1f9de99764630576e5d7ab8fc68af1d2ec32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 20:10:41 -1000 Subject: [PATCH 02/12] [api] Encode action strings straight from flash The service name, description and argument strings are progmem fields, so list entities no longer copies them into a stack scratch buffer, and the ESP8266 per action string budget goes away. --- esphome/components/api/__init__.py | 33 ------------ esphome/components/api/api.proto | 10 ++-- esphome/components/api/api_pb2.cpp | 17 +++++-- esphome/components/api/api_pb2.h | 10 ++-- esphome/components/api/api_pb2_dump.cpp | 17 +++++-- esphome/components/api/list_entities.cpp | 3 +- esphome/components/api/user_services.cpp | 29 ++++------- esphome/components/api/user_services.h | 31 ++++-------- esphome/core/defines.h | 1 - .../api/test_action_metadata.py | 50 +++---------------- 10 files changed, 61 insertions(+), 140 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f538c0b5e50..961fce5b52d 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -251,9 +251,6 @@ def _validate_supports_response(value: Any) -> str: return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) -# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small -ESP8266_ACTION_STRINGS_MAX_TOTAL = 384 - VARIABLE_SCHEMA = cv.Schema( { cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), @@ -484,29 +481,6 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]: return strings -def _action_strings_size(strings: list[str | None]) -> int: - """Bytes needed to copy every string out of flash, each with its terminator.""" - return sum( - len(string.encode("utf-8")) + 1 for string in strings if string is not None - ) - - -def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: - if not CORE.is_esp8266: - return config - actions = config.get(CONF_ACTIONS, []) - has_metadata = _has_action_metadata(actions) - for conf in actions: - size = _action_strings_size(_action_strings(conf, has_metadata)) - if size > ESP8266_ACTION_STRINGS_MAX_TOTAL: - raise cv.Invalid( - f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, " - f"description and example text; ESP8266 allows at most " - f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action" - ) - return config - - def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: """An IPv6 host can never be parsed, so never dialed, without IPv6.""" if ( @@ -531,7 +505,6 @@ def _validate_wizard(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = cv.All( - _validate_esp8266_action_strings, _validate_outgoing_host_ipv6, _validate_wizard, ) @@ -604,7 +577,6 @@ async def to_code(config: ConfigType) -> None: if config[CONF_HOMEASSISTANT_STATES]: cg.add_define("USE_API_HOMEASSISTANT_STATES") - scratch_size = 0 if actions: # Metadata is compiled in for every action once any action declares it, because the # string table layout is fixed by the define rather than per action @@ -657,8 +629,6 @@ async def to_code(config: ConfigType) -> None: func_args.append((native, name)) strings = _action_strings(conf, has_metadata) table = _add_action_strings(index, strings, interned_strings) - if CORE.is_esp8266: - scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types templ = cg.TemplateArguments(supports_response, *service_template_args) # Key is hashed here because the name is not readable at runtime on ESP8266 @@ -685,9 +655,6 @@ async def to_code(config: ConfigType) -> None: cg.add(auto.add_actions([unregister_action])) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) - if CORE.is_esp8266 and has_user_actions: - # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action - cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None: await wizard.to_code(wizard_config) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 76b82810a4b..bddee6da94e 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1144,21 +1144,21 @@ enum SupportsResponseType { } message ListEntitiesServicesArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; - string name = 1; + string name = 1 [(progmem) = true]; ServiceArgType type = 2; - string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; - string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; + string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; + string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; } message ListEntitiesServicesResponse { option (id) = 41; option (source) = SOURCE_SERVER; option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; - string name = 1; + string name = 1 [(progmem) = true]; fixed32 key = 2 [(force) = true]; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; SupportsResponseType supports_response = 4; - string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; + string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; } message ExecuteServiceArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b59663aeb51..58ba4e117f8 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1324,13 +1324,17 @@ void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, + reinterpret_cast(msg.name.progmem_ptr()), msg.name.size()); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description); + pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, + reinterpret_cast(msg.description.progmem_ptr()), + msg.description.size()); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); + pos = ProtoEncode::encode_progmem_bytes( + pos PROTO_ENCODE_DEBUG_ARG, 4, reinterpret_cast(msg.example.progmem_ptr()), msg.example.size()); #endif return pos; } @@ -1350,14 +1354,17 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) { uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, + reinterpret_cast(msg.name.progmem_ptr()), msg.name.size()); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); for (auto &it : msg.args) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description); + pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 5, + reinterpret_cast(msg.description.progmem_ptr()), + msg.description.size()); #endif return pos; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 421c3fccb38..35c29f60001 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1576,13 +1576,13 @@ class GetTimeResponse final : public ProtoDecodableMessage { #ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { public: - StringRef name{nullptr, 0}; // null until set, encode only + ProgmemStringRef name{}; enums::ServiceArgType type{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{nullptr, 0}; // null until set, encode only + ProgmemStringRef description{}; #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef example{nullptr, 0}; // null until set, encode only + ProgmemStringRef example{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1603,12 +1603,12 @@ class ListEntitiesServicesResponse final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } #endif - StringRef name{nullptr, 0}; // null until set, encode only + ProgmemStringRef name{}; uint32_t key{0}; FixedVector args{}; enums::SupportsResponseType supports_response{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{nullptr, 0}; // null until set, encode only + ProgmemStringRef description{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3c6f5302b55..3317fe9882f 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -134,6 +134,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co progmem_memcpy(data_buf, data, len); dump_bytes_field(out, field_name, data_buf, len, indent); } + +// Helper for string fields in flash: copies the shown characters out with progmem_memcpy first +static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, + int indent = 2) { + char buf[DUMP_BYTES_MAX]; + dump_field(out, field_name, StringRef(buf, value.write_to(buf, sizeof(buf))), indent); +} #pragma GCC diagnostic pop template<> const char *proto_enum_to_string(enums::DisconnectReason value) { @@ -1612,19 +1619,19 @@ const char *GetTimeResponse::dump_to(DumpBuffer &out) const { #ifdef USE_API_USER_DEFINED_ACTIONS const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesArgument")); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("description"), this->description); + dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("example"), this->example); + dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example); #endif return out.c_str(); } const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesResponse")); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("key"), this->key); for (const auto &it : this->args) { out.append(4, ' ').append_p(ESPHOME_PSTR("args")).append(": "); @@ -1633,7 +1640,7 @@ const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { } dump_field(out, ESPHOME_PSTR("supports_response"), static_cast(this->supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("description"), this->description); + dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description); #endif return out.c_str(); } diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index 507b098fb48..57ff616ca7c 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -99,8 +99,7 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3; bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { - UserActionScratch scratch; - auto resp = service->encode_list_service_response(scratch); + auto resp = service->encode_list_service_response(); if (!this->client_->send_message(resp)) return false; // at_ is this service's index diff --git a/esphome/components/api/user_services.cpp b/esphome/components/api/user_services.cpp index fad3cde29b9..a9836170640 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -1,47 +1,36 @@ #include "user_services.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::api { -StringRef UserServiceStatic::str_(size_t idx, std::span &scratch) const { +ProgmemStringRef UserServiceStatic::str_(size_t idx) const { const char *s = progmem_read_ptr(&this->strings_[idx]); if (s == nullptr) return {}; -#ifdef USE_ESP8266 - // Codegen sizes the scratch buffer for the largest service; the bound only guards other callers - if (scratch.empty()) - return {}; - size_t len = strnlen_P(s, scratch.size() - 1); - progmem_memcpy(scratch.data(), s, len); - scratch[len] = '\0'; - StringRef ref(scratch.data(), len); - scratch = scratch.subspan(len + 1); - return ref; -#else - return StringRef(s); -#endif + return ProgmemStringRef(s, ESPHOME_strlen_P(s)); } ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_( - std::span arg_types, std::span scratch) const { + std::span arg_types) const { ListEntitiesServicesResponse msg; - msg.name = this->str_(0, scratch); + msg.name = this->str_(0); msg.key = this->key_; msg.supports_response = this->supports_response_; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - msg.description = this->str_(1, scratch); + msg.description = this->str_(1); #endif msg.args.init(arg_types.size()); for (size_t i = 0; i < arg_types.size(); i++) { size_t base = USER_ACTION_HEADER_STRINGS + i * USER_ACTION_ARG_STRINGS; auto &arg = msg.args.emplace_back(); arg.type = arg_types[i]; - arg.name = this->str_(base, scratch); + arg.name = this->str_(base); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - arg.description = this->str_(base + 1, scratch); - arg.example = this->str_(base + 2, scratch); + arg.description = this->str_(base + 1); + arg.example = this->str_(base + 2); #endif } return msg; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 3b17bdb7bcd..057e04dd7a7 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -20,9 +20,8 @@ class APIServer; class UserServiceDescriptor { public: - /// Build the list-entities message. On ESP8266 the strings live in PROGMEM and are copied into - /// `scratch`, so the returned message is only valid while `scratch` is; other platforms ignore it. - virtual ListEntitiesServicesResponse encode_list_service_response(std::span scratch) = 0; + /// Build the list-entities message; its strings may point into flash (PROGMEM). + virtual ListEntitiesServicesResponse encode_list_service_response() = 0; virtual bool execute_service(const ExecuteServiceRequest &req) = 0; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -37,13 +36,6 @@ template T get_execute_arg_value(const ExecuteServiceArgument &arg); template enums::ServiceArgType to_service_arg_type(); -// Scratch buffer list-entities hands to encode_list_service_response(); only ESP8266 copies into it -#ifdef USE_ESP8266 -using UserActionScratch = std::array; -#else -using UserActionScratch = std::array; -#endif - // Non-template base for YAML-defined services so the list-entities encoder is compiled once. // All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in // __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then @@ -63,12 +55,9 @@ class UserServiceStatic : public UserServiceDescriptor { : strings_(strings), key_(key), supports_response_(supports_response) {} protected: - ListEntitiesServicesResponse encode_list_service_response_(std::span arg_types, - std::span scratch) const; - /// Reference table entry `idx`; nullptr gives an empty StringRef. - /// On ESP8266 the bytes are copied out of PROGMEM into `scratch` with a terminator, and the span - /// is advanced past the copy. - StringRef str_(size_t idx, std::span &scratch) const; + ListEntitiesServicesResponse encode_list_service_response_(std::span arg_types) const; + /// Table entry `idx` as it sits in flash; nullptr gives an empty string. + ProgmemStringRef str_(size_t idx) const; const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr() uint32_t key_; @@ -79,9 +68,9 @@ template class UserServiceBase : public UserServiceStatic { public: using UserServiceStatic::UserServiceStatic; - ListEntitiesServicesResponse encode_list_service_response(std::span scratch) override { + ListEntitiesServicesResponse encode_list_service_response() override { std::array arg_types = {to_service_arg_type()...}; - return this->encode_list_service_response_(arg_types, scratch); + return this->encode_list_service_response_(arg_types); } bool execute_service(const ExecuteServiceRequest &req) override { @@ -125,9 +114,9 @@ template class UserServiceDynamic : public UserServiceDescriptor this->key_ = fnv1_hash(this->name_.c_str()); } - ListEntitiesServicesResponse encode_list_service_response(std::span /*scratch*/) override { + ListEntitiesServicesResponse encode_list_service_response() override { ListEntitiesServicesResponse msg; - msg.name = StringRef(this->name_); + msg.name = ProgmemStringRef(StringRef(this->name_)); msg.key = this->key_; msg.supports_response = enums::SUPPORTS_RESPONSE_NONE; // Dynamic services don't support responses yet std::array arg_types = {to_service_arg_type()...}; @@ -135,7 +124,7 @@ template class UserServiceDynamic : public UserServiceDescriptor for (size_t i = 0; i < sizeof...(Ts); i++) { auto &arg = msg.args.emplace_back(); arg.type = arg_types[i]; - arg.name = StringRef(this->arg_names_[i]); + arg.name = ProgmemStringRef(StringRef(this->arg_names_[i])); } return msg; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 12f61467d65..e74320540ed 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -283,7 +283,6 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 -#define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64 #define API_WIZARD_DATA_SIZE 1 #define API_WIZARD_INPUT_COUNT 1 #define MAX_API_CONNECTIONS 6 diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index adbfdf306eb..8bfdb7a7a7d 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -7,18 +7,14 @@ import pytest from esphome.components.api import ( _action_strings, - _action_strings_size, _has_action_metadata, - _validate_esp8266_action_strings, validate_variable, ) from esphome.config_validation import Invalid -from esphome.const import PlatformFramework from esphome.core import CORE from esphome.cpp_generator import safe_exp from esphome.helpers import fnv1_hash from tests.component_tests.helpers import get_define_value -from tests.component_tests.types import SetCoreConfigCallable CONFIG = "tests/component_tests/api/test_action_metadata.yaml" CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml" @@ -54,18 +50,16 @@ def test_metadata_is_emitted_as_progmem_table( ) assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines} - assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None -def test_esp8266_sizes_scratch_buffer_for_largest_action( +def test_esp8266_encodes_action_strings_from_flash( generate_main: Callable[[str | Path], str], ) -> None: - """ESP8266 gets a scratch buffer define equal to the byte total of the largest action.""" - generate_main(CONFIG_ESP8266) + """ESP8266 uses the same flash string table, with no scratch buffer to size.""" + main_cpp = generate_main(CONFIG_ESP8266) - # play_buzzer: name, description, two variable names, one description, one example, - # each with a terminator - assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") == "117" + assert "api_action0_strings[] PROGMEM" in main_cpp + assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None def test_shorthand_variables_emit_no_metadata( @@ -101,36 +95,8 @@ def test_variable_rejects_invalid(value: object) -> None: validate_variable(value) -def _oversized_action_config() -> dict: - return { - "actions": [ - { - "action": "big", - "description": "x" * 300, - "variables": {"a": {"type": "string", "example": "y" * 300}}, - } - ] - } - - -def test_esp8266_rejects_actions_over_string_budget( - set_core_config: SetCoreConfigCallable, -) -> None: - set_core_config(PlatformFramework.ESP8266_ARDUINO) - with pytest.raises(Invalid, match="ESP8266 allows at most 384 bytes"): - _validate_esp8266_action_strings(_oversized_action_config()) - - -def test_other_platforms_have_no_string_budget( - set_core_config: SetCoreConfigCallable, -) -> None: - set_core_config(PlatformFramework.ESP32_IDF) - config = _oversized_action_config() - assert _validate_esp8266_action_strings(config) is config - - -def test_empty_metadata_is_unset_and_not_counted() -> None: - """An empty description or example emits nullptr and takes no scratch space.""" +def test_empty_metadata_is_unset() -> None: + """An empty description or example emits nullptr.""" conf = { "action": "a", "description": "", @@ -138,8 +104,6 @@ def test_empty_metadata_is_unset_and_not_counted() -> None: } strings = _action_strings(conf, has_metadata=True) assert strings == ["a", None, "b", None, "ex"] - # Every emitted string counts its terminator: "a" + "b" + "ex" - assert _action_strings_size(strings) == 2 + 2 + 3 def test_empty_metadata_does_not_enable_the_define() -> None: From 1fa844a5d2443b27911cf4c4a1c527ec867ff795 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 20:17:55 -1000 Subject: [PATCH 03/12] [api] Add encode_progmem_string for progmem string fields Generated code passes the ProgmemStringRef instead of spelling out the cast. --- esphome/components/api/api_pb2.cpp | 17 +++++------------ esphome/components/api/proto.h | 11 +++++++++++ script/api_protobuf/api_protobuf.py | 5 ++--- .../api/test_api_protobuf_generator.py | 4 ++-- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 58ba4e117f8..d3d17815894 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1324,17 +1324,13 @@ void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, - reinterpret_cast(msg.name.progmem_ptr()), msg.name.size()); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, - reinterpret_cast(msg.description.progmem_ptr()), - msg.description.size()); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_progmem_bytes( - pos PROTO_ENCODE_DEBUG_ARG, 4, reinterpret_cast(msg.example.progmem_ptr()), msg.example.size()); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); #endif return pos; } @@ -1354,17 +1350,14 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) { uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, - reinterpret_cast(msg.name.progmem_ptr()), msg.name.size()); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); for (auto &it : msg.args) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 5, - reinterpret_cast(msg.description.progmem_ptr()), - msg.description.size()); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description); #endif return pos; } diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 04ce317caca..55021186a62 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -498,6 +498,17 @@ class ProtoEncode { progmem_memcpy(pos, data, len); return pos + len; } + /// encode_progmem_bytes for a string field that may be in flash. + [[nodiscard]] static inline uint8_t *encode_progmem_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const ProgmemStringRef &ref) { + return encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, field_id, + reinterpret_cast(ref.progmem_ptr()), ref.size()); + } + [[nodiscard]] static inline uint8_t *encode_progmem_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const ProgmemStringRef &ref) { + return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, + reinterpret_cast(ref.progmem_ptr()), ref.size()); + } [[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) { pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 5c29733f342..aaa1663dbba 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1285,10 +1285,9 @@ class PointerToStringBufferType(PointerToBufferTypeBase): return _encode_call(encode_fn, str(tag), f"this->{self.field_name}") if self.progmem: return _encode_call( - "encode_progmem_bytes", + "encode_progmem_string", str(self.number), - f"reinterpret_cast(this->{self.field_name}.progmem_ptr())", - f"this->{self.field_name}.size()", + f"this->{self.field_name}", force=self.force, ) if result := self._encode_bytes_with_precomputed_tag( diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 4927b620205..9d8eab69b90 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -419,8 +419,8 @@ def test_progmem_rejected_on_non_bytes_field() -> None: ("force", "max_len", "encode_fn"), [ (True, 120, "encode_short_progmem_string_force("), - (False, None, "encode_progmem_bytes("), - (True, None, "encode_progmem_bytes_force("), + (False, None, "encode_progmem_string("), + (True, None, "encode_progmem_string_force("), ], ) def test_progmem_string_field_copies_from_flash( From 8378c935d6492b01b5926b23cdce72c424db1873 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 20:17:56 -1000 Subject: [PATCH 04/12] Drop the scratch define assertion from the action metadata test --- tests/component_tests/api/test_action_metadata.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index 8bfdb7a7a7d..8e7dea4b19a 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -14,7 +14,6 @@ from esphome.config_validation import Invalid from esphome.core import CORE from esphome.cpp_generator import safe_exp from esphome.helpers import fnv1_hash -from tests.component_tests.helpers import get_define_value CONFIG = "tests/component_tests/api/test_action_metadata.yaml" CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml" @@ -59,7 +58,6 @@ def test_esp8266_encodes_action_strings_from_flash( main_cpp = generate_main(CONFIG_ESP8266) assert "api_action0_strings[] PROGMEM" in main_cpp - assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None def test_shorthand_variables_emit_no_metadata( From 40460f3e0117b03bbf3874376e9d85633a4a5ac0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 21:34:31 -1000 Subject: [PATCH 05/12] Dump progmem strings straight into the dump buffer, assert the scratch define is gone --- esphome/components/api/api_pb2_dump.cpp | 8 +++++--- script/api_protobuf/api_protobuf.py | 8 +++++--- tests/component_tests/api/test_action_metadata.py | 2 ++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3317fe9882f..f74aa72741e 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -135,11 +135,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co dump_bytes_field(out, field_name, data_buf, len, indent); } -// Helper for string fields in flash: copies the shown characters out with progmem_memcpy first +// Helper for string fields in flash: copies straight into the dump buffer, which bounds the length static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, int indent = 2) { - char buf[DUMP_BYTES_MAX]; - dump_field(out, field_name, StringRef(buf, value.write_to(buf, sizeof(buf))), indent); + append_field_prefix(out, field_name, indent); + out.append("'"); + out.set_pos(out.pos() + value.write_to(out.data() + out.pos(), DumpBuffer::CAPACITY - out.pos())); + out.append("'\n"); } #pragma GCC diagnostic pop diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index aaa1663dbba..c0eb46c2777 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3391,11 +3391,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co dump_bytes_field(out, field_name, data_buf, len, indent); } -// Helper for string fields in flash: copies the shown characters out with progmem_memcpy first +// Helper for string fields in flash: copies straight into the dump buffer, which bounds the length static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, int indent = 2) { - char buf[DUMP_BYTES_MAX]; - dump_field(out, field_name, StringRef(buf, value.write_to(buf, sizeof(buf))), indent); + append_field_prefix(out, field_name, indent); + out.append("'"); + out.set_pos(out.pos() + value.write_to(out.data() + out.pos(), DumpBuffer::CAPACITY - out.pos())); + out.append("'\\n"); } #pragma GCC diagnostic pop diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index 8e7dea4b19a..8bfdb7a7a7d 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -14,6 +14,7 @@ from esphome.config_validation import Invalid from esphome.core import CORE from esphome.cpp_generator import safe_exp from esphome.helpers import fnv1_hash +from tests.component_tests.helpers import get_define_value CONFIG = "tests/component_tests/api/test_action_metadata.yaml" CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml" @@ -58,6 +59,7 @@ def test_esp8266_encodes_action_strings_from_flash( main_cpp = generate_main(CONFIG_ESP8266) assert "api_action0_strings[] PROGMEM" in main_cpp + assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None def test_shorthand_variables_emit_no_metadata( From 47655129e12aa1d2232a14153ce9654bcae828f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 21:44:06 -1000 Subject: [PATCH 06/12] Share the outlined PROGMEM append for the progmem string dump --- esphome/components/api/api_pb2_dump.cpp | 11 +++++------ esphome/components/api/proto.h | 11 +++++++++++ script/api_protobuf/api_protobuf.py | 11 +++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index f74aa72741e..a50c12bd552 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -12,8 +12,9 @@ namespace esphome::api { #ifdef USE_ESP8266 // Out-of-line to avoid inlining strlen_P/memcpy_P at every call site -void DumpBuffer::append_p_esp8266(const char *str) { - size_t len = strlen_P(str); +void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); } + +void DumpBuffer::append_p_esp8266(const char *str, size_t len) { size_t space = CAPACITY - 1 - pos_; if (len > space) len = space; @@ -135,13 +136,11 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co dump_bytes_field(out, field_name, data_buf, len, indent); } -// Helper for string fields in flash: copies straight into the dump buffer, which bounds the length +// Helper for string fields in flash static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, int indent = 2) { append_field_prefix(out, field_name, indent); - out.append("'"); - out.set_pos(out.pos() + value.write_to(out.data() + out.pos(), DumpBuffer::CAPACITY - out.pos())); - out.append("'\n"); + out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\n"); } #pragma GCC diagnostic pop diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 55021186a62..245032f83dc 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -708,9 +708,20 @@ class DumpBuffer { return *this; } + /// Append len bytes of a PROGMEM string + DumpBuffer &append_p(const char *str, size_t len) { +#ifdef USE_ESP8266 + append_p_esp8266(str, len); +#else + append_impl_(str, len); +#endif + return *this; + } + #ifdef USE_ESP8266 /// Out-of-line ESP8266 PROGMEM append to avoid inlining strlen_P/memcpy_P at every call site void append_p_esp8266(const char *str); + void append_p_esp8266(const char *str, size_t len); #endif const char *c_str() const { return buf_; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index c0eb46c2777..4760890aab6 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3268,8 +3268,9 @@ namespace esphome::api { #ifdef USE_ESP8266 // Out-of-line to avoid inlining strlen_P/memcpy_P at every call site -void DumpBuffer::append_p_esp8266(const char *str) { - size_t len = strlen_P(str); +void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); } + +void DumpBuffer::append_p_esp8266(const char *str, size_t len) { size_t space = CAPACITY - 1 - pos_; if (len > space) len = space; @@ -3391,13 +3392,11 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co dump_bytes_field(out, field_name, data_buf, len, indent); } -// Helper for string fields in flash: copies straight into the dump buffer, which bounds the length +// Helper for string fields in flash static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, int indent = 2) { append_field_prefix(out, field_name, indent); - out.append("'"); - out.set_pos(out.pos() + value.write_to(out.data() + out.pos(), DumpBuffer::CAPACITY - out.pos())); - out.append("'\\n"); + out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\\n"); } #pragma GCC diagnostic pop From 4497dad959b2464197ce069beea0afd7ee38af8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 22:07:35 -1000 Subject: [PATCH 07/12] Keep the inline short string encode off ESP8266 --- esphome/components/api/proto.h | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 245032f83dc..189cee9e0eb 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -410,7 +410,7 @@ class ProtoEncode { [[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const StringRef &ref) { #ifdef USE_ESP8266 - // memcpy_P reads RAM too, so RAM strings share the outlined copy + // memcpy_P reads RAM too, so RAM strings share the progmem copy return encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, tag, ProgmemStringRef(ref)); #else #ifdef ESPHOME_DEBUG_API @@ -424,7 +424,8 @@ class ProtoEncode { #endif } /// encode_short_string_force for a string that may be in flash (PROGMEM). - [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *encode_short_progmem_string_force( +#ifdef USE_ESP8266 + [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) { #ifdef ESPHOME_DEBUG_API assert(ref.size() < 128 && "encode_short_progmem_string_force: string exceeds max_data_length < 128"); @@ -435,6 +436,12 @@ class ProtoEncode { progmem_memcpy(pos + 2, ref.progmem_ptr(), ref.size()); return pos + 2 + ref.size(); } +#else + [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) { + return encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, tag, ref.ram_ref()); + } +#endif /// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field. [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32( uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) { From dd495be86146c0e04b92169ed63ea99378e40851 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 16:28:37 -1000 Subject: [PATCH 08/12] Fold dev's ProgmemStringRef struct into the class --- esphome/core/string_ref.h | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 4e388a4d091..d8c68d7e7fa 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -190,24 +190,6 @@ inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { r inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); } -/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen. -struct ProgmemStringRef { - PGM_P str; - size_t len; -}; - -inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) { - return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0); -} - -inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } - -inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) { - // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes - return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0); -} - -inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } #endif // USE_ESP8266 inline bool operator<(const StringRef &lhs, const StringRef &rhs) { @@ -271,7 +253,9 @@ class ProgmemStringRef { bool equals(const StringRef &other) const { #ifdef USE_ESP8266 - return other.size() == this->ref_.size() && memcmp_P(other.c_str(), this->ref_.c_str(), other.size()) == 0; + // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes + return other.size() == this->ref_.size() && + (other.empty() || memcmp_P(other.c_str(), this->ref_.c_str(), other.size()) == 0); #else return other == this->ref_; #endif @@ -293,6 +277,13 @@ class ProgmemStringRef { StringRef ref_; }; +#ifdef USE_ESP8266 +inline bool operator==(const StringRef &lhs, const ProgmemStringRef &rhs) { return rhs.equals(lhs); } +inline bool operator!=(const StringRef &lhs, const ProgmemStringRef &rhs) { return !rhs.equals(lhs); } +inline bool operator==(const std::string &lhs, const ProgmemStringRef &rhs) { return rhs.equals(StringRef(lhs)); } +inline bool operator!=(const std::string &lhs, const ProgmemStringRef &rhs) { return !rhs.equals(StringRef(lhs)); } +#endif + // String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef) // Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation. namespace internal { From 9b873e21796e08ae7a77efe266a9b13ea1dc5aa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 16:47:08 -1000 Subject: [PATCH 09/12] Keep the RAM short string encode on memcpy and drop unused ProgmemStringRef helpers --- esphome/components/api/proto.h | 12 --------- esphome/core/string_ref.h | 21 +-------------- script/api_protobuf/api_protobuf.py | 15 ++++------- .../api/test_action_metadata.py | 4 +-- .../core/test_progmem_string_ref.cpp | 27 ------------------- 5 files changed, 7 insertions(+), 72 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 189cee9e0eb..e01deccbc21 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -409,10 +409,6 @@ class ProtoEncode { /// Tag must be a single-byte varint (< 128). Always encodes (no zero check). [[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const StringRef &ref) { -#ifdef USE_ESP8266 - // memcpy_P reads RAM too, so RAM strings share the progmem copy - return encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, tag, ProgmemStringRef(ref)); -#else #ifdef ESPHOME_DEBUG_API assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128"); #endif @@ -421,10 +417,8 @@ class ProtoEncode { pos[1] = static_cast(ref.size()); std::memcpy(pos + 2, ref.c_str(), ref.size()); return pos + 2 + ref.size(); -#endif } /// encode_short_string_force for a string that may be in flash (PROGMEM). -#ifdef USE_ESP8266 [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) { #ifdef ESPHOME_DEBUG_API @@ -436,12 +430,6 @@ class ProtoEncode { progmem_memcpy(pos + 2, ref.progmem_ptr(), ref.size()); return pos + 2 + ref.size(); } -#else - [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( - uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) { - return encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, tag, ref.ram_ref()); - } -#endif /// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field. [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32( uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) { diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index d8c68d7e7fa..b5b4a0bdd4e 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -226,7 +226,7 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { } /// A string that may live in flash (on ESP8266): pointer and length, with no byte-reading operations. -/// progmem_ptr() is safe as a "%s" log argument; read the contents with write_to() or equals(). +/// progmem_ptr() is safe as a "%s" log argument; compare the contents with equals(). class ProgmemStringRef { public: constexpr ProgmemStringRef() = default; @@ -237,20 +237,6 @@ class ProgmemStringRef { constexpr size_t size() const { return this->ref_.size(); } constexpr bool empty() const { return this->ref_.empty(); } - /// Copy into buf (null terminated, truncated to fit), returns the length copied. - size_t write_to(char *buf, size_t buf_size) const { - if (buf_size == 0) - return 0; - size_t len = std::min(this->ref_.size(), buf_size - 1); -#ifdef USE_ESP8266 - memcpy_P(buf, this->ref_.c_str(), len); -#else - std::memcpy(buf, this->ref_.c_str(), len); -#endif - buf[len] = '\0'; - return len; - } - bool equals(const StringRef &other) const { #ifdef USE_ESP8266 // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes @@ -261,11 +247,6 @@ class ProgmemStringRef { #endif } -#ifndef USE_ESP8266 - /// The string is in RAM here, so it can be viewed directly. - constexpr const StringRef &ram_ref() const { return this->ref_; } -#endif - // Remove before 2027.5.0 [[deprecated( "May point to flash; use progmem_ptr() or the owner's accessors. Removed in 2027.5.0")]] constexpr const char * diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 4760890aab6..2f601f5c947 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1158,6 +1158,11 @@ class PointerToBufferTypeBase(TypeInfo): """Get the wire type for this field.""" return WireType.LENGTH_DELIMITED # Uses wire type 2 + @property + def progmem(self) -> bool: + """Whether the data is in flash, so encode and dump copy it with progmem_memcpy.""" + return get_field_opt(self._field, pb.progmem, False) + def get_estimated_size(self) -> int: # field ID + length varint + typical data (assume small for pointer fields) return self.calculate_field_id_size() + 2 + 16 @@ -1171,11 +1176,6 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): reference_type = "const uint8_t*" const_reference_type = "const uint8_t*" - @property - def progmem(self) -> bool: - """Whether the data is in flash, so encode and dump copy it with progmem_memcpy.""" - return get_field_opt(self._field, pb.progmem, False) - @property def public_content(self) -> list[str]: # Use uint16_t for length - max packet size is well below 65535 @@ -1253,11 +1253,6 @@ class PointerToStringBufferType(PointerToBufferTypeBase): one zero fill. Any encode path that copies unconditionally must check this.""" return not self._needs_decode and not self.force - @property - def progmem(self) -> bool: - """Whether the string is in flash, so encode and dump copy it with progmem_memcpy.""" - return get_field_opt(self._field, pb.progmem, False) - @property def public_content(self) -> list[str]: if self.progmem: diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index 46896d26f60..2b7662b6655 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -15,7 +15,6 @@ from esphome.config_validation import Invalid from esphome.core import CORE from esphome.cpp_generator import safe_exp from esphome.helpers import fnv1_hash -from tests.component_tests.helpers import get_define_value CONFIG = "tests/component_tests/api/test_action_metadata.yaml" CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml" @@ -67,11 +66,10 @@ def test_esp8266_strings_are_shared_progmem_arrays( def test_esp8266_encodes_action_strings_from_flash( generate_main: Callable[[str | Path], str], ) -> None: - """ESP8266 uses the same flash string table, with no scratch buffer to size.""" + """ESP8266 uses the same flash string table.""" main_cpp = generate_main(CONFIG_ESP8266) assert "api_action0_strings[] PROGMEM" in main_cpp - assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None def test_shorthand_variables_emit_no_metadata( diff --git a/tests/components/core/test_progmem_string_ref.cpp b/tests/components/core/test_progmem_string_ref.cpp index fe6dd0e24a2..ec6e77b1e49 100644 --- a/tests/components/core/test_progmem_string_ref.cpp +++ b/tests/components/core/test_progmem_string_ref.cpp @@ -2,35 +2,8 @@ #include "esphome/core/string_ref.h" -#include -#include - namespace esphome::core::testing { -// ram_ref() must return a reference so callers can hand it out as `const StringRef &` -static_assert(std::is_same_v().ram_ref()), const StringRef &>); - -TEST(ProgmemStringRef, WriteToCopiesAndTerminates) { - ProgmemStringRef ref("Kitchen", 7); - char buf[16]; - EXPECT_EQ(ref.write_to(buf, sizeof(buf)), 7u); - EXPECT_STREQ(buf, "Kitchen"); -} - -TEST(ProgmemStringRef, WriteToTruncatesToFit) { - ProgmemStringRef ref("Kitchen", 7); - char buf[4]; - EXPECT_EQ(ref.write_to(buf, sizeof(buf)), 3u); - EXPECT_STREQ(buf, "Kit"); -} - -TEST(ProgmemStringRef, WriteToZeroSizeWritesNothing) { - ProgmemStringRef ref("Kitchen", 7); - char buf[1] = {'x'}; - EXPECT_EQ(ref.write_to(buf, 0), 0u); - EXPECT_EQ(buf[0], 'x'); -} - TEST(ProgmemStringRef, EqualsComparesLengthAndContent) { ProgmemStringRef ref("Kitchen", 7); EXPECT_TRUE(ref.equals(StringRef("Kitchen"))); From 65b32731d4250ad9f8f6492ee580c5078d126c5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 16:53:23 -1000 Subject: [PATCH 10/12] Keep the ESP8266 short string encode on the shared progmem copy --- esphome/components/api/proto.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index e01deccbc21..f8e9dae09a6 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -409,6 +409,10 @@ class ProtoEncode { /// Tag must be a single-byte varint (< 128). Always encodes (no zero check). [[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const StringRef &ref) { +#ifdef USE_ESP8266 + // memcpy_P reads RAM too, and sharing the progmem copy is smaller than an inline memcpy + return encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, tag, ProgmemStringRef(ref)); +#else #ifdef ESPHOME_DEBUG_API assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128"); #endif @@ -417,6 +421,7 @@ class ProtoEncode { pos[1] = static_cast(ref.size()); std::memcpy(pos + 2, ref.c_str(), ref.size()); return pos + 2 + ref.size(); +#endif } /// encode_short_string_force for a string that may be in flash (PROGMEM). [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( From da561e08d6822f97efdefd9c4c9411823efb6616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 15:59:41 -1000 Subject: [PATCH 11/12] Keep the RAM short string encode on memcpy; the ESP8266 shared copy moves to #20423 --- esphome/components/api/proto.h | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index f8e9dae09a6..e01deccbc21 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -409,10 +409,6 @@ class ProtoEncode { /// Tag must be a single-byte varint (< 128). Always encodes (no zero check). [[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const StringRef &ref) { -#ifdef USE_ESP8266 - // memcpy_P reads RAM too, and sharing the progmem copy is smaller than an inline memcpy - return encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, tag, ProgmemStringRef(ref)); -#else #ifdef ESPHOME_DEBUG_API assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128"); #endif @@ -421,7 +417,6 @@ class ProtoEncode { pos[1] = static_cast(ref.size()); std::memcpy(pos + 2, ref.c_str(), ref.size()); return pos + 2 + ref.size(); -#endif } /// encode_short_string_force for a string that may be in flash (PROGMEM). [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( From ef5b0861f175679e08d31d1d5738d7cd4724a31e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 15:59:41 -1000 Subject: [PATCH 12/12] Encode Home Assistant action names and data from flash --- esphome/components/api/api.proto | 6 ++-- esphome/components/api/api_pb2.cpp | 6 ++-- esphome/components/api/api_pb2.h | 6 ++-- esphome/components/api/api_pb2_dump.cpp | 6 ++-- esphome/components/api/api_server.cpp | 2 +- esphome/components/api/custom_api_device.h | 16 +++++----- .../components/api/homeassistant_service.h | 32 +++---------------- .../button/homeassistant_button.cpp | 22 ++++++------- .../number/homeassistant_number.cpp | 29 ++++++++--------- .../select/homeassistant_select.cpp | 28 ++++++++-------- .../switch/homeassistant_switch.cpp | 18 +++++------ .../homeassistant/text/homeassistant_text.cpp | 28 ++++++++-------- esphome/core/string_ref.h | 2 ++ .../core/test_progmem_string_ref.cpp | 8 +++++ 14 files changed, 96 insertions(+), 113 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 61d41ad3275..1842a80222f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1026,8 +1026,8 @@ message SubscribeHomeassistantServicesRequest { } message HomeassistantServiceMap { - string key = 1; - string value = 2; + string key = 1 [(progmem) = true]; + string value = 2 [(progmem) = true]; } message HomeassistantActionRequest { @@ -1036,7 +1036,7 @@ message HomeassistantActionRequest { option (no_delay) = true; option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES"; - string service = 1; + string service = 1 [(progmem) = true]; repeated HomeassistantServiceMap data = 2 [(fixed_vector) = true]; repeated HomeassistantServiceMap data_template = 3 [(fixed_vector) = true]; repeated HomeassistantServiceMap variables = 4 [(fixed_vector) = true]; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index dc2c9bdb95f..2aa3c9b2d59 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1140,8 +1140,8 @@ uint32_t NoiseEncryptionSetKeyResponse::calc_size_msg(const void *self) { #ifdef USE_API_HOMEASSISTANT_SERVICES uint8_t *HomeassistantServiceMap::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); return pos; } uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { @@ -1153,7 +1153,7 @@ uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { } uint8_t *HomeassistantActionRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); for (auto &it : msg.data) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 701c76ab92f..76020454e10 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1388,8 +1388,8 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_SERVICES class HomeassistantServiceMap final : public ProtoMessage { public: - StringRef key{nullptr, 0}; // null until set, encode only - StringRef value{nullptr, 0}; // null until set, encode only + ProgmemStringRef key{}; + ProgmemStringRef value{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -1409,7 +1409,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); } #endif - StringRef service{nullptr, 0}; // null until set, encode only + ProgmemStringRef service{}; FixedVector data{}; FixedVector data_template{}; FixedVector variables{}; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index a50c12bd552..5e15cd7db46 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1519,13 +1519,13 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const { #ifdef USE_API_HOMEASSISTANT_SERVICES const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantServiceMap")); - dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("value"), this->value); + dump_progmem_string_field(out, ESPHOME_PSTR("key"), this->key); + dump_progmem_string_field(out, ESPHOME_PSTR("value"), this->value); return out.c_str(); } const char *HomeassistantActionRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantActionRequest")); - dump_field(out, ESPHOME_PSTR("service"), this->service); + dump_progmem_string_field(out, ESPHOME_PSTR("service"), this->service); for (const auto &it : this->data) { out.append(4, ' ').append_p(ESPHOME_PSTR("data")).append(": "); it.dump_to(out); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index f63039a9714..a8d14256dcb 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -547,7 +547,7 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call // arrive before the subscription and are lost - warn instead of failing silently. ESP_LOGW(TAG, "Home Assistant %s '%.*s' dropped; %s", call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"), - static_cast(call.service.size()), call.service.empty() ? "" : call.service.c_str(), + static_cast(call.service.size()), call.service.empty() ? "" : call.service.progmem_ptr(), this->is_connected() ? LOG_STR_LITERAL("client has not subscribed to actions (yet)") : LOG_STR_LITERAL("no client connected")); } diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index c2b0f14bcc5..208c956eb17 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -247,7 +247,7 @@ class CustomAPIDevice { */ void call_homeassistant_service(const std::string &service_name) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); global_api_server->send_homeassistant_action(resp); } @@ -267,12 +267,12 @@ class CustomAPIDevice { */ void call_homeassistant_service(const std::string &service_name, const std::map &data) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); resp.data.init(data.size()); for (auto &it : data) { auto &kv = resp.data.emplace_back(); - kv.key = StringRef(it.first); - kv.value = StringRef(it.second); // data map lives until send completes + kv.key = ProgmemStringRef(StringRef(it.first)); + kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes } global_api_server->send_homeassistant_action(resp); } @@ -289,7 +289,7 @@ class CustomAPIDevice { */ void fire_homeassistant_event(const std::string &event_name) { HomeassistantActionRequest resp; - resp.service = StringRef(event_name); + resp.service = ProgmemStringRef(StringRef(event_name)); resp.is_event = true; global_api_server->send_homeassistant_action(resp); } @@ -309,13 +309,13 @@ class CustomAPIDevice { */ void fire_homeassistant_event(const std::string &service_name, const std::map &data) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); resp.is_event = true; resp.data.init(data.size()); for (auto &it : data) { auto &kv = resp.data.emplace_back(); - kv.key = StringRef(it.first); - kv.value = StringRef(it.second); // data map lives until send completes + kv.key = ProgmemStringRef(StringRef(it.first)); + kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes } global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index e7798a5d267..8964e8ca352 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -116,42 +116,18 @@ template class HomeAssistantServiceCallAction final : public Act const Field *fields = this->fields_; const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_; - // Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send + // Lambda results must live until the send; the constant strings are encoded from flash where they are size_t lambda_count = 0; -#ifdef USE_ESP8266 - size_t flash_len = 0; -#endif - for (size_t i = 0; i < total; i++) { + for (size_t i = 0; i < total; i++) lambda_count += fields[i].fn != nullptr; -#ifdef USE_ESP8266 - if (fields[i].fn == nullptr) - flash_len += ESPHOME_strlen_P(fields[i].value); - if (fields[i].key != nullptr) - flash_len += ESPHOME_strlen_P(fields[i].key); -#endif - } FixedVector results; results.init(lambda_count); -#ifdef USE_ESP8266 - SmallBufferWithHeapFallback<128, char> flash_copy(flash_len); - char *cursor = flash_copy.get(); -#endif - auto string_ref = [&](const char *str) { -#ifdef USE_ESP8266 - size_t len = ESPHOME_strlen_P(str); - memcpy_P(cursor, str, len); - StringRef ref(cursor, len); - cursor += len; - return ref; -#else - return StringRef(str); -#endif - }; + auto string_ref = [](const char *str) { return ProgmemStringRef(str, ESPHOME_strlen_P(str)); }; auto value_ref = [&](const Field &field) { if (field.fn == nullptr) return string_ref(field.value); results.push_back(field.fn(x...)); - return StringRef(results.back()); + return ProgmemStringRef(StringRef(results.back())); }; auto fill = [&](FixedVector &dest, uint8_t count) { dest.init(count); diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp index 6cf1c87eaa5..5c830c8c923 100644 --- a/esphome/components/homeassistant/button/homeassistant_button.cpp +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -5,6 +5,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -31,22 +32,21 @@ void HomeassistantButton::press_action() { } #endif - static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press"); - static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_BUTTON[] PROGMEM = "button.press"; + static const char SERVICE_INPUT_BUTTON[] PROGMEM = "input_button.press"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_BUTTON; - } else { - resp.service = SERVICE_BUTTON; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_BUTTON, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_BUTTON) : ProgmemStringRef::from_lit(SERVICE_BUTTON); resp.data.init(1); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index df92aad38eb..73efc8e5922 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -5,6 +5,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -89,31 +90,29 @@ void HomeassistantNumber::control(float value) { this->publish_state(value); - static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); - static constexpr auto INPUT_NUMBER_SERVICE_NAME = StringRef::from_lit("input_number.set_value"); - static constexpr char INPUT_PREFIX[] = "input_"; - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto VALUE_KEY = StringRef::from_lit("value"); + // In flash on the ESP8266 and encoded from there + static const char NUMBER_SERVICE_NAME[] PROGMEM = "number.set_value"; + static const char INPUT_NUMBER_SERVICE_NAME[] PROGMEM = "input_number.set_value"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char VALUE_KEY[] PROGMEM = "value"; api::HomeassistantActionRequest resp; - // input_number entities have their own set_value action - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = INPUT_NUMBER_SERVICE_NAME; - } else { - resp.service = NUMBER_SERVICE_NAME; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, INPUT_NUMBER_SERVICE_NAME, sizeof("input_") - 1) == 0; + resp.service = input_helper ? ProgmemStringRef::from_lit(INPUT_NUMBER_SERVICE_NAME) + : ProgmemStringRef::from_lit(NUMBER_SERVICE_NAME); resp.data.init(2); auto &entity_id = resp.data.emplace_back(); - entity_id.key = ENTITY_ID_KEY; - entity_id.value = StringRef(this->entity_id_); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &entity_value = resp.data.emplace_back(); - entity_value.key = VALUE_KEY; + entity_value.key = ProgmemStringRef::from_lit(VALUE_KEY); // Stack buffer - no heap allocation; %g produces shortest representation char value_buf[16]; buf_append_printf(value_buf, sizeof(value_buf), 0, "%g", value); - entity_value.value = StringRef(value_buf); + entity_value.value = ProgmemStringRef(StringRef(value_buf)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index 189a6b1f376..4f4fc7a3295 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -6,6 +6,7 @@ #include "esphome/components/api/api_server.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -237,27 +238,26 @@ void HomeassistantSelect::control(size_t index) { } #endif - static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option"); - static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto OPTION_KEY = StringRef::from_lit("option"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_SELECT[] PROGMEM = "select.select_option"; + static const char SERVICE_INPUT_SELECT[] PROGMEM = "input_select.select_option"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char OPTION_KEY[] PROGMEM = "option"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_SELECT; - } else { - resp.service = SERVICE_SELECT; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_SELECT, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_SELECT) : ProgmemStringRef::from_lit(SERVICE_SELECT); resp.data.init(2); auto &entity_id = resp.data.emplace_back(); - entity_id.key = ENTITY_ID_KEY; - entity_id.value = StringRef(this->entity_id_); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &option = resp.data.emplace_back(); - option.key = OPTION_KEY; - option.value = StringRef(this->option_at(index)); + option.key = ProgmemStringRef::from_lit(OPTION_KEY); + option.value = ProgmemStringRef(StringRef(this->option_at(index))); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 841f16df200..f6fbdc1ef48 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -1,6 +1,7 @@ #include "homeassistant_switch.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -47,21 +48,18 @@ void HomeassistantSwitch::write_state(bool state) { } #endif - static constexpr auto SERVICE_ON = StringRef::from_lit("homeassistant.turn_on"); - static constexpr auto SERVICE_OFF = StringRef::from_lit("homeassistant.turn_off"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + // In flash on the ESP8266 and encoded from there + static const char SERVICE_ON[] PROGMEM = "homeassistant.turn_on"; + static const char SERVICE_OFF[] PROGMEM = "homeassistant.turn_off"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; api::HomeassistantActionRequest resp; - if (state) { - resp.service = SERVICE_ON; - } else { - resp.service = SERVICE_OFF; - } + resp.service = state ? ProgmemStringRef::from_lit(SERVICE_ON) : ProgmemStringRef::from_lit(SERVICE_OFF); resp.data.init(1); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp index 7155f6ee189..2a5598756a5 100644 --- a/esphome/components/homeassistant/text/homeassistant_text.cpp +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -6,6 +6,7 @@ #include "esphome/components/api/api_server.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -86,27 +87,26 @@ void HomeassistantText::control(const std::string &value) { } #endif - static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value"); - static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto VALUE_KEY = StringRef::from_lit("value"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_TEXT[] PROGMEM = "text.set_value"; + static const char SERVICE_INPUT_TEXT[] PROGMEM = "input_text.set_value"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char VALUE_KEY[] PROGMEM = "value"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_TEXT; - } else { - resp.service = SERVICE_TEXT; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_TEXT, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_TEXT) : ProgmemStringRef::from_lit(SERVICE_TEXT); resp.data.init(2); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &value_kv = resp.data.emplace_back(); - value_kv.key = VALUE_KEY; - value_kv.value = StringRef(value.data(), value.size()); + value_kv.key = ProgmemStringRef::from_lit(VALUE_KEY); + value_kv.value = ProgmemStringRef(value.data(), value.size()); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index b5b4a0bdd4e..7deea8ff664 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -232,6 +232,8 @@ class ProgmemStringRef { constexpr ProgmemStringRef() = default; constexpr ProgmemStringRef(const char *s, size_t len) : ref_(s, len) {} constexpr explicit ProgmemStringRef(const StringRef &s) : ref_(s) {} + /// A PROGMEM char array, with its length known at compile time + template static constexpr ProgmemStringRef from_lit(const char (&s)[N]) { return {s, N - 1}; } constexpr const char *progmem_ptr() const { return this->ref_.c_str(); } constexpr size_t size() const { return this->ref_.size(); } diff --git a/tests/components/core/test_progmem_string_ref.cpp b/tests/components/core/test_progmem_string_ref.cpp index ec6e77b1e49..1d996f36db7 100644 --- a/tests/components/core/test_progmem_string_ref.cpp +++ b/tests/components/core/test_progmem_string_ref.cpp @@ -1,5 +1,6 @@ #include +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::core::testing { @@ -12,6 +13,13 @@ TEST(ProgmemStringRef, EqualsComparesLengthAndContent) { EXPECT_FALSE(ref.equals(StringRef("Bedroom"))); } +TEST(ProgmemStringRef, FromLitTakesTheLengthAtCompileTime) { + static const char NAME[] PROGMEM = "entity_id"; + constexpr ProgmemStringRef ref = ProgmemStringRef::from_lit(NAME); + static_assert(ref.size() == 9); + EXPECT_TRUE(ref.equals(StringRef("entity_id"))); +} + TEST(ProgmemStringRef, DefaultIsEmpty) { ProgmemStringRef ref; EXPECT_TRUE(ref.empty());