From 60c6bc3e21519912a726b36bdd0fa074cad5b591 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:23:14 -1000 Subject: [PATCH 01/37] try another way --- esphome/components/api/api_frame_helper_noise.cpp | 2 +- esphome/components/api/api_frame_helper_plaintext.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 5b60cee143..0781c501e5 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -489,7 +489,7 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, const M } // Outlined multi-message path to keep the single-message fast path's stack frame small. -APIError __attribute__((noinline)) +APIError __attribute__((noinline, flatten)) APINoiseFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages) { StaticVector iovs; uint16_t total_write_len = 0; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 64c8882afa..9d560a0333 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -288,7 +288,7 @@ static inline uint8_t *write_plaintext_header(uint8_t *buf_start, const MessageI // Outlined multi-message path to keep the single-message fast path's stack frame small. // The StaticVector would force a ~300-byte stack frame // even when only sending one message if it were in the same function. -APIError __attribute__((noinline)) +APIError __attribute__((noinline, flatten)) APIPlaintextFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages) { StaticVector iovs; uint16_t total_write_len = 0; From 9a896413770ef88735bbb1bcb4156670a3c4ddb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:23:14 -1000 Subject: [PATCH 02/37] [api] Add flatten attribute to batch write functions to avoid regression The noinline attribute on the batch path prevented the compiler from inlining callees like write_plaintext_header and encrypt_noise_message_ into the loop body, causing a 5.6% regression on batch writes. Adding flatten forces all callees to be inlined within the batch function while noinline still keeps its large stack frame separate from the single-message fast path. --- esphome/components/api/api_frame_helper_noise.cpp | 2 +- esphome/components/api/api_frame_helper_plaintext.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 5b60cee143..0781c501e5 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -489,7 +489,7 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, const M } // Outlined multi-message path to keep the single-message fast path's stack frame small. -APIError __attribute__((noinline)) +APIError __attribute__((noinline, flatten)) APINoiseFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages) { StaticVector iovs; uint16_t total_write_len = 0; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 64c8882afa..9d560a0333 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -288,7 +288,7 @@ static inline uint8_t *write_plaintext_header(uint8_t *buf_start, const MessageI // Outlined multi-message path to keep the single-message fast path's stack frame small. // The StaticVector would force a ~300-byte stack frame // even when only sending one message if it were in the same function. -APIError __attribute__((noinline)) +APIError __attribute__((noinline, flatten)) APIPlaintextFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages) { StaticVector iovs; uint16_t total_write_len = 0; From 481c0688ad0171a6402591f3370f180592a4e3d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:32:00 -1000 Subject: [PATCH 03/37] [api] Split write_protobuf_packet into dedicated virtual for single messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of routing single messages through write_protobuf_messages and branching internally, make write_protobuf_packet a separate virtual override in each frame helper. The single-message path gets its own minimal stack frame with no StaticVector allocation, and the batch path in write_protobuf_messages has no size==1 branch — each caller picks the right method upfront. --- esphome/components/api/api_frame_helper.h | 12 +--- .../components/api/api_frame_helper_noise.cpp | 45 +++++++-------- .../components/api/api_frame_helper_noise.h | 2 +- .../api/api_frame_helper_plaintext.cpp | 57 ++++++++----------- .../api/api_frame_helper_plaintext.h | 2 +- 5 files changed, 52 insertions(+), 66 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 72ccf8aa56..3507b4d22a 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -161,15 +161,9 @@ class APIFrameHelper { this->nodelay_counter_ = 0; } } - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - // Resize buffer to include footer space if needed (e.g. Noise MAC) - if (frame_footer_size_) - buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); - MessageInfo msg{type, 0, - static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; - return write_protobuf_messages(buffer, std::span(&msg, 1)); - } - // Write multiple protobuf messages in a single operation + // Write a single protobuf message - the hot path (87-100% of all writes) + virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; + // Write multiple protobuf messages in a single batched operation // messages contains (message_type, offset, length) for each message in the buffer // The buffer contains all messages with appropriate padding before each virtual APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) = 0; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 0781c501e5..565a96678e 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -488,23 +488,22 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, const M return APIError::OK; } -// Outlined multi-message path to keep the single-message fast path's stack frame small. -APIError __attribute__((noinline, flatten)) -APINoiseFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages) { - StaticVector iovs; - uint16_t total_write_len = 0; +APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) + return aerr; - for (const auto &msg : messages) { - uint8_t *buf_start = buffer_data + msg.offset; - struct iovec iov; - APIError aerr = this->encrypt_noise_message_(buf_start, msg, iov); - if (aerr != APIError::OK) - return aerr; - iovs.push_back(iov); - total_write_len += iov.iov_len; - } + // Resize buffer to include footer space for Noise MAC + if (frame_footer_size_) + buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); - return this->write_raw_(iovs.data(), iovs.size(), total_write_len); + MessageInfo msg{type, 0, + static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; + struct iovec iov; + aerr = this->encrypt_noise_message_(buffer.get_buffer()->data(), msg, iov); + if (aerr != APIError::OK) + return aerr; + return this->write_raw_(&iov, 1, static_cast(iov.iov_len)); } APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { @@ -517,20 +516,20 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s } uint8_t *buffer_data = buffer.get_buffer()->data(); + StaticVector iovs; + uint16_t total_write_len = 0; - if (messages.size() == 1) [[likely]] { - // Peeled first iteration: single-message case (most common path via write_protobuf_packet) - // avoids StaticVector stack allocation and loop overhead - const auto &first = messages[0]; + for (const auto &msg : messages) { + uint8_t *buf_start = buffer_data + msg.offset; struct iovec iov; - aerr = this->encrypt_noise_message_(buffer_data + first.offset, first, iov); + aerr = this->encrypt_noise_message_(buf_start, msg, iov); if (aerr != APIError::OK) return aerr; - return this->write_raw_(&iov, 1, static_cast(iov.iov_len)); + iovs.push_back(iov); + total_write_len += iov.iov_len; } - // Multiple messages: outlined to avoid large stack frame on single-message path - return this->write_protobuf_messages_batch_(buffer_data, messages); + return this->write_raw_(iovs.data(), iovs.size(), total_write_len); } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 7a81d2032e..e56006d955 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -22,6 +22,7 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: @@ -29,7 +30,6 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError try_read_frame_(); APIError write_frame_(const uint8_t *data, uint16_t len); APIError encrypt_noise_message_(uint8_t *buf_start, const MessageInfo &msg, struct iovec &iov_out); - APIError write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages); APIError init_handshake_(); APIError check_handshake_finished_(); void send_explicit_handshake_reject_(const LogString *reason); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 9d560a0333..0dc5690b8f 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -285,11 +285,31 @@ static inline uint8_t *write_plaintext_header(uint8_t *buf_start, const MessageI return buf_start + header_offset; } -// Outlined multi-message path to keep the single-message fast path's stack frame small. -// The StaticVector would force a ~300-byte stack frame -// even when only sending one message if it were in the same function. -APIError __attribute__((noinline, flatten)) -APIPlaintextFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages) { +APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) + return aerr; + + MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - frame_header_padding_)}; + uint8_t *buffer_data = buffer.get_buffer()->data(); + uint8_t *msg_start = write_plaintext_header(buffer_data, msg, frame_header_padding_); + uint8_t msg_header_len = static_cast(buffer_data + frame_header_padding_ - msg_start); + size_t msg_len = static_cast(msg_header_len + msg.payload_size); + struct iovec iov = {msg_start, msg_len}; + return write_raw_(&iov, 1, static_cast(msg_len)); +} + +APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, + std::span messages) { + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) + return aerr; + + if (messages.empty()) { + return APIError::OK; + } + + uint8_t *buffer_data = buffer.get_buffer()->data(); StaticVector iovs; uint16_t total_write_len = 0; const uint8_t padding = frame_header_padding_; @@ -305,33 +325,6 @@ APIPlaintextFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, st return write_raw_(iovs.data(), iovs.size(), total_write_len); } -APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, - std::span messages) { - APIError aerr = this->check_data_state_(); - if (aerr != APIError::OK) - return aerr; - - if (messages.empty()) { - return APIError::OK; - } - - uint8_t *buffer_data = buffer.get_buffer()->data(); - - if (messages.size() == 1) [[likely]] { - // Peeled first iteration: single-message case (most common path via write_protobuf_packet) - // avoids StaticVector stack allocation and loop overhead - const auto &first = messages[0]; - uint8_t *first_start = write_plaintext_header(buffer_data + first.offset, first, frame_header_padding_); - uint8_t first_header_len = static_cast((buffer_data + first.offset + frame_header_padding_) - first_start); - size_t first_len = static_cast(first_header_len + first.payload_size); - struct iovec iov = {first_start, first_len}; - return write_raw_(&iov, 1, static_cast(first_len)); - } - - // Multiple messages: outlined to avoid large stack frame on single-message path - return write_protobuf_messages_batch_(buffer_data, messages); -} - } // namespace esphome::api #endif // USE_API_PLAINTEXT #endif // USE_API diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 84311cb93a..96d47e9c7b 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -19,11 +19,11 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: APIError try_read_frame_(); - APIError write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages); // Group 2-byte aligned types uint16_t rx_header_parsed_type_ = 0; From 98b7f5a57140b9112a7b77d4d738088de4628995 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:38:54 -1000 Subject: [PATCH 04/37] [api] Force inline write_plaintext_header to avoid batch regression --- esphome/components/api/api_frame_helper_plaintext.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 0dc5690b8f..e670cca255 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -240,7 +240,7 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // Write plaintext header into pre-allocated padding before payload. // Returns pointer to start of frame (header + payload are contiguous). static inline uint8_t *write_plaintext_header(uint8_t *buf_start, const MessageInfo &msg, - uint8_t frame_header_padding) { + uint8_t frame_header_padding) ESPHOME_ALWAYS_INLINE { // Calculate varint sizes for header layout using inline ternary to avoid varint_slow call overhead uint8_t size_varint_len = msg.payload_size < ProtoSize::VARINT_THRESHOLD_1_BYTE ? 1 From 5d8f67c8199893af0b616a6e0dbbdbd717984ffc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:42:20 -1000 Subject: [PATCH 05/37] [api] Add single-buffer write_raw_ overload for single-message path The existing write_raw_ takes iovec array + count, but single-message writes (87-100% of traffic) always pass iovcnt=1. Adding a dedicated overload that takes (data, len) eliminates iovec construction, the iovcnt==1 branch, and pointer indirection on the hot path. --- esphome/components/api/api_frame_helper.cpp | 50 ++++++++++++++++++- esphome/components/api/api_frame_helper.h | 4 +- .../components/api/api_frame_helper_noise.cpp | 11 ++-- .../api/api_frame_helper_plaintext.cpp | 12 ++--- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 6d3bd51b58..e81981980f 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -112,8 +112,54 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { } // Write data to socket, overflow to backlog buffer if LWIP TCP send buffer is full. -// Returns OK if all data was sent or successfully queued. -// Returns SOCKET_WRITE_FAILED on hard error (sets state to FAILED). +// Single-buffer write path — avoids iovec setup for the common single-message case. +APIError APIFrameHelper::write_raw_(const void *data, uint16_t len) { +#ifdef HELPER_LOG_PACKETS + LOG_PACKET_SENDING(reinterpret_cast(data), len); +#endif + + // Drain any existing backlog first + if (!this->overflow_buf_.empty()) [[unlikely]] { + APIError err = this->drain_overflow_and_handle_errors_(); + if (err != APIError::OK) + return err; + } + + // If backlog is clear, try direct send + if (this->overflow_buf_.empty()) [[likely]] { + ssize_t sent = this->socket_->write(data, len); + + if (sent == -1) [[unlikely]] { + int err = errno; + if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + HELPER_LOG("Socket write failed with errno %d", err); + return APIError::SOCKET_WRITE_FAILED; + } + } else if (static_cast(sent) >= len) [[likely]] { + return APIError::OK; + } else { + // Partial write — queue remainder into overflow buffer + struct iovec iov = {const_cast(data), len}; + if (!this->overflow_buf_.enqueue_iov(&iov, 1, len, static_cast(sent))) { + HELPER_LOG("Overflow buffer full, dropping connection"); + this->state_ = State::FAILED; + return APIError::SOCKET_WRITE_FAILED; + } + return APIError::OK; + } + } + + // Socket not ready — queue all data into overflow buffer + struct iovec iov = {const_cast(data), len}; + if (!this->overflow_buf_.enqueue_iov(&iov, 1, len, 0)) { + HELPER_LOG("Overflow buffer full, dropping connection"); + this->state_ = State::FAILED; + return APIError::SOCKET_WRITE_FAILED; + } + return APIError::OK; +} + +// Multi-buffer write path for batched messages. APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { #ifdef HELPER_LOG_PACKETS for (int i = 0; i < iovcnt; i++) { diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 3507b4d22a..9520cf9c14 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -190,7 +190,9 @@ class APIFrameHelper { // Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors. APIError drain_overflow_and_handle_errors_(); - // Common implementation for writing raw data to socket + // Write a single contiguous buffer to the socket + APIError write_raw_(const void *data, uint16_t len); + // Write multiple iovec buffers to the socket in one writev call APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); // Check if a socket write errno is a hard error (not WOULD_BLOCK/EAGAIN). diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 565a96678e..0471713c54 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -499,11 +499,12 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; + uint8_t *buf_start = buffer.get_buffer()->data(); struct iovec iov; - aerr = this->encrypt_noise_message_(buffer.get_buffer()->data(), msg, iov); + aerr = this->encrypt_noise_message_(buf_start, msg, iov); if (aerr != APIError::OK) return aerr; - return this->write_raw_(&iov, 1, static_cast(iov.iov_len)); + return this->write_raw_(iov.iov_base, static_cast(iov.iov_len)); } APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { @@ -538,12 +539,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[1] = (uint8_t) (len >> 8); header[2] = (uint8_t) len; + if (len == 0) { + return this->write_raw_(header, 3); // Just header + } struct iovec iov[2]; iov[0].iov_base = header; iov[0].iov_len = 3; - if (len == 0) { - return this->write_raw_(iov, 1, 3); // Just header - } iov[1].iov_base = const_cast(data); iov[1].iov_len = len; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index e670cca255..79ccc83c4b 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -205,7 +205,6 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // Make sure to tell the remote that we don't // understand the indicator byte so it knows // we do not support it. - struct iovec iov[1]; // The \x00 first byte is the marker for plaintext. // // The remote will know how to handle the indicator byte, @@ -220,14 +219,12 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - iov[0].iov_base = (void *) msg; + this->write_raw_(msg, INDICATOR_MSG_SIZE); #else static const char MSG[] = "\x00" "Bad indicator byte"; - iov[0].iov_base = (void *) MSG; + this->write_raw_(MSG, INDICATOR_MSG_SIZE); #endif - iov[0].iov_len = INDICATOR_MSG_SIZE; - this->write_raw_(iov, 1, INDICATOR_MSG_SIZE); } return aerr; } @@ -294,9 +291,8 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite uint8_t *buffer_data = buffer.get_buffer()->data(); uint8_t *msg_start = write_plaintext_header(buffer_data, msg, frame_header_padding_); uint8_t msg_header_len = static_cast(buffer_data + frame_header_padding_ - msg_start); - size_t msg_len = static_cast(msg_header_len + msg.payload_size); - struct iovec iov = {msg_start, msg_len}; - return write_raw_(&iov, 1, static_cast(msg_len)); + uint16_t msg_len = static_cast(msg_header_len + msg.payload_size); + return write_raw_(msg_start, msg_len); } APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, From f272826274d9448ca405b51e3c36fa183c1d5eac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:43:01 -1000 Subject: [PATCH 06/37] [api] Extract enqueue_overflow_ slow path and remove dead iovcnt==1 branch Both write_raw_ overloads now have a clean fast path (direct socket write) and delegate to a shared noinline enqueue_overflow_ for the rare overflow case. The multi-iovec path now always calls writev since single-message callers use the dedicated overload. --- esphome/components/api/api_frame_helper.cpp | 46 +++++++++------------ esphome/components/api/api_frame_helper.h | 2 + 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index e81981980f..900f2e1f4b 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -112,6 +112,18 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { } // Write data to socket, overflow to backlog buffer if LWIP TCP send buffer is full. +// Slow path: queue data into overflow buffer when socket can't accept it all. +// Out-of-line to keep the fast paths small. +APIError __attribute__((noinline)) +APIFrameHelper::enqueue_overflow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t skip) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, skip)) { + HELPER_LOG("Overflow buffer full, dropping connection"); + this->state_ = State::FAILED; + return APIError::SOCKET_WRITE_FAILED; + } + return APIError::OK; +} + // Single-buffer write path — avoids iovec setup for the common single-message case. APIError APIFrameHelper::write_raw_(const void *data, uint16_t len) { #ifdef HELPER_LOG_PACKETS @@ -138,25 +150,15 @@ APIError APIFrameHelper::write_raw_(const void *data, uint16_t len) { } else if (static_cast(sent) >= len) [[likely]] { return APIError::OK; } else { - // Partial write — queue remainder into overflow buffer + // Partial write — queue remainder struct iovec iov = {const_cast(data), len}; - if (!this->overflow_buf_.enqueue_iov(&iov, 1, len, static_cast(sent))) { - HELPER_LOG("Overflow buffer full, dropping connection"); - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; - } - return APIError::OK; + return this->enqueue_overflow_(&iov, 1, len, static_cast(sent)); } } - // Socket not ready — queue all data into overflow buffer + // Socket not ready — queue all data struct iovec iov = {const_cast(data), len}; - if (!this->overflow_buf_.enqueue_iov(&iov, 1, len, 0)) { - HELPER_LOG("Overflow buffer full, dropping connection"); - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; - } - return APIError::OK; + return this->enqueue_overflow_(&iov, 1, len, 0); } // Multi-buffer write path for batched messages. @@ -167,8 +169,6 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ } #endif - uint16_t skip = 0; - // Drain any existing backlog first if (!this->overflow_buf_.empty()) [[unlikely]] { APIError err = this->drain_overflow_and_handle_errors_(); @@ -178,8 +178,7 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ // If backlog is clear, try direct send if (this->overflow_buf_.empty()) [[likely]] { - ssize_t sent = - (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); + ssize_t sent = this->socket_->writev(iov, iovcnt); if (sent == -1) [[unlikely]] { int err = errno; @@ -190,17 +189,12 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ } else if (static_cast(sent) >= total_write_len) [[likely]] { return APIError::OK; } else { - skip = static_cast(sent); + return this->enqueue_overflow_(iov, iovcnt, total_write_len, static_cast(sent)); } } - // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, skip)) { - HELPER_LOG("Overflow buffer full, dropping connection"); - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; - } - return APIError::OK; + // Socket not ready — queue all data + return this->enqueue_overflow_(iov, iovcnt, total_write_len, 0); } const char *APIFrameHelper::get_peername_to(std::span buf) const { diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 9520cf9c14..68284529a0 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -194,6 +194,8 @@ class APIFrameHelper { APIError write_raw_(const void *data, uint16_t len); // Write multiple iovec buffers to the socket in one writev call APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); + // Slow path: queue unsent data into overflow buffer + APIError enqueue_overflow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t skip); // Check if a socket write errno is a hard error (not WOULD_BLOCK/EAGAIN). // Returns WOULD_BLOCK for transient errors, SOCKET_WRITE_FAILED for hard errors. From ea7dc663f65184f131f443908722e55faad3cb20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:45:01 -1000 Subject: [PATCH 07/37] [api] Inline write_raw_ fast paths into header with single slow path Move the happy path (overflow empty + full write succeeds) inline into the header so it gets inlined at each call site. Both overloads share a single out-of-line write_raw_slow_ that handles partial writes, errors, and overflow buffering. --- esphome/components/api/api_frame_helper.cpp | 100 ++++++-------------- esphome/components/api/api_frame_helper.h | 40 +++++--- 2 files changed, 56 insertions(+), 84 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 900f2e1f4b..93119187d1 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -112,89 +112,49 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { } // Write data to socket, overflow to backlog buffer if LWIP TCP send buffer is full. -// Slow path: queue data into overflow buffer when socket can't accept it all. -// Out-of-line to keep the fast paths small. -APIError __attribute__((noinline)) -APIFrameHelper::enqueue_overflow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t skip) { - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, skip)) { - HELPER_LOG("Overflow buffer full, dropping connection"); - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; - } - return APIError::OK; -} - -// Single-buffer write path — avoids iovec setup for the common single-message case. -APIError APIFrameHelper::write_raw_(const void *data, uint16_t len) { -#ifdef HELPER_LOG_PACKETS - LOG_PACKET_SENDING(reinterpret_cast(data), len); -#endif - - // Drain any existing backlog first - if (!this->overflow_buf_.empty()) [[unlikely]] { - APIError err = this->drain_overflow_and_handle_errors_(); - if (err != APIError::OK) - return err; - } - - // If backlog is clear, try direct send - if (this->overflow_buf_.empty()) [[likely]] { - ssize_t sent = this->socket_->write(data, len); - - if (sent == -1) [[unlikely]] { - int err = errno; - if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { - HELPER_LOG("Socket write failed with errno %d", err); - return APIError::SOCKET_WRITE_FAILED; - } - } else if (static_cast(sent) >= len) [[likely]] { - return APIError::OK; - } else { - // Partial write — queue remainder - struct iovec iov = {const_cast(data), len}; - return this->enqueue_overflow_(&iov, 1, len, static_cast(sent)); - } - } - - // Socket not ready — queue all data - struct iovec iov = {const_cast(data), len}; - return this->enqueue_overflow_(&iov, 1, len, 0); -} - -// Multi-buffer write path for batched messages. -APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { +// Slow path: handles partial writes, errors, and overflow buffering. +// Called when the inline fast path in the header couldn't complete the write. +// sent == -1 means either the fast path write returned -1, or there was overflow backlog. +APIError APIFrameHelper::write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { #ifdef HELPER_LOG_PACKETS for (int i = 0; i < iovcnt; i++) { LOG_PACKET_SENDING(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); } #endif - // Drain any existing backlog first - if (!this->overflow_buf_.empty()) [[unlikely]] { - APIError err = this->drain_overflow_and_handle_errors_(); - if (err != APIError::OK) - return err; - } - - // If backlog is clear, try direct send - if (this->overflow_buf_.empty()) [[likely]] { - ssize_t sent = this->socket_->writev(iov, iovcnt); - - if (sent == -1) [[unlikely]] { + if (sent == -1) { + // Either the fast path got -1, or we were called with overflow backlog + if (!this->overflow_buf_.empty()) { + // Drain existing backlog first + APIError err = this->drain_overflow_and_handle_errors_(); + if (err != APIError::OK) + return err; + // Try again after drain + if (this->overflow_buf_.empty()) { + sent = + (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); + if (sent == static_cast(total_write_len)) + return APIError::OK; + } + } + if (sent == -1) { int err = errno; - if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + if (err != EWOULDBLOCK && err != EAGAIN) { + this->state_ = State::FAILED; HELPER_LOG("Socket write failed with errno %d", err); return APIError::SOCKET_WRITE_FAILED; } - } else if (static_cast(sent) >= total_write_len) [[likely]] { - return APIError::OK; - } else { - return this->enqueue_overflow_(iov, iovcnt, total_write_len, static_cast(sent)); + sent = 0; // Treat WOULD_BLOCK as zero bytes sent } } - // Socket not ready — queue all data - return this->enqueue_overflow_(iov, iovcnt, total_write_len, 0); + // Queue unsent data into overflow buffer + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { + HELPER_LOG("Overflow buffer full, dropping connection"); + this->state_ = State::FAILED; + return APIError::SOCKET_WRITE_FAILED; + } + return APIError::OK; } const char *APIFrameHelper::get_peername_to(std::span buf) const { diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 68284529a0..e8e27f8d98 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -190,21 +190,33 @@ class APIFrameHelper { // Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors. APIError drain_overflow_and_handle_errors_(); - // Write a single contiguous buffer to the socket - APIError write_raw_(const void *data, uint16_t len); - // Write multiple iovec buffers to the socket in one writev call - APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); - // Slow path: queue unsent data into overflow buffer - APIError enqueue_overflow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t skip); - - // Check if a socket write errno is a hard error (not WOULD_BLOCK/EAGAIN). - // Returns WOULD_BLOCK for transient errors, SOCKET_WRITE_FAILED for hard errors. - APIError check_socket_write_err_(int err) { - if (err == EWOULDBLOCK || err == EAGAIN) - return APIError::WOULD_BLOCK; - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; + // Write a single contiguous buffer to the socket (inlined fast path) + inline APIError ESPHOME_ALWAYS_INLINE write_raw_(const void *data, uint16_t len) { + // Fast path: no overflow backlog and full write succeeds + if (this->overflow_buf_.empty()) [[likely]] { + ssize_t sent = this->socket_->write(data, len); + if (sent == static_cast(len)) [[likely]] + return APIError::OK; + // Slow path: wrap in iovec and handle error/overflow + struct iovec iov = {const_cast(data), len}; + return this->write_raw_slow_(&iov, 1, len, sent); + } + struct iovec iov = {const_cast(data), len}; + return this->write_raw_slow_(&iov, 1, len, -1); } + // Write multiple iovec buffers to the socket (inlined fast path) + inline APIError ESPHOME_ALWAYS_INLINE write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { + // Fast path: no overflow backlog and full writev succeeds + if (this->overflow_buf_.empty()) [[likely]] { + ssize_t sent = this->socket_->writev(iov, iovcnt); + if (sent == static_cast(total_write_len)) [[likely]] + return APIError::OK; + return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); + } + return this->write_raw_slow_(iov, iovcnt, total_write_len, -1); + } + // Slow path (out-of-line): handle partial writes, errors, overflow buffering + APIError write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; From 348d5f583ade800be48c3364713ced678967a702 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:47:07 -1000 Subject: [PATCH 08/37] [api] Restructure write_raw_ for single tail call to slow path --- esphome/components/api/api_frame_helper.h | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index e8e27f8d98..0bf04735a2 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -193,27 +193,25 @@ class APIFrameHelper { // Write a single contiguous buffer to the socket (inlined fast path) inline APIError ESPHOME_ALWAYS_INLINE write_raw_(const void *data, uint16_t len) { // Fast path: no overflow backlog and full write succeeds + ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { - ssize_t sent = this->socket_->write(data, len); + sent = this->socket_->write(data, len); if (sent == static_cast(len)) [[likely]] return APIError::OK; - // Slow path: wrap in iovec and handle error/overflow - struct iovec iov = {const_cast(data), len}; - return this->write_raw_slow_(&iov, 1, len, sent); } struct iovec iov = {const_cast(data), len}; - return this->write_raw_slow_(&iov, 1, len, -1); + return this->write_raw_slow_(&iov, 1, len, sent); } // Write multiple iovec buffers to the socket (inlined fast path) inline APIError ESPHOME_ALWAYS_INLINE write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { // Fast path: no overflow backlog and full writev succeeds + ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { - ssize_t sent = this->socket_->writev(iov, iovcnt); + sent = this->socket_->writev(iov, iovcnt); if (sent == static_cast(total_write_len)) [[likely]] return APIError::OK; - return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); } - return this->write_raw_slow_(iov, iovcnt, total_write_len, -1); + return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); } // Slow path (out-of-line): handle partial writes, errors, overflow buffering APIError write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); From 3277683058d81b6cb10265e12d7464d0badb5bfc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:48:47 -1000 Subject: [PATCH 09/37] [api] Add missing this-> prefix on write_raw_ calls --- esphome/components/api/api_frame_helper_plaintext.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 79ccc83c4b..a8806e5215 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -292,7 +292,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite uint8_t *msg_start = write_plaintext_header(buffer_data, msg, frame_header_padding_); uint8_t msg_header_len = static_cast(buffer_data + frame_header_padding_ - msg_start); uint16_t msg_len = static_cast(msg_header_len + msg.payload_size); - return write_raw_(msg_start, msg_len); + return this->write_raw_(msg_start, msg_len); } APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, @@ -318,7 +318,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe total_write_len += msg_len; } - return write_raw_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_(iovs.data(), iovs.size(), total_write_len); } } // namespace esphome::api From a1163b298e7bbf82e12c4d791e31b0273532704e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:49:35 -1000 Subject: [PATCH 10/37] [api] Inline check_socket_write_err_ in drain_overflow (removed from header) --- esphome/components/api/api_frame_helper.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 93119187d1..0f2bc89e73 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -103,7 +103,8 @@ const LogString *api_error_to_logstr(APIError err) { APIError APIFrameHelper::drain_overflow_and_handle_errors_() { if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { int err = errno; - if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + if (err != EWOULDBLOCK && err != EAGAIN) { + this->state_ = State::FAILED; HELPER_LOG("Socket write failed with errno %d", err); return APIError::SOCKET_WRITE_FAILED; } From aee9d93671949f8c407ce3bb62d9813184b42dfc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 10:59:41 -1000 Subject: [PATCH 11/37] fix --- esphome/components/api/api_frame_helper_plaintext.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index a8806e5215..a726d2bc46 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -236,8 +236,8 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { } // Write plaintext header into pre-allocated padding before payload. // Returns pointer to start of frame (header + payload are contiguous). -static inline uint8_t *write_plaintext_header(uint8_t *buf_start, const MessageInfo &msg, - uint8_t frame_header_padding) ESPHOME_ALWAYS_INLINE { +ESPHOME_ALWAYS_INLINE static inline uint8_t *write_plaintext_header(uint8_t *buf_start, const MessageInfo &msg, + uint8_t frame_header_padding) { // Calculate varint sizes for header layout using inline ternary to avoid varint_slow call overhead uint8_t size_varint_len = msg.payload_size < ProtoSize::VARINT_THRESHOLD_1_BYTE ? 1 From ce9bc3aaa12968095fe3d1392420a84a8f14331b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:00:42 -1000 Subject: [PATCH 12/37] [api] Use iovec path for write_frame_ to avoid inlining write_raw_ in cold handshake code --- esphome/components/api/api_frame_helper_noise.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 0471713c54..e78f021743 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -539,12 +539,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[1] = (uint8_t) (len >> 8); header[2] = (uint8_t) len; - if (len == 0) { - return this->write_raw_(header, 3); // Just header - } struct iovec iov[2]; iov[0].iov_base = header; iov[0].iov_len = 3; + if (len == 0) { + return this->write_raw_(iov, 1, 3); // Just header + } iov[1].iov_base = const_cast(data); iov[1].iov_len = len; From 5a8e54301f46c5ef5dd5ad25d1e8d57f57a0bc86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:10:56 -1000 Subject: [PATCH 13/37] [api] Call write_raw_slow_ directly from cold paths to save flash write_frame_ (handshake) and bad indicator response are cold paths that don't benefit from the inlined write_raw_ fast path. Calling write_raw_slow_ directly avoids expanding the inline fast path code at these call sites, saving ~70 bytes per site. --- esphome/components/api/api_frame_helper_noise.cpp | 5 +++-- esphome/components/api/api_frame_helper_plaintext.cpp | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index e78f021743..d13a3472c4 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -539,16 +539,17 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[1] = (uint8_t) (len >> 8); header[2] = (uint8_t) len; + // Handshake-only path — use slow path directly to avoid inlining write_raw_ fast path struct iovec iov[2]; iov[0].iov_base = header; iov[0].iov_len = 3; if (len == 0) { - return this->write_raw_(iov, 1, 3); // Just header + return this->write_raw_slow_(iov, 1, 3, -1); } iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_(iov, 2, 3 + len); // Header + data + return this->write_raw_slow_(iov, 2, 3 + len, -1); } /** Initiate the data structures for the handshake. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index a726d2bc46..c9e178a900 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -219,11 +219,15 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - this->write_raw_(msg, INDICATOR_MSG_SIZE); + // Error path — use slow path directly to avoid inlining write_raw_ fast path + struct iovec iov = {msg, INDICATOR_MSG_SIZE}; + this->write_raw_slow_(&iov, 1, INDICATOR_MSG_SIZE, -1); #else static const char MSG[] = "\x00" "Bad indicator byte"; - this->write_raw_(MSG, INDICATOR_MSG_SIZE); + // Error path — use slow path directly to avoid inlining write_raw_ fast path + struct iovec iov = {const_cast(MSG), INDICATOR_MSG_SIZE}; + this->write_raw_slow_(&iov, 1, INDICATOR_MSG_SIZE, -1); #endif } return aerr; From ad25e2ae0ccc3d3ca4156356573fa078259581f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:12:10 -1000 Subject: [PATCH 14/37] [api] Add single-buffer write_raw_slow_ overload for cold paths Cold paths (write_frame_, bad indicator) no longer need to construct an iovec just to call the slow path. The single-buffer overload wraps in iovec internally, keeping call sites clean and avoiding iovec setup in the caller's stack frame. --- esphome/components/api/api_frame_helper.cpp | 9 +++++++-- esphome/components/api/api_frame_helper.h | 6 +++--- esphome/components/api/api_frame_helper_noise.cpp | 8 ++++---- esphome/components/api/api_frame_helper_plaintext.cpp | 8 ++------ 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 0f2bc89e73..c18bba0bee 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -112,8 +112,13 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { return APIError::OK; } -// Write data to socket, overflow to backlog buffer if LWIP TCP send buffer is full. -// Slow path: handles partial writes, errors, and overflow buffering. +// Single-buffer slow path: wraps data in iovec and delegates to the iovec slow path. +APIError APIFrameHelper::write_raw_slow_(const void *data, uint16_t len, ssize_t sent) { + struct iovec iov = {const_cast(data), len}; + return this->write_raw_slow_(&iov, 1, len, sent); +} + +// Multi-buffer slow path: handles partial writes, errors, and overflow buffering. // Called when the inline fast path in the header couldn't complete the write. // sent == -1 means either the fast path write returned -1, or there was overflow backlog. APIError APIFrameHelper::write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 0bf04735a2..172cb3e5fd 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -199,8 +199,7 @@ class APIFrameHelper { if (sent == static_cast(len)) [[likely]] return APIError::OK; } - struct iovec iov = {const_cast(data), len}; - return this->write_raw_slow_(&iov, 1, len, sent); + return this->write_raw_slow_(data, len, sent); } // Write multiple iovec buffers to the socket (inlined fast path) inline APIError ESPHOME_ALWAYS_INLINE write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { @@ -213,7 +212,8 @@ class APIFrameHelper { } return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); } - // Slow path (out-of-line): handle partial writes, errors, overflow buffering + // Slow paths (out-of-line): handle partial writes, errors, overflow buffering + APIError write_raw_slow_(const void *data, uint16_t len, ssize_t sent); APIError write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index d13a3472c4..d7348ae669 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -539,13 +539,13 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[1] = (uint8_t) (len >> 8); header[2] = (uint8_t) len; - // Handshake-only path — use slow path directly to avoid inlining write_raw_ fast path + if (len == 0) { + return this->write_raw_slow_(header, 3, -1); + } + // Handshake path — use iovec slow path directly to avoid inlining write_raw_ fast path struct iovec iov[2]; iov[0].iov_base = header; iov[0].iov_len = 3; - if (len == 0) { - return this->write_raw_slow_(iov, 1, 3, -1); - } iov[1].iov_base = const_cast(data); iov[1].iov_len = len; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index c9e178a900..9a134039e7 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -219,15 +219,11 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - // Error path — use slow path directly to avoid inlining write_raw_ fast path - struct iovec iov = {msg, INDICATOR_MSG_SIZE}; - this->write_raw_slow_(&iov, 1, INDICATOR_MSG_SIZE, -1); + this->write_raw_slow_(msg, INDICATOR_MSG_SIZE, -1); #else static const char MSG[] = "\x00" "Bad indicator byte"; - // Error path — use slow path directly to avoid inlining write_raw_ fast path - struct iovec iov = {const_cast(MSG), INDICATOR_MSG_SIZE}; - this->write_raw_slow_(&iov, 1, INDICATOR_MSG_SIZE, -1); + this->write_raw_slow_(MSG, INDICATOR_MSG_SIZE, -1); #endif } return aerr; From 9fd745cb09d7a0b3445ad590eab03d2f84018c88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:16:33 -1000 Subject: [PATCH 15/37] [api] Add out-of-line write_raw_ wrappers calling write_raw_inline_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hot paths use write_raw_inline_ (ALWAYS_INLINE fast path). Cold paths (handshake, error handling) use write_raw_ which is an out-of-line wrapper that calls write_raw_inline_ — same logic, no code duplication, but the compiler won't expand the fast path at each cold call site. --- esphome/components/api/api_frame_helper.cpp | 8 ++++++++ esphome/components/api/api_frame_helper.h | 20 ++++++++++++------- .../components/api/api_frame_helper_noise.cpp | 9 ++++----- .../api/api_frame_helper_plaintext.cpp | 8 ++++---- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index c18bba0bee..b784e46c4c 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -112,6 +112,14 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { return APIError::OK; } +// Out-of-line wrappers — same logic as write_raw_inline_ but not force-inlined. +// Used by cold callers (handshake, error handling) to avoid code bloat. +APIError APIFrameHelper::write_raw_(const void *data, uint16_t len) { return this->write_raw_inline_(data, len); } + +APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { + return this->write_raw_inline_(iov, iovcnt, total_write_len); +} + // Single-buffer slow path: wraps data in iovec and delegates to the iovec slow path. APIError APIFrameHelper::write_raw_slow_(const void *data, uint16_t len, ssize_t sent) { struct iovec iov = {const_cast(data), len}; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 172cb3e5fd..dba3344c6b 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -190,9 +190,14 @@ class APIFrameHelper { // Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors. APIError drain_overflow_and_handle_errors_(); - // Write a single contiguous buffer to the socket (inlined fast path) - inline APIError ESPHOME_ALWAYS_INLINE write_raw_(const void *data, uint16_t len) { - // Fast path: no overflow backlog and full write succeeds + // Out-of-line write methods — used by cold paths (handshake, error handling) + APIError write_raw_(const void *data, uint16_t len); + APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); + + // Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages) + // These inline the fast path (overflow empty + full write) and tail-call the out-of-line + // slow path only on failure/partial write. + inline APIError ESPHOME_ALWAYS_INLINE write_raw_inline_(const void *data, uint16_t len) { ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { sent = this->socket_->write(data, len); @@ -201,9 +206,8 @@ class APIFrameHelper { } return this->write_raw_slow_(data, len, sent); } - // Write multiple iovec buffers to the socket (inlined fast path) - inline APIError ESPHOME_ALWAYS_INLINE write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { - // Fast path: no overflow backlog and full writev succeeds + inline APIError ESPHOME_ALWAYS_INLINE write_raw_inline_(const struct iovec *iov, int iovcnt, + uint16_t total_write_len) { ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { sent = this->socket_->writev(iov, iovcnt); @@ -212,7 +216,9 @@ class APIFrameHelper { } return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); } - // Slow paths (out-of-line): handle partial writes, errors, overflow buffering + + private: + // Slow path: handle partial writes, errors, overflow buffering APIError write_raw_slow_(const void *data, uint16_t len, ssize_t sent); APIError write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index d7348ae669..3467566efe 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -504,7 +504,7 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff aerr = this->encrypt_noise_message_(buf_start, msg, iov); if (aerr != APIError::OK) return aerr; - return this->write_raw_(iov.iov_base, static_cast(iov.iov_len)); + return this->write_raw_inline_(iov.iov_base, static_cast(iov.iov_len)); } APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { @@ -530,7 +530,7 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s total_write_len += iov.iov_len; } - return this->write_raw_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_inline_(iovs.data(), iovs.size(), total_write_len); } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { @@ -540,16 +540,15 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[2] = (uint8_t) len; if (len == 0) { - return this->write_raw_slow_(header, 3, -1); + return this->write_raw_(header, 3); } - // Handshake path — use iovec slow path directly to avoid inlining write_raw_ fast path struct iovec iov[2]; iov[0].iov_base = header; iov[0].iov_len = 3; iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_slow_(iov, 2, 3 + len, -1); + return this->write_raw_(iov, 2, 3 + len); } /** Initiate the data structures for the handshake. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 9a134039e7..b208dcd455 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -219,11 +219,11 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - this->write_raw_slow_(msg, INDICATOR_MSG_SIZE, -1); + this->write_raw_(msg, INDICATOR_MSG_SIZE); #else static const char MSG[] = "\x00" "Bad indicator byte"; - this->write_raw_slow_(MSG, INDICATOR_MSG_SIZE, -1); + this->write_raw_(MSG, INDICATOR_MSG_SIZE); #endif } return aerr; @@ -292,7 +292,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite uint8_t *msg_start = write_plaintext_header(buffer_data, msg, frame_header_padding_); uint8_t msg_header_len = static_cast(buffer_data + frame_header_padding_ - msg_start); uint16_t msg_len = static_cast(msg_header_len + msg.payload_size); - return this->write_raw_(msg_start, msg_len); + return this->write_raw_inline_(msg_start, msg_len); } APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, @@ -318,7 +318,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe total_write_len += msg_len; } - return this->write_raw_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_inline_(iovs.data(), iovs.size(), total_write_len); } } // namespace esphome::api From 3af6001cdf3a36f3d40995401fe8cc892137e025 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:17:16 -1000 Subject: [PATCH 16/37] [api] Keep write_raw_slow_ protected to not break subclass member visibility --- esphome/components/api/api_frame_helper.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index dba3344c6b..460aca476b 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -217,8 +217,7 @@ class APIFrameHelper { return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); } - private: - // Slow path: handle partial writes, errors, overflow buffering + // Slow path: handle partial writes, errors, overflow buffering (private — only called by write_raw_inline_) APIError write_raw_slow_(const void *data, uint16_t len, ssize_t sent); APIError write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); From f1e780be0c1e4417c46dc55412c5b5c9e4c48f09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:25:33 -1000 Subject: [PATCH 17/37] [api] Remove out-of-line write_raw_ wrappers to save flash The wrappers called write_raw_inline_ which expanded the full fast path code, defeating the purpose. Cold callers now call write_raw_slow_ directly with sent=-1, which is the same behavior without duplicating the fast path at each cold call site. --- esphome/components/api/api_frame_helper.cpp | 8 -------- esphome/components/api/api_frame_helper.h | 7 ++----- esphome/components/api/api_frame_helper_noise.cpp | 4 ++-- esphome/components/api/api_frame_helper_plaintext.cpp | 4 ++-- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index b784e46c4c..c18bba0bee 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -112,14 +112,6 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { return APIError::OK; } -// Out-of-line wrappers — same logic as write_raw_inline_ but not force-inlined. -// Used by cold callers (handshake, error handling) to avoid code bloat. -APIError APIFrameHelper::write_raw_(const void *data, uint16_t len) { return this->write_raw_inline_(data, len); } - -APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { - return this->write_raw_inline_(iov, iovcnt, total_write_len); -} - // Single-buffer slow path: wraps data in iovec and delegates to the iovec slow path. APIError APIFrameHelper::write_raw_slow_(const void *data, uint16_t len, ssize_t sent) { struct iovec iov = {const_cast(data), len}; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 460aca476b..4b64c50925 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -190,10 +190,6 @@ class APIFrameHelper { // Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors. APIError drain_overflow_and_handle_errors_(); - // Out-of-line write methods — used by cold paths (handshake, error handling) - APIError write_raw_(const void *data, uint16_t len); - APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); - // Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages) // These inline the fast path (overflow empty + full write) and tail-call the out-of-line // slow path only on failure/partial write. @@ -217,7 +213,8 @@ class APIFrameHelper { return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); } - // Slow path: handle partial writes, errors, overflow buffering (private — only called by write_raw_inline_) + // Write methods — used by cold paths (handshake, error handling) + // These go through the slow path directly, avoiding inlining the fast path at the call site. APIError write_raw_slow_(const void *data, uint16_t len, ssize_t sent); APIError write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 3467566efe..190d0711a8 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -540,7 +540,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[2] = (uint8_t) len; if (len == 0) { - return this->write_raw_(header, 3); + return this->write_raw_slow_(header, 3, -1); } struct iovec iov[2]; iov[0].iov_base = header; @@ -548,7 +548,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_(iov, 2, 3 + len); + return this->write_raw_slow_(iov, 2, 3 + len, -1); } /** Initiate the data structures for the handshake. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index b208dcd455..6bd4b20a3b 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -219,11 +219,11 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - this->write_raw_(msg, INDICATOR_MSG_SIZE); + this->write_raw_slow_(msg, INDICATOR_MSG_SIZE, -1); #else static const char MSG[] = "\x00" "Bad indicator byte"; - this->write_raw_(MSG, INDICATOR_MSG_SIZE); + this->write_raw_slow_(MSG, INDICATOR_MSG_SIZE, -1); #endif } return aerr; From 9df835600cae95c6d6a6d5284f7a9efc157adec5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:26:23 -1000 Subject: [PATCH 18/37] [api] Rename write_raw_inline_ to write_raw_fast_ --- esphome/components/api/api_frame_helper.h | 5 ++--- esphome/components/api/api_frame_helper_noise.cpp | 4 ++-- esphome/components/api/api_frame_helper_plaintext.cpp | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 4b64c50925..f8f9bf81ef 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -193,7 +193,7 @@ class APIFrameHelper { // Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages) // These inline the fast path (overflow empty + full write) and tail-call the out-of-line // slow path only on failure/partial write. - inline APIError ESPHOME_ALWAYS_INLINE write_raw_inline_(const void *data, uint16_t len) { + inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_(const void *data, uint16_t len) { ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { sent = this->socket_->write(data, len); @@ -202,8 +202,7 @@ class APIFrameHelper { } return this->write_raw_slow_(data, len, sent); } - inline APIError ESPHOME_ALWAYS_INLINE write_raw_inline_(const struct iovec *iov, int iovcnt, - uint16_t total_write_len) { + inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { sent = this->socket_->writev(iov, iovcnt); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 190d0711a8..4b8b2f9706 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -504,7 +504,7 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff aerr = this->encrypt_noise_message_(buf_start, msg, iov); if (aerr != APIError::OK) return aerr; - return this->write_raw_inline_(iov.iov_base, static_cast(iov.iov_len)); + return this->write_raw_fast_(iov.iov_base, static_cast(iov.iov_len)); } APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { @@ -530,7 +530,7 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s total_write_len += iov.iov_len; } - return this->write_raw_inline_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_fast_(iovs.data(), iovs.size(), total_write_len); } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 6bd4b20a3b..cb97f9d394 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -292,7 +292,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite uint8_t *msg_start = write_plaintext_header(buffer_data, msg, frame_header_padding_); uint8_t msg_header_len = static_cast(buffer_data + frame_header_padding_ - msg_start); uint16_t msg_len = static_cast(msg_header_len + msg.payload_size); - return this->write_raw_inline_(msg_start, msg_len); + return this->write_raw_fast_(msg_start, msg_len); } APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, @@ -318,7 +318,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe total_write_len += msg_len; } - return this->write_raw_inline_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_fast_(iovs.data(), iovs.size(), total_write_len); } } // namespace esphome::api From a91e6d92f6e922d37283c6b9679e7ce458785716 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:32:43 -1000 Subject: [PATCH 19/37] [core] Remove dead get_loop_priority code (#15242) --- esphome/components/ch422g/ch422g.cpp | 6 ------ esphome/components/ch422g/ch422g.h | 3 --- esphome/components/ch423/ch423.cpp | 6 ------ esphome/components/ch423/ch423.h | 3 --- esphome/components/deep_sleep/deep_sleep_component.cpp | 6 ------ esphome/components/deep_sleep/deep_sleep_component.h | 3 --- esphome/components/pca9554/pca9554.cpp | 5 ----- esphome/components/pca9554/pca9554.h | 4 ---- esphome/components/pcf8574/pcf8574.cpp | 5 ----- esphome/components/pcf8574/pcf8574.h | 3 --- esphome/components/status_led/light/status_led_light.h | 3 --- esphome/components/status_led/status_led.cpp | 3 --- esphome/components/status_led/status_led.h | 3 --- esphome/components/wifi/wifi_component.cpp | 6 ------ esphome/components/wifi/wifi_component.h | 4 ---- esphome/core/application.cpp | 6 ------ esphome/core/component.cpp | 4 ---- esphome/core/component.h | 10 ---------- esphome/core/defines.h | 1 - 19 files changed, 84 deletions(-) diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 5f5e848c76..fc856cd563 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -124,12 +124,6 @@ bool CH422GComponent::write_outputs_() { float CH422GComponent::get_setup_priority() const { return setup_priority::IO; } -#ifdef USE_LOOP_PRIORITY -// Run our loop() method very early in the loop, so that we cache read values -// before other components call our digital_read() method. -float CH422GComponent::get_loop_priority() const { return 9.0f; } // Just after WIFI -#endif - void CH422GGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool CH422GGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index 1b96568209..6e6bdad64a 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -23,9 +23,6 @@ class CH422GComponent : public Component, public i2c::I2CDevice { void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif void dump_config() override; protected: diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index 805d8df877..8424d130b4 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -129,12 +129,6 @@ bool CH423Component::write_outputs_() { float CH423Component::get_setup_priority() const { return setup_priority::IO; } -#ifdef USE_LOOP_PRIORITY -// Run our loop() method very early in the loop, so that we cache read values -// before other components call our digital_read() method. -float CH423Component::get_loop_priority() const { return 9.0f; } // Just after WIFI -#endif - void CH423GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool CH423GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index d85648a8f9..d384971a72 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -22,9 +22,6 @@ class CH423Component : public Component, public i2c::I2CDevice { void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif void dump_config() override; protected: diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 0511518419..3dd1b70930 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -40,12 +40,6 @@ void DeepSleepComponent::loop() { this->begin_sleep(); } -#ifdef USE_LOOP_PRIORITY -float DeepSleepComponent::get_loop_priority() const { - return -100.0f; // run after everything else is ready -} -#endif - void DeepSleepComponent::set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } void DeepSleepComponent::set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 14713d51a1..9090f91876 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -113,9 +113,6 @@ class DeepSleepComponent : public Component { void setup() override; void dump_config() override; void loop() override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif float get_setup_priority() const override; /// Helper to enter deep sleep mode diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index d94767ef07..adc7bc0fb5 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -122,11 +122,6 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } -#ifdef USE_LOOP_PRIORITY -// Run our loop() method early to invalidate cache before any other components access the pins -float PCA9554Component::get_loop_priority() const { return 9.0f; } // Just after WIFI -#endif - void PCA9554GPIOPin::setup() { pin_mode(flags_); } void PCA9554GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCA9554GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 6dd15ccb4b..1d877f9ce2 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -23,10 +23,6 @@ class PCA9554Component : public Component, float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif - void dump_config() override; void set_pin_count(size_t pin_count) { this->pin_count_ = pin_count; } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index fa9496e7e4..d3ec31436d 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -99,11 +99,6 @@ bool PCF8574Component::write_gpio_() { } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } -#ifdef USE_LOOP_PRIORITY -// Run our loop() method early to invalidate cache before any other components access the pins -float PCF8574Component::get_loop_priority() const { return 9.0f; } // Just after WIFI -#endif - void PCF8574GPIOPin::setup() { pin_mode(flags_); } void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 23bccc26c9..b039173789 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -26,9 +26,6 @@ class PCF8574Component : public Component, void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif void dump_config() override; diff --git a/esphome/components/status_led/light/status_led_light.h b/esphome/components/status_led/light/status_led_light.h index a5c98d90d4..3a745e0017 100644 --- a/esphome/components/status_led/light/status_led_light.h +++ b/esphome/components/status_led/light/status_led_light.h @@ -30,9 +30,6 @@ class StatusLEDLightOutput : public light::LightOutput, public Component { void dump_config() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override { return 50.0f; } -#endif protected: GPIOPin *pin_{nullptr}; diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index 93a8d4b38e..a792110eeb 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -28,9 +28,6 @@ void StatusLED::loop() { } } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } -#ifdef USE_LOOP_PRIORITY -float StatusLED::get_loop_priority() const { return 50.0f; } -#endif } // namespace status_led } // namespace esphome diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index f262eb260c..a4b5db93d7 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -14,9 +14,6 @@ class StatusLED : public Component { void dump_config() override; void loop() override; float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif protected: GPIOPin *pin_; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 620d1a083d..db20332667 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -970,12 +970,6 @@ void WiFiComponent::set_ap(const WiFiAP &ap) { } #endif // USE_WIFI_AP -#ifdef USE_LOOP_PRIORITY -float WiFiComponent::get_loop_priority() const { - return 10.0f; // before other loop components -} -#endif - void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); } void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); } void WiFiComponent::clear_sta() { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 55e532c37d..8dfe5fa7af 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -463,10 +463,6 @@ class WiFiComponent final : public Component { void restart_adapter(); /// WIFI setup_priority. float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif - /// Reconnect WiFi if required. void loop() override; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index ce15aed1e2..5cb8a5bb24 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -99,12 +99,6 @@ void Application::setup() { if (component->can_proceed()) continue; -#ifdef USE_LOOP_PRIORITY - // Sort components 0 through i by loop priority - insertion_sort_by_prioritycomponents_.begin()), &Component::get_loop_priority>( - this->components_.begin(), this->components_.begin() + i + 1); -#endif - do { uint8_t new_app_state = STATUS_LED_WARNING; uint32_t now = millis(); diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index caaea89143..2ad82e1172 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -85,10 +85,6 @@ void store_component_error_message(const Component *component, const char *messa static constexpr uint16_t WARN_IF_BLOCKING_INCREMENT_MS = 10U; ///< How long the blocking time must be larger to warn again -#ifdef USE_LOOP_PRIORITY -float Component::get_loop_priority() const { return 0.0f; } -#endif - float Component::get_setup_priority() const { return setup_priority::DATA; } void Component::setup() {} diff --git a/esphome/core/component.h b/esphome/core/component.h index 46cd77b034..d08b1abfcd 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -115,16 +115,6 @@ class Component { void set_setup_priority(float priority); - /** priority of loop(). higher -> executed earlier - * - * Defaults to 0. - * - * @return The loop priority of this component - */ -#ifdef USE_LOOP_PRIORITY - virtual float get_loop_priority() const; -#endif - void call(); virtual void on_shutdown() {} diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8cf331c4d6..7259167a52 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -357,7 +357,6 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) -#define USE_LOOP_PRIORITY #define USE_RP2040_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C From d7464f94b8e8126adf43727a244d4cb1071539f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:40:22 -1000 Subject: [PATCH 20/37] [api] Remove single-buffer write_raw_slow_ overload to save 33 bytes Callers construct iovec inline instead of going through a 33-byte wrapper function. The iovec construction is trivial at each call site. --- esphome/components/api/api_frame_helper.cpp | 8 +------- esphome/components/api/api_frame_helper.h | 7 +++---- esphome/components/api/api_frame_helper_noise.cpp | 3 ++- esphome/components/api/api_frame_helper_plaintext.cpp | 6 ++++-- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index c18bba0bee..88bbbe697f 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -112,13 +112,7 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { return APIError::OK; } -// Single-buffer slow path: wraps data in iovec and delegates to the iovec slow path. -APIError APIFrameHelper::write_raw_slow_(const void *data, uint16_t len, ssize_t sent) { - struct iovec iov = {const_cast(data), len}; - return this->write_raw_slow_(&iov, 1, len, sent); -} - -// Multi-buffer slow path: handles partial writes, errors, and overflow buffering. +// Slow path: handles partial writes, errors, and overflow buffering. // Called when the inline fast path in the header couldn't complete the write. // sent == -1 means either the fast path write returned -1, or there was overflow backlog. APIError APIFrameHelper::write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f8f9bf81ef..d9bd5efa37 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -200,7 +200,8 @@ class APIFrameHelper { if (sent == static_cast(len)) [[likely]] return APIError::OK; } - return this->write_raw_slow_(data, len, sent); + struct iovec iov = {const_cast(data), len}; + return this->write_raw_slow_(&iov, 1, len, sent); } inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { ssize_t sent = -1; @@ -212,9 +213,7 @@ class APIFrameHelper { return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); } - // Write methods — used by cold paths (handshake, error handling) - // These go through the slow path directly, avoiding inlining the fast path at the call site. - APIError write_raw_slow_(const void *data, uint16_t len, ssize_t sent); + // Slow path (out-of-line): handle partial writes, errors, overflow buffering APIError write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 4b8b2f9706..150a110eb3 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -540,7 +540,8 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[2] = (uint8_t) len; if (len == 0) { - return this->write_raw_slow_(header, 3, -1); + struct iovec iov = {header, 3}; + return this->write_raw_slow_(&iov, 1, 3, -1); } struct iovec iov[2]; iov[0].iov_base = header; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index cb97f9d394..5a887801d5 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -219,11 +219,13 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - this->write_raw_slow_(msg, INDICATOR_MSG_SIZE, -1); + struct iovec iov = {msg, INDICATOR_MSG_SIZE}; + this->write_raw_slow_(&iov, 1, INDICATOR_MSG_SIZE, -1); #else static const char MSG[] = "\x00" "Bad indicator byte"; - this->write_raw_slow_(MSG, INDICATOR_MSG_SIZE, -1); + struct iovec iov = {const_cast(MSG), INDICATOR_MSG_SIZE}; + this->write_raw_slow_(&iov, 1, INDICATOR_MSG_SIZE, -1); #endif } return aerr; From 847c2ddbbf09d0d79abc011490fa77cbf97da98a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:41:11 -1000 Subject: [PATCH 21/37] [api] Rename write_raw_slow_ to write_raw_ --- esphome/components/api/api_frame_helper.cpp | 2 +- esphome/components/api/api_frame_helper.h | 6 +++--- esphome/components/api/api_frame_helper_noise.cpp | 4 ++-- esphome/components/api/api_frame_helper_plaintext.cpp | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 88bbbe697f..bac5ec1c14 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -115,7 +115,7 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { // Slow path: handles partial writes, errors, and overflow buffering. // Called when the inline fast path in the header couldn't complete the write. // sent == -1 means either the fast path write returned -1, or there was overflow backlog. -APIError APIFrameHelper::write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { +APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { #ifdef HELPER_LOG_PACKETS for (int i = 0; i < iovcnt; i++) { LOG_PACKET_SENDING(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index d9bd5efa37..7d6499dea9 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -201,7 +201,7 @@ class APIFrameHelper { return APIError::OK; } struct iovec iov = {const_cast(data), len}; - return this->write_raw_slow_(&iov, 1, len, sent); + return this->write_raw_(&iov, 1, len, sent); } inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { ssize_t sent = -1; @@ -210,11 +210,11 @@ class APIFrameHelper { if (sent == static_cast(total_write_len)) [[likely]] return APIError::OK; } - return this->write_raw_slow_(iov, iovcnt, total_write_len, sent); + return this->write_raw_(iov, iovcnt, total_write_len, sent); } // Slow path (out-of-line): handle partial writes, errors, overflow buffering - APIError write_raw_slow_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); + APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 150a110eb3..0a5e6fe085 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -541,7 +541,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { if (len == 0) { struct iovec iov = {header, 3}; - return this->write_raw_slow_(&iov, 1, 3, -1); + return this->write_raw_(&iov, 1, 3, -1); } struct iovec iov[2]; iov[0].iov_base = header; @@ -549,7 +549,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_slow_(iov, 2, 3 + len, -1); + return this->write_raw_(iov, 2, 3 + len, -1); } /** Initiate the data structures for the handshake. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 5a887801d5..2207f4e15f 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -220,12 +220,12 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); struct iovec iov = {msg, INDICATOR_MSG_SIZE}; - this->write_raw_slow_(&iov, 1, INDICATOR_MSG_SIZE, -1); + this->write_raw_(&iov, 1, INDICATOR_MSG_SIZE, -1); #else static const char MSG[] = "\x00" "Bad indicator byte"; struct iovec iov = {const_cast(MSG), INDICATOR_MSG_SIZE}; - this->write_raw_slow_(&iov, 1, INDICATOR_MSG_SIZE, -1); + this->write_raw_(&iov, 1, INDICATOR_MSG_SIZE, -1); #endif } return aerr; From 371070ab78193d48c7a47dd0079b9cd2f11578af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:42:42 -1000 Subject: [PATCH 22/37] [api] Default sent=-1 in write_raw_ for cleaner cold path call sites --- esphome/components/api/api_frame_helper.h | 2 +- esphome/components/api/api_frame_helper_noise.cpp | 4 ++-- esphome/components/api/api_frame_helper_plaintext.cpp | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 7d6499dea9..f93b086186 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -214,7 +214,7 @@ class APIFrameHelper { } // Slow path (out-of-line): handle partial writes, errors, overflow buffering - APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent); + APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent = -1); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 0a5e6fe085..429f6fde1f 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -541,7 +541,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { if (len == 0) { struct iovec iov = {header, 3}; - return this->write_raw_(&iov, 1, 3, -1); + return this->write_raw_(&iov, 1, 3); } struct iovec iov[2]; iov[0].iov_base = header; @@ -549,7 +549,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_(iov, 2, 3 + len, -1); + return this->write_raw_(iov, 2, 3 + len); } /** Initiate the data structures for the handshake. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 2207f4e15f..6e33f68154 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -220,12 +220,12 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); struct iovec iov = {msg, INDICATOR_MSG_SIZE}; - this->write_raw_(&iov, 1, INDICATOR_MSG_SIZE, -1); + this->write_raw_(&iov, 1, INDICATOR_MSG_SIZE); #else static const char MSG[] = "\x00" "Bad indicator byte"; struct iovec iov = {const_cast(MSG), INDICATOR_MSG_SIZE}; - this->write_raw_(&iov, 1, INDICATOR_MSG_SIZE, -1); + this->write_raw_(&iov, 1, INDICATOR_MSG_SIZE); #endif } return aerr; From 3bfc9b7f1f812f944ebe286da1e87e8cb02ebcdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:47:42 -1000 Subject: [PATCH 23/37] [api] Replace empty messages check with debug assert Callers are responsible for not passing empty spans. Added ESPHOME_DEBUG_API assert to catch bugs during development. --- esphome/components/api/api_frame_helper.h | 7 ++++--- esphome/components/api/api_frame_helper_noise.cpp | 7 +++---- esphome/components/api/api_frame_helper_plaintext.cpp | 7 +++---- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f93b086186..a582307738 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -163,9 +163,10 @@ class APIFrameHelper { } // Write a single protobuf message - the hot path (87-100% of all writes) virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; - // Write multiple protobuf messages in a single batched operation - // messages contains (message_type, offset, length) for each message in the buffer - // The buffer contains all messages with appropriate padding before each + // Write multiple protobuf messages in a single batched operation. + // messages must not be empty — caller is responsible for checking. + // messages contains (message_type, offset, length) for each message in the buffer. + // The buffer contains all messages with appropriate padding before each. virtual APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) = 0; // Get the frame header padding required by this protocol uint8_t frame_header_padding() const { return frame_header_padding_; } diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 429f6fde1f..d6b4a72cae 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -511,10 +511,9 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s APIError aerr = this->check_data_state_(); if (aerr != APIError::OK) return aerr; - - if (messages.empty()) { - return APIError::OK; - } +#ifdef ESPHOME_DEBUG_API + assert(!messages.empty()); +#endif uint8_t *buffer_data = buffer.get_buffer()->data(); StaticVector iovs; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 6e33f68154..a21d177b7f 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -302,10 +302,9 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe APIError aerr = this->check_data_state_(); if (aerr != APIError::OK) return aerr; - - if (messages.empty()) { - return APIError::OK; - } +#ifdef ESPHOME_DEBUG_API + assert(!messages.empty()); +#endif uint8_t *buffer_data = buffer.get_buffer()->data(); StaticVector iovs; From c970c38e8b6a6c8942b6864b5a48d2f1bd156acd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:49:52 -1000 Subject: [PATCH 24/37] [api] Replace check_data_state_ with debug assert in write methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers already guarantee DATA state before calling write_protobuf_packet (via try_to_clear_buffer) and write_protobuf_messages (via loop processing). The runtime check is unreachable — replaced with ESPHOME_DEBUG_API assert. --- esphome/components/api/api_frame_helper.h | 5 +++-- esphome/components/api/api_frame_helper_noise.cpp | 10 ++++------ esphome/components/api/api_frame_helper_plaintext.cpp | 10 ++++------ 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index a582307738..768cffdebb 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -161,10 +161,11 @@ class APIFrameHelper { this->nodelay_counter_ = 0; } } - // Write a single protobuf message - the hot path (87-100% of all writes) + // Write a single protobuf message - the hot path (87-100% of all writes). + // Caller must ensure state is DATA before calling. virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; // Write multiple protobuf messages in a single batched operation. - // messages must not be empty — caller is responsible for checking. + // Caller must ensure state is DATA and messages is not empty. // messages contains (message_type, offset, length) for each message in the buffer. // The buffer contains all messages with appropriate padding before each. virtual APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) = 0; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index d6b4a72cae..94f4780fb1 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -489,9 +489,9 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, const M } APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - APIError aerr = this->check_data_state_(); - if (aerr != APIError::OK) - return aerr; +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); +#endif // Resize buffer to include footer space for Noise MAC if (frame_footer_size_) @@ -508,10 +508,8 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff } APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - APIError aerr = this->check_data_state_(); - if (aerr != APIError::OK) - return aerr; #ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); assert(!messages.empty()); #endif diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index a21d177b7f..9144a0234b 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -285,9 +285,9 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t *write_plaintext_header(uint8_t *buf } APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - APIError aerr = this->check_data_state_(); - if (aerr != APIError::OK) - return aerr; +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); +#endif MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - frame_header_padding_)}; uint8_t *buffer_data = buffer.get_buffer()->data(); @@ -299,10 +299,8 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - APIError aerr = this->check_data_state_(); - if (aerr != APIError::OK) - return aerr; #ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); assert(!messages.empty()); #endif From 1ede6d2e60fb13e52c783d5dc46c1e3cde737caa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:53:06 -1000 Subject: [PATCH 25/37] [api] Fix missing APIError aerr declaration after check_data_state_ removal --- esphome/components/api/api_frame_helper_noise.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 94f4780fb1..f07f863549 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -501,7 +501,7 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; uint8_t *buf_start = buffer.get_buffer()->data(); struct iovec iov; - aerr = this->encrypt_noise_message_(buf_start, msg, iov); + APIError aerr = this->encrypt_noise_message_(buf_start, msg, iov); if (aerr != APIError::OK) return aerr; return this->write_raw_fast_(iov.iov_base, static_cast(iov.iov_len)); @@ -520,7 +520,7 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s for (const auto &msg : messages) { uint8_t *buf_start = buffer_data + msg.offset; struct iovec iov; - aerr = this->encrypt_noise_message_(buf_start, msg, iov); + APIError aerr = this->encrypt_noise_message_(buf_start, msg, iov); if (aerr != APIError::OK) return aerr; iovs.push_back(iov); From 8a802ca666b608426644bfa2fc4caf7e0ab72577 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:54:07 -1000 Subject: [PATCH 26/37] [benchmark] Add BLE raw advertisement proto encode benchmarks (#15289) --- script/cpp_benchmark.py | 5 ++ tests/benchmarks/components/api/__init__.py | 14 +++ .../components/api/bench_proto_encode.cpp | 89 +++++++++++++++++++ .../bluetooth_proxy/bluetooth_proxy.h | 38 ++++++++ 4 files changed, 146 insertions(+) create mode 100644 tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index a54d3752df..92faa05819 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -21,6 +21,10 @@ BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components" # Path to /tests/benchmarks/core (always included, not a component) CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" +# Stub headers for ESP32-only components (e.g. bluetooth_proxy) that +# allow benchmarks to compile on the host platform. +STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" + PLATFORMIO_OPTIONS = { "build_unflags": [ "-Os", # remove default size-opt @@ -29,6 +33,7 @@ PLATFORMIO_OPTIONS = { "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) "-g", # debug symbols for profiling "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() + f"-I{STUBS_DIR}", # stub headers for ESP32-only components ], # Use deep+ LDF mode to ensure PlatformIO detects the benchmark # library dependency from nested includes. diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0687c3f87f..eb86492964 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -1,3 +1,4 @@ +import esphome.codegen as cg from tests.testing_helpers import ComponentManifestOverride @@ -5,3 +6,16 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # api must run its to_code to define USE_API, USE_API_PLAINTEXT, # and add the noise-c library dependency. manifest.enable_codegen() + + original_to_code = manifest.to_code + + async def to_code(config): + await original_to_code(config) + # Enable BLE proto message types for benchmarks. The real + # bluetooth_proxy component is ESP32-only; a lightweight stub + # header in tests/benchmarks/stubs/ satisfies the include. + cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + + manifest.to_code = to_code diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp index 656c1e17db..1e2efcd281 100644 --- a/tests/benchmarks/components/api/bench_proto_encode.cpp +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -295,4 +295,93 @@ static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) { } BENCHMARK(CalcAndEncode_DeviceInfoResponse_Fresh); +// --- BluetoothLERawAdvertisementsResponse (12 adverts, highest-volume BLE message) --- + +#ifdef USE_BLUETOOTH_PROXY + +static BluetoothLERawAdvertisementsResponse make_ble_raw_advs_12() { + static const uint8_t fake_adv_data[] = { + 0x02, 0x01, 0x06, 0x03, 0x03, 0x9F, 0xFE, 0x17, 0x16, 0x9F, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + BluetoothLERawAdvertisementsResponse msg; + msg.advertisements_len = 12; + for (int i = 0; i < 12; i++) { + auto &adv = msg.advertisements[i]; + adv.address = 0xAABBCCDD0000ULL + i; + adv.rssi = -60 - i; + adv.address_type = 1; + memcpy(adv.data, fake_adv_data, sizeof(fake_adv_data)); + adv.data_len = sizeof(fake_adv_data); + } + return msg; +} + +static void CalculateSize_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_BLERawAdvs12); + +static void Encode_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + APIBuffer buffer; + uint32_t total_size = msg.calculate_size(); + buffer.resize(total_size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_BLERawAdvs12); + +static void CalcAndEncode_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_BLERawAdvs12); + +static void CalcAndEncode_BLERawAdvs12_Fresh(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + benchmark::DoNotOptimize(buffer.data()); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_BLERawAdvs12_Fresh); + +#endif // USE_BLUETOOTH_PROXY + } // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h new file mode 100644 index 0000000000..0934e0d4ed --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -0,0 +1,38 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp needs when USE_BLUETOOTH_PROXY is defined, +// without pulling in ESP32 BLE dependencies. +#pragma once + +#include "esphome/components/api/api_pb2.h" + +namespace esphome { +namespace api { +class APIConnection; +} // namespace api + +namespace bluetooth_proxy { + +class BluetoothProxy { + public: + api::APIConnection *get_api_connection() const { return nullptr; } + void subscribe_api_connection(api::APIConnection *conn, uint32_t flags) {} + void unsubscribe_api_connection(api::APIConnection *conn) {} + void bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {} + void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {} + void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {} + void bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {} + void bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {} + void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {} + void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {} + void send_connections_free(api::APIConnection *conn) {} + void bluetooth_scanner_set_mode(bool active) {} + void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {} + uint32_t get_feature_flags() const { return 0; } + void get_bluetooth_mac_address_pretty(char *buf) const { buf[0] = '\0'; } +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern BluetoothProxy *global_bluetooth_proxy; + +} // namespace bluetooth_proxy +} // namespace esphome From 1f3fd60d294eed3162328520077119541666101f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:55:39 -1000 Subject: [PATCH 27/37] [version] Remove duplicate build_info_data.h include (#15288) --- esphome/components/version/version_text_sensor.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 8aec98d2da..34c7aae6bc 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,6 +1,5 @@ #include "version_text_sensor.h" #include "esphome/core/application.h" -#include "esphome/core/build_info_data.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -36,7 +35,9 @@ void VersionTextSensor::setup() { if (!this->hide_timestamp_) { size_t len = strlen(version_str); ESPHOME_strncat_P(version_str, BUILT_STR, sizeof(version_str) - len - 1); - ESPHOME_strncat_P(version_str, ESPHOME_BUILD_TIME_STR, sizeof(version_str) - strlen(version_str) - 1); + char build_time_buf[Application::BUILD_TIME_STR_SIZE]; + App.get_build_time_string(build_time_buf); + strncat(version_str, build_time_buf, sizeof(version_str) - strlen(version_str) - 1); } // The closing parenthesis is part of the config-hash suffix and must From 2a97eca00b82e8ef10c5203f9b1865ac6c5cc6de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:55:52 -1000 Subject: [PATCH 28/37] [sensor] Use std::array in ValueList/FilterOut/ThrottleWithPriority filters (#15265) --- esphome/components/sensor/__init__.py | 6 ++-- esphome/components/sensor/filter.cpp | 38 ++++++-------------- esphome/components/sensor/filter.h | 51 +++++++++++++++++++-------- esphome/core/helpers.h | 18 ++++++++++ 4 files changed, 70 insertions(+), 43 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 19d03a0afc..5569567de1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -381,7 +381,7 @@ async def filter_out_filter_to_code(config, filter_id): if not isinstance(config, list): config = [config] template_ = [await cg.templatable(x, [], float) for x in config] - return cg.new_Pvariable(filter_id, template_) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) QUANTILE_SCHEMA = cv.All( @@ -650,7 +650,9 @@ async def throttle_with_priority_filter_to_code(config, filter_id): if not isinstance(config[CONF_VALUE], list): config[CONF_VALUE] = [config[CONF_VALUE]] template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]] - return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) + return cg.new_Pvariable( + filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ + ) HEARTBEAT_SCHEMA = cv.Schema( diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index d995ee4111..66a9e9555b 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -222,16 +222,14 @@ MultiplyFilter::MultiplyFilter(TemplatableValue multiplier) : multiplier_ optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } -// ValueListFilter (base class) -ValueListFilter::ValueListFilter(std::initializer_list> values) : values_(values) {} - -bool ValueListFilter::value_matches_any_(float sensor_value) { - int8_t accuracy = this->parent_->get_accuracy_decimals(); +// ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) +bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue *values, size_t count) { + int8_t accuracy = parent->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); - for (auto &filter_value : this->values_) { - float fv = filter_value.value(); + for (size_t i = 0; i < count; i++) { + float fv = values[i].value(); // Handle NaN comparison if (std::isnan(fv)) { @@ -248,16 +246,6 @@ bool ValueListFilter::value_matches_any_(float sensor_value) { return false; } -// FilterOutValueFilter -FilterOutValueFilter::FilterOutValueFilter(std::initializer_list> values_to_filter_out) - : ValueListFilter(values_to_filter_out) {} - -optional FilterOutValueFilter::new_value(float value) { - if (this->value_matches_any_(value)) - return {}; // Filter out - return value; // Pass through -} - // ThrottleFilter ThrottleFilter::ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {} optional ThrottleFilter::new_value(float value) { @@ -269,17 +257,13 @@ optional ThrottleFilter::new_value(float value) { return {}; } -// ThrottleWithPriorityFilter -ThrottleWithPriorityFilter::ThrottleWithPriorityFilter( - uint32_t min_time_between_inputs, std::initializer_list> prioritized_values) - : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - -optional ThrottleWithPriorityFilter::new_value(float value) { +// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) +optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue *values, + size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { const uint32_t now = App.get_loop_component_start_time(); - // Allow value through if: no previous input, time expired, or is prioritized - if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_ || - this->value_matches_any_(value)) { - this->last_input_ = now; + if (last_input == 0 || now - last_input >= min_time_between_inputs || + value_list_matches_any(parent, value, values, count)) { + last_input = now; return value; } return {}; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 6a76bd373e..80fa14742c 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -3,6 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_SENSOR_FILTER +#include #include #include #include "esphome/core/automation.h" @@ -328,28 +329,42 @@ class MultiplyFilter : public Filter { TemplatableValue multiplier_; }; -/** Base class for filters that compare sensor values against a list of configured values. +/// Non-template helper for value matching (implementation in filter.cpp) +bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue *values, size_t count); + +/** Base class for filters that compare sensor values against a fixed list of configured values. * - * This base class provides common functionality for filters that need to check if a sensor - * value matches any value in a configured list, with proper handling of NaN values and - * accuracy-based rounding for comparisons. + * Templated on N (the number of values) so the list is stored inline in a std::array, + * avoiding heap allocation and the overhead of FixedVector. + * + * @tparam N Number of values in the filter list, set by code generation to match + * the exact number of values configured in YAML. */ -class ValueListFilter : public Filter { +template class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values); + explicit ValueListFilter(std::initializer_list> values) { + init_array_from(this->values_, values); + } /// Check if sensor value matches any configured value (with accuracy rounding) - bool value_matches_any_(float sensor_value); + bool value_matches_any_(float sensor_value) { + return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); + } - FixedVector> values_; + std::array, N> values_{}; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. -class FilterOutValueFilter : public ValueListFilter { +template class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out); + explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) + : ValueListFilter(values_to_filter_out) {} - optional new_value(float value) override; + optional new_value(float value) override { + if (this->value_matches_any_(value)) + return {}; // Filter out + return value; // Pass through + } }; class ThrottleFilter : public Filter { @@ -363,13 +378,21 @@ class ThrottleFilter : public Filter { uint32_t min_time_between_inputs_; }; +/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) +optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue *values, + size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); + /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. -class ThrottleWithPriorityFilter : public ValueListFilter { +template class ThrottleWithPriorityFilter : public ValueListFilter { public: explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values); + std::initializer_list> prioritized_values) + : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - optional new_value(float value) override; + optional new_value(float value) override { + return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_, + this->min_time_between_inputs_); + } protected: uint32_t last_input_{0}; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 82c6b3833c..913614f564 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -497,6 +498,23 @@ template::max()> index_type capacity_{0}; }; +/// Initialize a std::array from an initializer_list. Uses memcpy for trivially copyable types (optimal codegen), +/// falls back to element-wise copy for non-trivially copyable types (e.g. TemplatableValue). +/// N is set by code generation; assert catches mismatches in debug/integration tests. +template inline void init_array_from(std::array &dest, std::initializer_list src) { +#ifdef ESPHOME_DEBUG + assert(src.size() == N); +#endif + if constexpr (std::is_trivially_copyable_v) { + __builtin_memcpy(dest.data(), src.begin(), N * sizeof(T)); + } else { + size_t i = 0; + for (const auto &v : src) { + dest[i++] = v; + } + } +} + /// Fixed-capacity vector - allocates once at runtime, never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time From 5da3253f4b67128b991c7631b9730488072f9a6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:57:52 -1000 Subject: [PATCH 29/37] [esp8266] Add enable_scanf_float option (#15284) --- esphome/components/esp8266/__init__.py | 62 +++++++++++++++---- .../components/esp8266/test.esp8266-ard.yaml | 3 + tests/unit_tests/components/test_esp8266.py | 62 +++++++++++++++++++ 3 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/components/test_esp8266.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 16043b6d69..2081145096 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +import re import esphome.codegen as cg import esphome.config_validation as cv @@ -18,8 +19,9 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority from esphome.helpers import copy_file_if_changed +from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS from .const import ( @@ -40,12 +42,42 @@ from .const import ( ) from .gpio import PinInitialState, add_pin_initial_states_array +CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float" +# Heuristically matches scanf/sscanf calls with float format specifiers. +# Standard scanf float conversions: %f %F %e %E %g %G %a %A +# With optional modifiers: %*f (suppression), %8f (width), %lf %Lf (length) +# Also matches non-standard patterns like %.2f as a heuristic — these are +# invalid in scanf but users may write them by analogy with printf. +# Uses [^;]*? to stay within a single statement, preventing false positives +# from e.g. sscanf(buf, "%d", &x); printf("%f", val); +_SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]") + CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True +def lambdas_use_scanf_float(config: ConfigType) -> bool: + """Check if any lambda in the config uses scanf with a float format specifier. + + Comments are stripped before matching to avoid false positives from + commented-out code. The cost of a false positive is only ~8KB flash. + """ + stack: list = [config] + while stack: + obj = stack.pop() + if isinstance(obj, Lambda): + src = obj.comment_remover(obj.value) + if _SCANF_FLOAT_RE.search(src): + return True + elif isinstance(obj, dict): + stack.extend(obj.values()) + elif isinstance(obj, list): + stack.extend(obj) + return False + + def set_core_data(config): CORE.data[KEY_ESP8266] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 @@ -181,6 +213,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SERIAL): cv.boolean, cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, + cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, } ), set_core_data, @@ -201,16 +234,23 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option( - "extra_scripts", - [ - "pre:testing_mode.py", - "pre:exclude_updater.py", - "pre:exclude_waveform.py", - "pre:remove_float_scanf.py", - "post:post_build.py", - ], - ) + enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) + if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): + enable_scanf_float = True + _LOGGER.warning( + "Lambda uses scanf with a float format specifier; " + "enabling scanf float support (~8KB flash)" + ) + + extra_scripts = [ + "pre:testing_mode.py", + "pre:exclude_updater.py", + "pre:exclude_waveform.py", + ] + if not enable_scanf_float: + extra_scripts.append("pre:remove_float_scanf.py") + extra_scripts.append("post:post_build.py") + cg.add_platformio_option("extra_scripts", extra_scripts) conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") diff --git a/tests/components/esp8266/test.esp8266-ard.yaml b/tests/components/esp8266/test.esp8266-ard.yaml index c77218f7a3..ba70c1a6a4 100644 --- a/tests/components/esp8266/test.esp8266-ard.yaml +++ b/tests/components/esp8266/test.esp8266-ard.yaml @@ -14,3 +14,6 @@ esphome: assert(x == 95); x = clamp_at_most(x, 40); assert(x == 40); + - lambda: |- + float value = 0.0f; + sscanf("3.14", "%f", &value); diff --git a/tests/unit_tests/components/test_esp8266.py b/tests/unit_tests/components/test_esp8266.py new file mode 100644 index 0000000000..318fd2d889 --- /dev/null +++ b/tests/unit_tests/components/test_esp8266.py @@ -0,0 +1,62 @@ +"""Tests for ESP8266 component.""" + +import pytest + +from esphome.components.esp8266 import lambdas_use_scanf_float +from esphome.core import Lambda +from esphome.types import ConfigType + + +@pytest.mark.parametrize( + ("src", "expected"), + [ + # Basic float formats + ('sscanf(buf, "%f", &v)', True), + ('sscanf(buf, "%F", &v)', True), + ('sscanf(buf, "%e", &v)', True), + ('sscanf(buf, "%E", &v)', True), + ('sscanf(buf, "%g", &v)', True), + ('sscanf(buf, "%G", &v)', True), + ('sscanf(buf, "%a", &v)', True), + ('sscanf(buf, "%A", &v)', True), + # With modifiers + ('sscanf(buf, "%lf", &v)', True), + ('sscanf(buf, "%Lf", &v)', True), + ('sscanf(buf, "%8lf", &v)', True), + ('sscanf(buf, "%*f")', True), + ('sscanf(buf, "%.2f", &v)', True), + # Mixed formats + ('sscanf(buf, "%d,%f", &a, &b)', True), + # fscanf and std::sscanf + ('fscanf(fp, "%f", &v)', True), + ('std::sscanf(buf, "%f", &v)', True), + # Multi-line + ('sscanf(buf,\n"%f", &v)', True), + # No float format + ('sscanf(buf, "%d", &v)', False), + ('sscanf(buf, "%s", s)', False), + # printf not scanf + ('printf("%f", val)', False), + # %f in a different statement after scanf + ('sscanf(buf, "%d", &x); printf("%f", val);', False), + # scanf %f in comment only + ('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False), + ('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False), + ], +) +def test_lambdas_use_scanf_float(src: str, expected: bool) -> None: + """Test scanf float detection in lambda source.""" + config: ConfigType = {"test": [Lambda(src)]} + assert lambdas_use_scanf_float(config) is expected + + +def test_lambdas_use_scanf_float_no_lambdas() -> None: + """Test with config containing no lambdas.""" + config: ConfigType = {"key": "value", "list": [1, 2]} + assert lambdas_use_scanf_float(config) is False + + +def test_lambdas_use_scanf_float_nested() -> None: + """Test detection in deeply nested config.""" + config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} + assert lambdas_use_scanf_float(config) is True From 584807b03900ea488f46fde1cd3a1cd13234bed6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:58:03 -1000 Subject: [PATCH 30/37] [ld2410] Fix flaky integration test race condition (#15299) --- tests/integration/test_uart_mock_ld2410.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_uart_mock_ld2410.py b/tests/integration/test_uart_mock_ld2410.py index ce0e1bb7ec..88d6f2cbac 100644 --- a/tests/integration/test_uart_mock_ld2410.py +++ b/tests/integration/test_uart_mock_ld2410.py @@ -73,9 +73,16 @@ async def test_uart_mock_ld2410( ], ) - # Signal when we see recovery frame values + # Signal when we see ALL recovery frame values to avoid race where some + # arrive after the waiter fires but before we index into the lists recovery_received = collector.add_waiter( - lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"] + lambda: ( + pytest.approx(50.0) in collector.sensor_states["moving_distance"] + and pytest.approx(75.0) in collector.sensor_states["still_distance"] + and pytest.approx(100.0) in collector.sensor_states["moving_energy"] + and pytest.approx(80.0) in collector.sensor_states["still_energy"] + and pytest.approx(127.0) in collector.sensor_states["detection_distance"] + ) ) async with ( From 52c297206a2841b58a5ccd9a4eed8b455c057492 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:01:06 -1000 Subject: [PATCH 31/37] [api] Restore single-buffer write_raw_ overload to keep inline failure path small Moving iovec construction into write_raw_fast_ caused an 8% regression by enlarging the inlined failure path. The 33-byte out-of-line wrapper keeps the hot inline path minimal. --- esphome/components/api/api_frame_helper.cpp | 11 +++++++++-- esphome/components/api/api_frame_helper.h | 6 +++--- esphome/components/api/api_frame_helper_noise.cpp | 3 +-- esphome/components/api/api_frame_helper_plaintext.cpp | 6 ++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index bac5ec1c14..248e063045 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -112,8 +112,15 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { return APIError::OK; } -// Slow path: handles partial writes, errors, and overflow buffering. -// Called when the inline fast path in the header couldn't complete the write. +// Single-buffer write path: wraps in iovec and delegates. +APIError APIFrameHelper::write_raw_(const void *data, uint16_t len, ssize_t sent) { + struct iovec iov = {const_cast(data), len}; + return this->write_raw_(&iov, 1, len, sent); +} + +// Handles partial writes, errors, and overflow buffering. +// Called when the inline fast path in the header couldn't complete the write, +// or directly from cold paths (handshake, error handling). // sent == -1 means either the fast path write returned -1, or there was overflow backlog. APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { #ifdef HELPER_LOG_PACKETS diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 768cffdebb..cba9d92c74 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -202,8 +202,7 @@ class APIFrameHelper { if (sent == static_cast(len)) [[likely]] return APIError::OK; } - struct iovec iov = {const_cast(data), len}; - return this->write_raw_(&iov, 1, len, sent); + return this->write_raw_(data, len, sent); } inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { ssize_t sent = -1; @@ -215,7 +214,8 @@ class APIFrameHelper { return this->write_raw_(iov, iovcnt, total_write_len, sent); } - // Slow path (out-of-line): handle partial writes, errors, overflow buffering + // Out-of-line write paths: handle partial writes, errors, overflow buffering + APIError write_raw_(const void *data, uint16_t len, ssize_t sent = -1); APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent = -1); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index f07f863549..b1499c7557 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -537,8 +537,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[2] = (uint8_t) len; if (len == 0) { - struct iovec iov = {header, 3}; - return this->write_raw_(&iov, 1, 3); + return this->write_raw_(header, 3); } struct iovec iov[2]; iov[0].iov_base = header; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 9144a0234b..f561acfff9 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -219,13 +219,11 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - struct iovec iov = {msg, INDICATOR_MSG_SIZE}; - this->write_raw_(&iov, 1, INDICATOR_MSG_SIZE); + this->write_raw_(msg, INDICATOR_MSG_SIZE); #else static const char MSG[] = "\x00" "Bad indicator byte"; - struct iovec iov = {const_cast(MSG), INDICATOR_MSG_SIZE}; - this->write_raw_(&iov, 1, INDICATOR_MSG_SIZE); + this->write_raw_(MSG, INDICATOR_MSG_SIZE); #endif } return aerr; From c2b8ea33610be67f2ea7cf043e2276552d1c558a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:02:29 -1000 Subject: [PATCH 32/37] [web_server_base] Reduce sizeof(WebServerBase) by 4 bytes (#15251) --- esphome/components/web_server_base/web_server_base.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 48e13ad71e..2aa3ae215c 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -135,7 +135,7 @@ class WebServerBase { uint16_t get_port() const { return port_; } protected: - int initialized_{0}; + uint8_t initialized_{0}; uint16_t port_{80}; AsyncWebServer *server_{nullptr}; std::vector handlers_; From 38fa8925da52981021c334e6f88ba4e521208fc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:02:47 -1000 Subject: [PATCH 33/37] [ai] Add automation, callback manager, and test grouping docs (#15243) --- .ai/instructions.md | 174 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index a7e08f9c4d..86f554e9ce 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -239,6 +239,123 @@ This document provides essential context for AI models interacting with this pro var = await switch.new_switch(config) ``` +* **Automations (Triggers, Actions, Conditions):** + + Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). + + * **Triggers -- Callback method (preferred):** + + Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema. + + **Python:** + ```python + from esphome import automation + + CONFIG_SCHEMA = cv.Schema({ + cv.GenerateID(): cv.declare_id(MyComponent), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + }).extend(cv.COMPONENT_SCHEMA) + + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + for conf in config.get(CONF_ON_STATE, []): + await automation.build_callback_automation( + var, "add_on_state_callback", [(bool, "x")], conf + ) + ``` + + `build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder`). + + For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`: + ```python + for conf_key, forwarder in ( + (CONF_ON_PRESS, automation.TriggerOnTrueForwarder), + (CONF_ON_RELEASE, automation.TriggerOnFalseForwarder), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, "add_on_state_callback", [], conf, forwarder=forwarder + ) + ``` + + **C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation): + ```cpp + class MyComponent : public Component { + public: + // Must be a template -- accepts both std::function and pointer-sized forwarder structs + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + protected: + // Use CallbackManager when callbacks are always registered (e.g. core components) + CallbackManager state_callback_; + // Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes + // (nullptr vs empty std::vector) per instance when no callbacks are added + // LazyCallbackManager state_callback_; + }; + ``` + + * **Triggers -- Trigger class method:** + + Use `build_automation()` with a `Trigger` subclass only when the forwarder needs **mutable state beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic). + + **Python:** + ```python + TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template()) + + CONFIG_SCHEMA = cv.Schema({ + cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)} + ), + }) + + async def to_code(config): + for conf in config.get(CONF_ON_TURN_ON, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + ``` + + **C++:** + ```cpp + class TurnOnTrigger : public Trigger<> { + public: + explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} { + parent->add_on_state_callback([this](bool state) { + if (state && !this->last_on_) + this->trigger(); + this->last_on_ = state; + }); + } + protected: + bool last_on_; + }; + ``` + + * **Actions:** + ```cpp + template class MyAction : public Action { + public: + explicit MyAction(MyComponent *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->do_something(); } + protected: + MyComponent *parent_; + }; + ``` + Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + + * **Conditions:** + ```cpp + template class MyCondition : public Condition { + public: + explicit MyCondition(MyComponent *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_active(); } + protected: + MyComponent *parent_; + }; + ``` + Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. + * **Configuration Validation:** * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. @@ -274,10 +391,39 @@ This document provides essential context for AI models interacting with this pro * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ - ├── test_build_components/ # Base test configurations - └── components/[component]/ # Component-specific tests + ├── test_build_components/ + │ └── common/ # Shared bus packages (uart, i2c, spi, etc.) + │ ├── uart/ # UART at default baud rate + │ ├── uart_115200/ # UART at 115200 baud + │ ├── i2c/ # I2C bus + │ └── spi/ # SPI bus + └── components/[component]/ + ├── common.yaml # Component-only config (no bus definitions) + ├── test.esp32-idf.yaml + ├── test.esp8266-ard.yaml + └── test.rp2040-ard.yaml ``` Run them using `script/test_build_components`. Use `-c ` to test specific components and `-t ` for specific platforms. + + * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`: + ```yaml + # test.esp32-idf.yaml — use packages for buses + packages: + uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + + <<: !include common.yaml + ``` + ```yaml + # common.yaml — component config only, NO bus definitions + my_component: + id: my_instance + + sensor: + - platform: my_component + name: My Sensor + ``` + Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time. + * **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use: ```bash ./script/test_component_grouping.py -e config --all @@ -417,6 +563,30 @@ This document provides essential context for AI models interacting with this pro Note: Avoiding heap allocation after `setup()` is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from `std::vector` to `StaticVector`). + **Callback Managers:** + + ESPHome provides two callback manager types in `esphome/core/helpers.h` for the observer pattern. Both support `std::function`, lambdas, and lightweight forwarder structs via their templatized `add()` method. + + | Type | Idle overhead (32-bit) | When to use | + |------|----------------------|-------------| + | `CallbackManager` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered | + | `LazyCallbackManager` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) | + + `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first callback is added. Prefer it for entity-level callbacks where most instances have no subscribers. + + **Important:** Registration methods that add to a callback manager **must always be templatized** to accept both `std::function` and pointer-sized forwarder structs (used by `build_callback_automation`). Never use `std::function` in the method signature: + ```cpp + // Bad -- forces heap allocation for forwarder structs + void add_on_state_callback(std::function &&callback) { + this->state_callback_.add(std::move(callback)); + } + + // Good -- accepts any callable without forcing std::function wrapping + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + ``` + * **State Management:** Use `CORE.data` for component state that needs to persist during configuration generation. Avoid module-level mutable globals. **Bad Pattern (Module-Level Globals):** From a9aaf29d837b9228437f3954c1b2fb2396035ce6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:09:21 -1000 Subject: [PATCH 34/37] [core] Shrink Component from 12 to 8 bytes per instance (#15103) --- esphome/core/component.cpp | 24 ++++-- esphome/core/component.h | 37 +++++--- esphome/cpp_helpers.py | 123 ++++++++++++++++++++++++++- tests/unit_tests/test_cpp_helpers.py | 72 +++++++++++++++- 4 files changed, 230 insertions(+), 26 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2ad82e1172..00a36fce3d 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -84,6 +84,8 @@ void store_component_error_message(const Component *component, const char *messa static constexpr uint16_t WARN_IF_BLOCKING_INCREMENT_MS = 10U; ///< How long the blocking time must be larger to warn again +// Threshold in ms (computed from centiseconds constant in component.h) +static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; float Component::get_setup_priority() const { return setup_priority::DATA; } @@ -268,15 +270,18 @@ void Component::call() { break; } } +const LogString *Component::get_component_log_str() const { + return component_source_lookup(this->component_source_index_); +} bool Component::should_warn_of_blocking(uint32_t blocking_time) { - if (blocking_time > this->warn_if_blocking_over_) { - // Prevent overflow when adding increment - if we're about to overflow, just max out - if (blocking_time + WARN_IF_BLOCKING_INCREMENT_MS < blocking_time || - blocking_time + WARN_IF_BLOCKING_INCREMENT_MS > std::numeric_limits::max()) { - this->warn_if_blocking_over_ = std::numeric_limits::max(); - } else { - this->warn_if_blocking_over_ = static_cast(blocking_time + WARN_IF_BLOCKING_INCREMENT_MS); - } + // Convert centisecond threshold to milliseconds for comparison + uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + if (blocking_time > threshold_ms) { + // Set new threshold: blocking_time + increment, converted back to centiseconds + uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; + uint32_t new_cs = new_threshold_ms / 10U; + // Saturate at uint8_t max (255 = 2550ms) + this->warn_if_blocking_over_ = static_cast(new_cs > 255U ? 255U : new_cs); return true; } return false; @@ -537,4 +542,7 @@ void clear_setup_priority_overrides() { } #endif +// Weak default for component_source_lookup - overridden by generated code +__attribute__((weak)) const LogString *component_source_lookup(uint8_t) { return LOG_STR(""); } + } // namespace esphome diff --git a/esphome/core/component.h b/esphome/core/component.h index d08b1abfcd..c390a205f0 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -11,6 +11,10 @@ #include "esphome/core/log.h" #include "esphome/core/optional.h" +// Forward declarations for friend access from codegen-generated setup() +void setup(); // NOLINT(readability-redundant-declaration) - may be declared in Arduino.h +void original_setup(); // NOLINT(readability-redundant-declaration) + namespace esphome { // Forward declaration for LogString @@ -79,11 +83,14 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08; inline constexpr uint8_t STATUS_LED_ERROR = 0x10; // Component loop override flag uses bit 5 (set at registration time) inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; - // Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; -inline constexpr uint16_t WARN_IF_BLOCKING_OVER_MS = 50U; +inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds (1cs = 10ms) + +/// Lookup component source name by index (1-based). Generated by Python codegen. +/// Weak default returns "" so builds without codegen still link. +const LogString *component_source_lookup(uint8_t index); class Component { public: @@ -275,23 +282,25 @@ class Component { bool has_overridden_loop() const { return (this->component_state_ & COMPONENT_HAS_LOOP) != 0; } - /** Set where this component was loaded from for some debug messages. - * - * This is set by the ESPHome core, and should not be called manually. - */ - void set_component_source(const LogString *source) { component_source_ = source; } /** Get the integration where this component was declared as a LogString for logging. * * Returns LOG_STR("") if source not set */ - const LogString *get_component_log_str() const { - return this->component_source_ == nullptr ? LOG_STR("") : this->component_source_; - } + const LogString *get_component_log_str() const; bool should_warn_of_blocking(uint32_t blocking_time); protected: friend class Application; + friend void ::setup(); + friend void ::original_setup(); + + /** Set where this component was loaded from for some debug messages. + * + * This is set by the ESPHome core during setup, and should not be called manually. + * @param index 1-based index into the component source lookup table (0 = not set) + */ + void set_component_source_(uint8_t index) { this->component_source_index_ = index; } virtual void call_setup(); void call_dump_config_(); @@ -509,9 +518,9 @@ class Component { void status_clear_warning_slow_path_(); void status_clear_error_slow_path_(); - // Ordered for optimal packing on 32-bit systems - const LogString *component_source_{nullptr}; - uint16_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; ///< Warn if blocked for this many ms (max 65.5s) + // Ordered for optimal packing on 32-bit systems (8 bytes total with vtable) + uint8_t component_source_index_{0}; ///< Index into component source PROGMEM lookup table (0 = not set) + uint8_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_CS}; ///< Warn threshold in centiseconds (max 2550ms) /// State of this component - each bit has a purpose: /// Bits 0-2: Component state (0x00=CONSTRUCTION, 0x01=SETUP, 0x02=LOOP, 0x03=FAILED, 0x04=LOOP_DONE) /// Bit 3: STATUS_LED_WARNING @@ -588,6 +597,8 @@ class WarnIfComponentBlockingGuard { this->record_runtime_stats_(); #endif #ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { warn_blocking(this->component_, blocking_time); } diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 8f8c693140..e7ff2965c8 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import logging from esphome.const import ( @@ -7,15 +8,130 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, ID, coroutine +from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import LogStringLiteral, add, add_define, get_variable +from esphome.cpp_generator import ( + RawStatement, + add, + add_define, + add_global, + get_variable, +) from esphome.cpp_types import App +from esphome.helpers import cpp_string_escape from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry _LOGGER = logging.getLogger(__name__) +_COMPONENT_SOURCE_DOMAIN = "component_source_pool" + +# Maximum unique component source names (8-bit index, 0 = not set) +_MAX_COMPONENT_SOURCES = 0xFF # 255 + + +@dataclass +class ComponentSourcePool: + """Pool of component source names for PROGMEM lookup table. + + Source names are registered during to_code() and assigned 1-based indices. + Index 0 means "not set" (returns LOG_STR("")). At render time, + the pool generates a C++ PROGMEM table + lookup function. + """ + + sources: dict[str, int] = field(default_factory=dict) + table_registered: bool = False + + +def _get_source_pool() -> ComponentSourcePool: + """Get or create the component source pool from CORE.data.""" + if _COMPONENT_SOURCE_DOMAIN not in CORE.data: + CORE.data[_COMPONENT_SOURCE_DOMAIN] = ComponentSourcePool() + return CORE.data[_COMPONENT_SOURCE_DOMAIN] + + +def _ensure_source_table_registered() -> None: + """Schedule the table generation job (once).""" + pool = _get_source_pool() + if pool.table_registered: + return + pool.table_registered = True + CORE.add_job(_generate_component_source_table) + + +def register_component_source(name: str) -> int: + """Register a component source name and return its 1-based index. + + Deduplicates: multiple components from the same source share one index. + """ + if not name: + return 0 + pool = _get_source_pool() + if name in pool.sources: + return pool.sources[name] + idx = len(pool.sources) + 1 + if idx > _MAX_COMPONENT_SOURCES: + _LOGGER.warning( + "Too many unique component source names (max %d), '%s' will show as ''", + _MAX_COMPONENT_SOURCES, + name, + ) + return 0 + pool.sources[name] = idx + _ensure_source_table_registered() + return idx + + +def _generate_source_table_code( + table_var: str, + lookup_fn: str, + strings: dict[str, int], +) -> str: + """Generate C++ PROGMEM table + LogString* lookup for component sources. + + Same pattern as entity_helpers._generate_category_code but returns + const LogString* instead of const char* (needed for LOG_STR_ARG). + """ + if not strings: + return "" + + sorted_strings = sorted(strings.items(), key=lambda x: x[1]) + count = len(sorted_strings) + + # Emit individual PROGMEM char arrays so string data lives in flash on ESP8266 + lines: list[str] = [] + var_names: list[str] = [] + for i, (s, _) in enumerate(sorted_strings): + var_name = f"{table_var}_STR_{i}" + var_names.append(var_name) + lines.append( + f"static const char {var_name}[] PROGMEM = {cpp_string_escape(s)};" + ) + + entries = ", ".join(var_names) + lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") + lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{") + lines.append(f' if (index == 0 || index > {count}) return LOG_STR("");') + lines.append(" return reinterpret_cast(") + lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));") + lines.append("}") + return "\n".join(lines) + "\n" + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _generate_component_source_table() -> None: + """Generate the component source lookup table as a FINAL-priority job. + + Runs after all component to_code() calls have registered their sources. + """ + pool = _get_source_pool() + if code := _generate_source_table_code( + "COMP_SRC_TABLE", "component_source_lookup", pool.sources + ): + add_global( + RawStatement(f"namespace esphome {{\n{code}}} // namespace esphome") + ) + async def gpio_pin_expression(conf): """Generate an expression for the given pin option. @@ -77,7 +193,8 @@ async def register_component(var, config): "Error while finding name of component, please report this", exc_info=e ) if name is not None: - add(var.set_component_source(LogStringLiteral(name))) + idx = register_component_source(name) + add(var.set_component_source_(idx)) add(App.register_component_(var)) diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 5b6eed156f..52424a7cb2 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,8 +1,10 @@ +import logging from unittest.mock import Mock import pytest from esphome import const, cpp_helpers as ch +from esphome.cpp_helpers import ComponentSourcePool, register_component_source @pytest.mark.asyncio @@ -23,7 +25,7 @@ async def test_register_component(monkeypatch): app_mock = Mock(register_component_=Mock(return_value=var)) monkeypatch.setattr(ch, "App", app_mock) - core_mock = Mock(component_ids=["foo.bar"]) + core_mock = Mock(component_ids=["foo.bar"], data={}) monkeypatch.setattr(ch, "CORE", core_mock) add_mock = Mock() @@ -59,7 +61,7 @@ async def test_register_component__with_setup_priority(monkeypatch): app_mock = Mock(register_component_=Mock(return_value=var)) monkeypatch.setattr(ch, "App", app_mock) - core_mock = Mock(component_ids=["foo.bar"]) + core_mock = Mock(component_ids=["foo.bar"], data={}) monkeypatch.setattr(ch, "CORE", core_mock) add_mock = Mock() @@ -78,3 +80,69 @@ async def test_register_component__with_setup_priority(monkeypatch): assert add_mock.call_count == 4 app_mock.register_component_.assert_called_with(var) assert core_mock.component_ids == [] + + +def test_register_component_source_empty_name(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ch, "CORE", Mock(data={})) + assert register_component_source("") == 0 + + +def test_register_component_source_deduplicates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(ch, "CORE", Mock(data={})) + idx1 = register_component_source("wifi") + idx2 = register_component_source("api") + idx3 = register_component_source("wifi") + assert idx1 == 1 + assert idx2 == 2 + assert idx3 == 1 # deduplicated + + +def test_generate_source_table_code_empty() -> None: + from esphome.cpp_helpers import _generate_source_table_code + + assert _generate_source_table_code("TBL", "lookup", {}) == "" + + +def test_generate_source_table_code_non_empty() -> None: + from esphome.cpp_helpers import _generate_source_table_code + + code = _generate_source_table_code("TBL", "lookup", {"wifi": 1, "api": 2}) + assert "PROGMEM" in code + assert "wifi" in code + assert "api" in code + assert "lookup" in code + assert "index == 0" in code + assert "progmem_read_ptr" in code + assert "index > 2" in code + + +@pytest.mark.asyncio +async def test_generate_component_source_table_empty_pool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that _generate_component_source_table does nothing with an empty pool.""" + from esphome.cpp_helpers import _generate_component_source_table + + monkeypatch.setattr(ch, "CORE", Mock(data={})) + add_global_mock = Mock() + monkeypatch.setattr(ch, "add_global", add_global_mock) + await _generate_component_source_table() + add_global_mock.assert_not_called() + + +def test_register_component_source_overflow_warns( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + # Pre-fill pool to max + pool = ComponentSourcePool( + sources={f"comp_{i}": i + 1 for i in range(0xFF)}, + table_registered=True, + ) + monkeypatch.setattr(ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool})) + with caplog.at_level(logging.WARNING): + idx = register_component_source("overflow_component") + assert idx == 0 + assert "Too many unique component source names" in caplog.text + assert "overflow_component" in caplog.text From 048636d6c32edf0c9bd489782a3d3b0591b4cf19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:13:36 -1000 Subject: [PATCH 35/37] [api] Use explicit names: write_raw_fast_, write_raw_buf_, write_raw_iov_ Avoids ambiguous overload resolution between const void* and const iovec* and makes each call site's intent clear. --- esphome/components/api/api_frame_helper.cpp | 6 +++--- esphome/components/api/api_frame_helper.h | 8 ++++---- esphome/components/api/api_frame_helper_noise.cpp | 4 ++-- esphome/components/api/api_frame_helper_plaintext.cpp | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 248e063045..f46693a4e8 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -113,16 +113,16 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { } // Single-buffer write path: wraps in iovec and delegates. -APIError APIFrameHelper::write_raw_(const void *data, uint16_t len, ssize_t sent) { +APIError APIFrameHelper::write_raw_buf_(const void *data, uint16_t len, ssize_t sent) { struct iovec iov = {const_cast(data), len}; - return this->write_raw_(&iov, 1, len, sent); + return this->write_raw_iov_(&iov, 1, len, sent); } // Handles partial writes, errors, and overflow buffering. // Called when the inline fast path in the header couldn't complete the write, // or directly from cold paths (handshake, error handling). // sent == -1 means either the fast path write returned -1, or there was overflow backlog. -APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { +APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { #ifdef HELPER_LOG_PACKETS for (int i = 0; i < iovcnt; i++) { LOG_PACKET_SENDING(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index cba9d92c74..cc5c6ee569 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -202,7 +202,7 @@ class APIFrameHelper { if (sent == static_cast(len)) [[likely]] return APIError::OK; } - return this->write_raw_(data, len, sent); + return this->write_raw_buf_(data, len, sent); } inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { ssize_t sent = -1; @@ -211,12 +211,12 @@ class APIFrameHelper { if (sent == static_cast(total_write_len)) [[likely]] return APIError::OK; } - return this->write_raw_(iov, iovcnt, total_write_len, sent); + return this->write_raw_iov_(iov, iovcnt, total_write_len, sent); } // Out-of-line write paths: handle partial writes, errors, overflow buffering - APIError write_raw_(const void *data, uint16_t len, ssize_t sent = -1); - APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent = -1); + APIError write_raw_buf_(const void *data, uint16_t len, ssize_t sent = -1); + APIError write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent = -1); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index b1499c7557..424f450fd9 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -537,7 +537,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[2] = (uint8_t) len; if (len == 0) { - return this->write_raw_(header, 3); + return this->write_raw_buf_(header, 3); } struct iovec iov[2]; iov[0].iov_base = header; @@ -545,7 +545,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_(iov, 2, 3 + len); + return this->write_raw_iov_(iov, 2, 3 + len); } /** Initiate the data structures for the handshake. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index f561acfff9..1d2ea5bcb8 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -219,11 +219,11 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - this->write_raw_(msg, INDICATOR_MSG_SIZE); + this->write_raw_buf_(msg, INDICATOR_MSG_SIZE); #else static const char MSG[] = "\x00" "Bad indicator byte"; - this->write_raw_(MSG, INDICATOR_MSG_SIZE); + this->write_raw_buf_(MSG, INDICATOR_MSG_SIZE); #endif } return aerr; From f57468109bf81e2425abfbdd512c3b190533a920 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:14:54 -1000 Subject: [PATCH 36/37] [api] Split write_raw_fast_ into write_raw_fast_buf_ and write_raw_fast_iov_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No overloads — every method has a unique name matching its data type: write_raw_fast_buf_ / write_raw_buf_ — single contiguous buffer write_raw_fast_iov_ / write_raw_iov_ — iovec array --- esphome/components/api/api_frame_helper.h | 5 +++-- esphome/components/api/api_frame_helper_noise.cpp | 4 ++-- esphome/components/api/api_frame_helper_plaintext.cpp | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index cc5c6ee569..94007eeb3d 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -195,7 +195,7 @@ class APIFrameHelper { // Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages) // These inline the fast path (overflow empty + full write) and tail-call the out-of-line // slow path only on failure/partial write. - inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_(const void *data, uint16_t len) { + inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_buf_(const void *data, uint16_t len) { ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { sent = this->socket_->write(data, len); @@ -204,7 +204,8 @@ class APIFrameHelper { } return this->write_raw_buf_(data, len, sent); } - inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { + inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_iov_(const struct iovec *iov, int iovcnt, + uint16_t total_write_len) { ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { sent = this->socket_->writev(iov, iovcnt); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 424f450fd9..0c0c069b53 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -504,7 +504,7 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff APIError aerr = this->encrypt_noise_message_(buf_start, msg, iov); if (aerr != APIError::OK) return aerr; - return this->write_raw_fast_(iov.iov_base, static_cast(iov.iov_len)); + return this->write_raw_fast_buf_(iov.iov_base, static_cast(iov.iov_len)); } APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { @@ -527,7 +527,7 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s total_write_len += iov.iov_len; } - return this->write_raw_fast_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_fast_iov_(iovs.data(), iovs.size(), total_write_len); } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 1d2ea5bcb8..6be20f89f3 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -292,7 +292,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite uint8_t *msg_start = write_plaintext_header(buffer_data, msg, frame_header_padding_); uint8_t msg_header_len = static_cast(buffer_data + frame_header_padding_ - msg_start); uint16_t msg_len = static_cast(msg_header_len + msg.payload_size); - return this->write_raw_fast_(msg_start, msg_len); + return this->write_raw_fast_buf_(msg_start, msg_len); } APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, @@ -315,7 +315,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe total_write_len += msg_len; } - return this->write_raw_fast_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_fast_iov_(iovs.data(), iovs.size(), total_write_len); } } // namespace esphome::api From d6475eaeed764bb30589323f832cf305d94bd69f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:15:18 -1000 Subject: [PATCH 37/37] [binary_sensor] Remove redundant `optional` state_, save 8 bytes per instance (#15095) --- .../binary_sensor/binary_sensor.cpp | 7 -- .../components/binary_sensor/binary_sensor.h | 21 +++-- esphome/core/entity_base.h | 88 +++++++++++++------ 3 files changed, 77 insertions(+), 39 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 8ace7eafd1..7596975a68 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -32,13 +32,6 @@ void BinarySensor::publish_initial_state(bool new_state) { this->invalidate_state(); this->publish_state(new_state); } -void BinarySensor::send_state_internal(bool new_state) { - // copy the new state to the visible property for backwards compatibility, before any callbacks - this->state = new_state; - // Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed - this->set_new_state(new_state); -} - bool BinarySensor::set_new_state(const optional &new_state) { if (StatefulEntityBase::set_new_state(new_state)) { // weirdly, this file could be compiled even without USE_BINARY_SENSOR defined diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 6ae5d04bcb..28c156763a 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -32,7 +32,10 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi */ class BinarySensor : public StatefulEntityBase { public: - explicit BinarySensor(){}; + explicit BinarySensor() = default; + + const bool &get_state() const override { return this->state; } + void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } /** Publish a new state to the front-end. * @@ -54,16 +57,24 @@ class BinarySensor : public StatefulEntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - void send_state_internal(bool new_state); + void send_state_internal(bool new_state) { + // Fast path: skip virtual dispatch when state hasn't changed + if (this->flags_.has_state && this->state == new_state) + return; + this->set_new_state(new_state); + } /// Return whether this binary sensor has outputted a state. virtual bool is_status_binary_sensor() const; - // For backward compatibility, provide an accessible property - + /// The current state of this binary sensor. Also used as the backing storage for StatefulEntityBase. bool state{}; protected: + bool get_trigger_on_initial_state() const override { return this->trigger_on_initial_state_; } + void set_state_value(const bool &value) override { this->state = value; } + + bool trigger_on_initial_state_{true}; #ifdef USE_BINARY_SENSOR_FILTER Filter *filter_list_{nullptr}; #endif @@ -73,7 +84,7 @@ class BinarySensor : public StatefulEntityBase { class BinarySensorInitiallyOff : public BinarySensor { public: - bool has_state() const override { return true; } + BinarySensorInitiallyOff() { this->set_has_state(true); } }; } // namespace esphome::binary_sensor diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8c1f1a213e..5a69c9dd09 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -296,15 +296,36 @@ void log_entity_device_class(const char *tag, const char *prefix, const EntityBa #define LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj) log_entity_unit_of_measurement(tag, prefix, obj) void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj); -/** - * An entity that has a state. - * @tparam T The type of the state +/** Base class for entities that track a typed state value with change-detection and callbacks. + * + * This class does not store the state value — subclasses own their storage. Whether a state + * has been set is tracked by EntityBase::has_state(). + * + * Subclasses must implement: + * - get_state(): return a const reference to the current value + * - set_state_value(): store a new value (called only when the state actually changes) + * - get_trigger_on_initial_state(): return whether callbacks should fire on the first state + * + * Subclasses may override set_new_state() to add behavior (logging, notifications) after calling + * the base implementation. Since set_new_state() is virtual, callers like invalidate_state() + * dispatch through the vtable to the subclass override in the .cpp, avoiding template code + * bloat at inline call sites. Subclasses may also add a fast-path dedup check before calling + * set_new_state() to skip virtual dispatch entirely when the state hasn't changed. + * + * Callback behavior: + * - full_state_callbacks_: fired on every change, receives optional previous and current + * - state_callbacks_: fired only when the new state has a value, and either this is not the + * first state (had_state) or trigger_on_initial_state is set + * + * @tparam T The type of the state value */ template class StatefulEntityBase : public EntityBase { public: - virtual bool has_state() const { return this->state_.has_value(); } - virtual const T &get_state() const { return this->state_.value(); } // NOLINT(bugprone-unchecked-optional-access) - virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); } + /// Return the current state value. Only valid when has_state() is true. + virtual const T &get_state() const = 0; + /// Return the current state if available, otherwise return the provided default. + T get_state_default(T default_value) const { return this->has_state() ? this->get_state() : default_value; } + /// Clear the state — sets has_state() to false and fires callbacks with nullopt. void invalidate_state() { this->set_new_state({}); } template void add_full_state_callback(F &&callback) { @@ -314,33 +335,46 @@ template class StatefulEntityBase : public EntityBase { this->state_callbacks_.add(std::forward(callback)); } - void set_trigger_on_initial_state(bool trigger_on_initial_state) { - this->trigger_on_initial_state_ = trigger_on_initial_state; - } - protected: - optional state_{}; - /** - * Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous. + /// Subclasses return whether callbacks should fire on the very first state. + virtual bool get_trigger_on_initial_state() const = 0; + + /** Apply a new state, de-duplicating and firing callbacks as needed. * - * @param new_state The new state. - * @return True if the state was changed, false if it was the same as before. + * Pass nullopt to invalidate (clear) the state. Pass a value to set it. + * Returns true if the state actually changed, false if it was the same. + * Subclasses may override to add logging/notifications after calling the base. */ virtual bool set_new_state(const optional &new_state) { - if (this->state_ != new_state) { - // call the full state callbacks with the previous and new state - this->full_state_callbacks_.call(this->state_, new_state); - // trigger legacy callbacks only if the new state is valid and either the trigger on initial state is enabled or - // the previous state was valid - auto had_state = this->has_state(); - this->state_ = new_state; - if (new_state.has_value() && (this->trigger_on_initial_state_ || had_state)) - this->state_callbacks_.call(new_state.value()); - return true; + // Access flags_ directly to avoid function call overhead in this hot path + bool had_state = this->flags_.has_state; + // Use pointer to avoid requiring T to be default-constructible + const T *current = had_state ? &this->get_state() : nullptr; + if (new_state.has_value()) { + if (current != nullptr && *current == new_state.value()) + return false; // same value, no change + } else if (!had_state) { + return false; // already invalidated, no change } - return false; + // Capture old_state before set_state_value — current pointer aliases subclass storage + bool has_full_cbs = !this->full_state_callbacks_.empty(); + optional old_state; + if (has_full_cbs) + old_state = current != nullptr ? optional(*current) : nullopt; + // Update storage before firing callbacks so callback code can inspect current state + this->flags_.has_state = new_state.has_value(); + if (new_state.has_value()) { + this->set_state_value(new_state.value()); + } + if (has_full_cbs) + this->full_state_callbacks_.call(old_state, new_state); + // had_state first: on every change except the first, skips the virtual call + if (new_state.has_value() && (had_state || this->get_trigger_on_initial_state())) + this->state_callbacks_.call(new_state.value()); + return true; } - bool trigger_on_initial_state_{true}; + /// Subclasses implement this to store the actual value into their own storage. + virtual void set_state_value(const T &value) = 0; LazyCallbackManager previous, optional current)> full_state_callbacks_; LazyCallbackManager state_callbacks_; };