From 25baec48ee1d94eb4ef6ede335d0f3013f5e38fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Feb 2026 11:02:03 -0600 Subject: [PATCH 1/8] [api] Optimize varint encoding to eliminate uint64_t widening Replace ProtoVarInt temporaries with direct uint32_t encoding functions to avoid unnecessary 64-bit widening on 32-bit architectures (ESP32 Xtensa). - Add encode_varint_to_buffer() free function for direct buffer encoding - Replace encode_varint_raw(ProtoVarInt) with uint32_t-native implementation - Add encode_varint_raw_64() for the few call sites needing 64-bit (BLE) - Remove unused ProtoVarInt::encode() and encode_to_buffer_unchecked() - Remove dead null checks from ProtoVarInt::parse() Net savings: -152 bytes flash on ESP32 IDF. --- .../api/api_frame_helper_plaintext.cpp | 5 +- esphome/components/api/proto.h | 92 +++++++------------ 2 files changed, 36 insertions(+), 61 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index ed3cc8934e..5069dbf68b 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -295,9 +295,8 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe buf_start[header_offset] = 0x00; // indicator // Encode varints directly into buffer - ProtoVarInt(msg.payload_size).encode_to_buffer_unchecked(buf_start + header_offset + 1, size_varint_len); - ProtoVarInt(msg.message_type) - .encode_to_buffer_unchecked(buf_start + header_offset + 1 + size_varint_len, type_varint_len); + encode_varint_to_buffer(msg.payload_size, buf_start + header_offset + 1); + encode_varint_to_buffer(msg.message_type, buf_start + header_offset + 1 + size_varint_len); // Add iovec for this message (header + payload) size_t msg_len = static_cast(total_header_len + msg.payload_size); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 41ea0043f9..6eb4135df9 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -57,6 +57,16 @@ inline uint16_t count_packed_varints(const uint8_t *data, size_t len) { return count; } +/// Encode a varint directly into a pre-allocated buffer. +/// Caller must ensure buffer has space (use ProtoSize::varint() to calculate). +inline void encode_varint_to_buffer(uint32_t val, uint8_t *buffer) { + while (val > 0x7F) { + *buffer++ = static_cast(val | 0x80); + val >>= 7; + } + *buffer = static_cast(val); +} + /* * StringRef Ownership Model for API Protocol Messages * =================================================== @@ -93,17 +103,14 @@ class ProtoVarInt { ProtoVarInt() : value_(0) {} explicit ProtoVarInt(uint64_t value) : value_(value) {} + /// Parse a varint from buffer. consumed must be a valid pointer (not null). static optional parse(const uint8_t *buffer, uint32_t len, uint32_t *consumed) { - if (len == 0) { - if (consumed != nullptr) - *consumed = 0; + if (len == 0) return {}; - } // Most common case: single-byte varint (values 0-127) if ((buffer[0] & 0x80) == 0) { - if (consumed != nullptr) - *consumed = 1; + *consumed = 1; return ProtoVarInt(buffer[0]); } @@ -122,14 +129,11 @@ class ProtoVarInt { result |= uint64_t(val & 0x7F) << uint64_t(bitpos); bitpos += 7; if ((val & 0x80) == 0) { - if (consumed != nullptr) - *consumed = i + 1; + *consumed = i + 1; return ProtoVarInt(result); } } - if (consumed != nullptr) - *consumed = 0; return {}; // Incomplete or invalid varint } @@ -153,50 +157,6 @@ class ProtoVarInt { // with ZigZag encoding return decode_zigzag64(this->value_); } - /** - * Encode the varint value to a pre-allocated buffer without bounds checking. - * - * @param buffer The pre-allocated buffer to write the encoded varint to - * @param len The size of the buffer in bytes - * - * @note The caller is responsible for ensuring the buffer is large enough - * to hold the encoded value. Use ProtoSize::varint() to calculate - * the exact size needed before calling this method. - * @note No bounds checking is performed for performance reasons. - */ - void encode_to_buffer_unchecked(uint8_t *buffer, size_t len) { - uint64_t val = this->value_; - if (val <= 0x7F) { - buffer[0] = val; - return; - } - size_t i = 0; - while (val && i < len) { - uint8_t temp = val & 0x7F; - val >>= 7; - if (val) { - buffer[i++] = temp | 0x80; - } else { - buffer[i++] = temp; - } - } - } - void encode(std::vector &out) { - uint64_t val = this->value_; - if (val <= 0x7F) { - out.push_back(val); - return; - } - while (val) { - uint8_t temp = val & 0x7F; - val >>= 7; - if (val) { - out.push_back(temp | 0x80); - } else { - out.push_back(temp); - } - } - } protected: uint64_t value_; @@ -256,8 +216,24 @@ class ProtoWriteBuffer { public: ProtoWriteBuffer(std::vector *buffer) : buffer_(buffer) {} void write(uint8_t value) { this->buffer_->push_back(value); } - void encode_varint_raw(ProtoVarInt value) { value.encode(*this->buffer_); } - void encode_varint_raw(uint32_t value) { this->encode_varint_raw(ProtoVarInt(value)); } + void encode_varint_raw(uint32_t value) { + if (value <= 0x7F) { + this->buffer_->push_back(static_cast(value)); + return; + } + while (value) { + uint8_t temp = value & 0x7F; + value >>= 7; + this->buffer_->push_back(value ? (temp | 0x80) : temp); + } + } + void encode_varint_raw_64(uint64_t value) { + while (value > 0x7F) { + this->buffer_->push_back(static_cast(value | 0x80)); + value >>= 7; + } + this->buffer_->push_back(static_cast(value)); + } /** * Encode a field key (tag/wire type combination). * @@ -307,7 +283,7 @@ class ProtoWriteBuffer { if (value == 0 && !force) return; this->encode_field_raw(field_id, 0); // type 0: Varint - uint64 - this->encode_varint_raw(ProtoVarInt(value)); + this->encode_varint_raw_64(value); } void encode_bool(uint32_t field_id, bool value, bool force = false) { if (!value && !force) @@ -938,7 +914,7 @@ inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessa this->buffer_->resize(this->buffer_->size() + varint_length_bytes); // Write the length varint directly - ProtoVarInt(msg_length_bytes).encode_to_buffer_unchecked(this->buffer_->data() + begin, varint_length_bytes); + encode_varint_to_buffer(msg_length_bytes, this->buffer_->data() + begin); // Now encode the message content - it will append to the buffer value.encode(*this); From dc2085cc2515e51b3770a71775aedaaeac5079d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Feb 2026 11:11:22 -0600 Subject: [PATCH 2/8] tweaks --- esphome/components/api/proto.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 6eb4135df9..4546851aec 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -221,11 +221,11 @@ class ProtoWriteBuffer { this->buffer_->push_back(static_cast(value)); return; } - while (value) { - uint8_t temp = value & 0x7F; + while (value > 0x7F) { + this->buffer_->push_back(static_cast(value | 0x80)); value >>= 7; - this->buffer_->push_back(value ? (temp | 0x80) : temp); } + this->buffer_->push_back(static_cast(value)); } void encode_varint_raw_64(uint64_t value) { while (value > 0x7F) { @@ -289,7 +289,7 @@ class ProtoWriteBuffer { if (!value && !force) return; this->encode_field_raw(field_id, 0); // type 0: Varint - bool - this->write(0x01); + this->buffer_->push_back(0x01); } void encode_fixed32(uint32_t field_id, uint32_t value, bool force = false) { if (value == 0 && !force) From fb1c4955065feec7f8947857dadb4e5ddc3c4931 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Feb 2026 11:12:46 -0600 Subject: [PATCH 3/8] tweaks --- esphome/components/api/proto.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 4546851aec..0088e5c451 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -105,6 +105,7 @@ class ProtoVarInt { /// Parse a varint from buffer. consumed must be a valid pointer (not null). static optional parse(const uint8_t *buffer, uint32_t len, uint32_t *consumed) { + assert(consumed != nullptr); if (len == 0) return {}; From 77d8fc80be5034d84a51acd73a440de688186bee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Feb 2026 11:18:50 -0600 Subject: [PATCH 4/8] Guard asserts with ESPHOME_DEBUG_API to avoid flash overhead --- esphome/components/api/proto.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 0088e5c451..536a3536db 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -105,7 +105,9 @@ class ProtoVarInt { /// Parse a varint from buffer. consumed must be a valid pointer (not null). static optional parse(const uint8_t *buffer, uint32_t len, uint32_t *consumed) { +#ifdef ESPHOME_DEBUG_API assert(consumed != nullptr); +#endif if (len == 0) return {}; @@ -920,8 +922,10 @@ inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessa // Now encode the message content - it will append to the buffer value.encode(*this); +#ifdef ESPHOME_DEBUG_API // Verify that the encoded size matches what we calculated assert(this->buffer_->size() == begin + varint_length_bytes + msg_length_bytes); +#endif } // Implementation of decode_to_message - must be after ProtoDecodableMessage is defined From d3e47a5d8049ed8578327c918dab56dd38374457 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Feb 2026 11:20:41 -0600 Subject: [PATCH 5/8] Fix encode_bool to encode actual value when force=true --- esphome/components/api/proto.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 536a3536db..9666b7e9ff 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -292,7 +292,7 @@ class ProtoWriteBuffer { if (!value && !force) return; this->encode_field_raw(field_id, 0); // type 0: Varint - bool - this->buffer_->push_back(0x01); + this->buffer_->push_back(value ? 0x01 : 0x00); } void encode_fixed32(uint32_t field_id, uint32_t value, bool force = false) { if (value == 0 && !force) From 93ff39709a3affadff3031e2d01ff7cdaf65063c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Feb 2026 11:28:08 -0600 Subject: [PATCH 6/8] Enable ESPHOME_DEBUG_API in API integration tests --- esphome/core/defines.h | 1 + tests/components/api/common-base.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0c888933bf..a2a967985e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -14,6 +14,7 @@ #define ESPHOME_PROJECT_VERSION_30 "v2" #define ESPHOME_VARIANT "ESP32" #define ESPHOME_DEBUG_SCHEDULER +#define ESPHOME_DEBUG_API // Default threading model for static analysis (ESP32 is multi-threaded with atomics) #define ESPHOME_THREAD_MULTI_ATOMICS diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index c766b61b13..c24045b28a 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -1,4 +1,7 @@ esphome: + platformio_options: + build_flags: + - -DESPHOME_DEBUG_API on_boot: then: - wait_until: From e63308aa33f60bd849b179ca964618cc8506dc7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Feb 2026 11:30:51 -0600 Subject: [PATCH 7/8] Use DEBUG guard for asserts instead of custom define Integration tests already pass -DDEBUG, so no custom define needed. --- esphome/components/api/proto.h | 4 ++-- esphome/core/defines.h | 1 - tests/components/api/common-base.yaml | 3 --- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 9666b7e9ff..be87dac6f6 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -105,7 +105,7 @@ class ProtoVarInt { /// Parse a varint from buffer. consumed must be a valid pointer (not null). static optional parse(const uint8_t *buffer, uint32_t len, uint32_t *consumed) { -#ifdef ESPHOME_DEBUG_API +#ifdef DEBUG assert(consumed != nullptr); #endif if (len == 0) @@ -922,7 +922,7 @@ inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessa // Now encode the message content - it will append to the buffer value.encode(*this); -#ifdef ESPHOME_DEBUG_API +#ifdef DEBUG // Verify that the encoded size matches what we calculated assert(this->buffer_->size() == begin + varint_length_bytes + msg_length_bytes); #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a2a967985e..0c888933bf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -14,7 +14,6 @@ #define ESPHOME_PROJECT_VERSION_30 "v2" #define ESPHOME_VARIANT "ESP32" #define ESPHOME_DEBUG_SCHEDULER -#define ESPHOME_DEBUG_API // Default threading model for static analysis (ESP32 is multi-threaded with atomics) #define ESPHOME_THREAD_MULTI_ATOMICS diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index c24045b28a..c766b61b13 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -1,7 +1,4 @@ esphome: - platformio_options: - build_flags: - - -DESPHOME_DEBUG_API on_boot: then: - wait_until: From 242d152edc3c0b0abe047bf460b17267b30f02c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Feb 2026 11:34:14 -0600 Subject: [PATCH 8/8] Use ESPHOME_DEBUG_API guard instead of DEBUG CI compile tests also define DEBUG, so a dedicated guard is needed. Enable ESPHOME_DEBUG_API in integration tests via conftest.py. --- esphome/components/api/proto.h | 4 ++-- esphome/core/defines.h | 1 + tests/integration/conftest.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index be87dac6f6..9666b7e9ff 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -105,7 +105,7 @@ class ProtoVarInt { /// Parse a varint from buffer. consumed must be a valid pointer (not null). static optional parse(const uint8_t *buffer, uint32_t len, uint32_t *consumed) { -#ifdef DEBUG +#ifdef ESPHOME_DEBUG_API assert(consumed != nullptr); #endif if (len == 0) @@ -922,7 +922,7 @@ inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessa // Now encode the message content - it will append to the buffer value.encode(*this); -#ifdef DEBUG +#ifdef ESPHOME_DEBUG_API // Verify that the encoded size matches what we calculated assert(this->buffer_->size() == begin + varint_length_bytes + msg_length_bytes); #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0c888933bf..a2a967985e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -14,6 +14,7 @@ #define ESPHOME_PROJECT_VERSION_30 "v2" #define ESPHOME_VARIANT "ESP32" #define ESPHOME_DEBUG_SCHEDULER +#define ESPHOME_DEBUG_API // Default threading model for static analysis (ESP32 is multi-threaded with atomics) #define ESPHOME_THREAD_MULTI_ATOMICS diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 50e8d4122b..36df1bc83e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -197,6 +197,7 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s " platformio_options:\n" " build_flags:\n" ' - "-DDEBUG" # Enable assert() statements\n' + ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' ' - "-g" # Add debug symbols', )