From 3520ef74809b0d6f1c6e9d3abd067c36536ce9a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:38:04 -1000 Subject: [PATCH 01/19] [text_sensor] Use std::array in MapFilter (#15269) --- esphome/components/text_sensor/__init__.py | 2 +- esphome/components/text_sensor/filter.cpp | 14 ++++++-------- esphome/components/text_sensor/filter.h | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 51eedf9a956..78a7a3a41b3 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -129,7 +129,7 @@ async def map_filter_to_code(config, filter_id): ) for conf in config ] - return cg.new_Pvariable(filter_id, mappings) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(mappings)), mappings) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index f7c6a695fba..bc044f3a737 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -93,17 +93,15 @@ bool SubstituteFilter::new_value(std::string &value) { return true; } -// Map -MapFilter::MapFilter(const std::initializer_list &mappings) : mappings_(mappings) {} - -bool MapFilter::new_value(std::string &value) { - for (const auto &mapping : this->mappings_) { - if (value == mapping.from) { - value.assign(mapping.to); +// Map — non-template helper +bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value) { + for (size_t i = 0; i < count; i++) { + if (value == mappings[i].from) { + value.assign(mappings[i].to); return true; } } - return true; // Pass through if no match + return true; } } // namespace esphome::text_sensor diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 8a8bc55c8e1..07832af9e2b 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_TEXT_SENSOR_FILTER +#include + #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -131,6 +133,9 @@ class SubstituteFilter : public Filter { FixedVector substitutions_; }; +/// Non-template helper (implementation in filter.cpp) +bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value); + /** A filter that maps values from one set to another * * Uses linear search instead of std::map for typical small datasets (2-20 mappings). @@ -154,14 +159,18 @@ class SubstituteFilter : public Filter { * - Faster for typical ESPHome usage (2-10 mappings common, 20+ rare) * * Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20 + * + * N is set by code generation to match the exact number of mappings configured in YAML. */ -class MapFilter : public Filter { +template class MapFilter : public Filter { public: - explicit MapFilter(const std::initializer_list &mappings); - bool new_value(std::string &value) override; + explicit MapFilter(const std::initializer_list &mappings) { + init_array_from(this->mappings_, mappings); + } + bool new_value(std::string &value) override { return map_filter_apply(this->mappings_.data(), N, value); } protected: - FixedVector mappings_; + std::array mappings_{}; }; } // namespace esphome::text_sensor From 29419d9d97557af72cc75d351b80797e9cefe83d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 13:36:08 -1000 Subject: [PATCH 02/19] [automation] Use std::array in And/Or/Xor conditions (#15282) --- esphome/automation.py | 20 +++++++++++++++----- esphome/core/base_automation.h | 25 ++++++++++++++++--------- esphome/core/helpers.h | 3 ++- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 7b1d6ceca13..94d64086ec0 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -250,7 +250,9 @@ async def and_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("or", OrCondition, validate_condition_list) @@ -261,7 +263,9 @@ async def or_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("all", AndCondition, validate_condition_list) @@ -272,7 +276,9 @@ async def all_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("any", OrCondition, validate_condition_list) @@ -283,7 +289,9 @@ async def any_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("not", NotCondition, validate_potentially_and_condition) @@ -305,7 +313,9 @@ async def xor_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("lambda", LambdaCondition, cv.returning_lambda) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index efcffa8824d..11133d39739 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -9,14 +9,17 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include #include #include namespace esphome { -template class AndCondition : public Condition { +template class AndCondition : public Condition { public: - explicit AndCondition(std::initializer_list *> conditions) : conditions_(conditions) {} + explicit AndCondition(std::initializer_list *> conditions) { + init_array_from(this->conditions_, conditions); + } bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -27,12 +30,14 @@ template class AndCondition : public Condition { } protected: - FixedVector *> conditions_; + std::array *, N> conditions_{}; }; -template class OrCondition : public Condition { +template class OrCondition : public Condition { public: - explicit OrCondition(std::initializer_list *> conditions) : conditions_(conditions) {} + explicit OrCondition(std::initializer_list *> conditions) { + init_array_from(this->conditions_, conditions); + } bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -43,7 +48,7 @@ template class OrCondition : public Condition { } protected: - FixedVector *> conditions_; + std::array *, N> conditions_{}; }; template class NotCondition : public Condition { @@ -55,9 +60,11 @@ template class NotCondition : public Condition { Condition *condition_; }; -template class XorCondition : public Condition { +template class XorCondition : public Condition { public: - explicit XorCondition(std::initializer_list *> conditions) : conditions_(conditions) {} + explicit XorCondition(std::initializer_list *> conditions) { + init_array_from(this->conditions_, conditions); + } bool check(const Ts &...x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -68,7 +75,7 @@ template class XorCondition : public Condition { } protected: - FixedVector *> conditions_; + std::array *, N> conditions_{}; }; template class LambdaCondition : public Condition { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 913614f5641..66ba166445f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -500,7 +500,8 @@ template::max()> /// 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. +/// N is always set by code generation — the caller is responsible for ensuring src.size() == N. +/// The debug assert is a safety net for development, not a runtime check. template inline void init_array_from(std::array &dest, std::initializer_list src) { #ifdef ESPHOME_DEBUG assert(src.size() == N); From 6ff0d33ca4ec649c37b8a75d14ee9e624ea94ddd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 13:40:24 -1000 Subject: [PATCH 03/19] [api] Add safety check for full write in write_raw_iov_ before enqueue When called directly (not via write_raw_fast_iov_), sent could equal total_write_len. Check before enqueueing to avoid a no-op enqueue. --- esphome/components/api/api_frame_helper.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index f46693a4e80..d088a75c8aa 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -155,6 +155,10 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin } } + // Full write completed (possible when called directly, not via write_raw_fast_iov_) + if (sent == static_cast(total_write_len)) + return APIError::OK; + // 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"); From b4de015706483bb0a2256e3e1191d71a3761943e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 13:42:57 -1000 Subject: [PATCH 04/19] [api] Use buf_start directly in noise write_protobuf_packet, add flow comment --- esphome/components/api/api_frame_helper.cpp | 1 + esphome/components/api/api_frame_helper_noise.cpp | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index d088a75c8aa..5c5f1e8785d 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -142,6 +142,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin (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; + // Partial write or -1: fall through to error check / enqueue below } } if (sent == -1) { diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 0c0c069b534..156fcec314f 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -504,7 +504,8 @@ 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_buf_(iov.iov_base, static_cast(iov.iov_len)); + // buf_start and iov.iov_base point to the same location + return this->write_raw_fast_buf_(buf_start, static_cast(iov.iov_len)); } APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { From 1c1ddf463b76c3d13f8376a9d6279fe2b04cd432 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 13:49:47 -1000 Subject: [PATCH 05/19] [api] Fix write_raw_iov_ not attempting write when called directly with empty overflow When called from cold paths (write_raw_buf_, write_frame_) with sent=-1 and empty overflow, the function would skip the write and read a stale errno. Now always attempts the write when overflow is empty, matching the original write_raw_ behavior. --- esphome/components/api/api_frame_helper.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 5c5f1e8785d..89ae9b3975f 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -130,20 +130,19 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin #endif if (sent == -1) { - // Either the fast path got -1, or we were called with overflow backlog + // Either the fast path write returned -1, or we were called directly (cold path) 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; - // Partial write or -1: fall through to error check / enqueue below - } + } + // Try write if backlog is clear (either was empty, or drain succeeded) + 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; + // Partial write or -1: fall through to error check / enqueue below } if (sent == -1) { int err = errno; From 4da7f5ecc2e82e185c0dea21bf7f40caa1a88148 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 13:50:46 -1000 Subject: [PATCH 06/19] [binary_sensor] Use std::array in AutorepeatFilter (#15268) --- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/binary_sensor/filter.cpp | 27 ++++++------------ esphome/components/binary_sensor/filter.h | 29 ++++++++++++++++---- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 4705f1675d5..8d072904b07 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -255,6 +255,7 @@ async def delayed_off_filter_to_code(config, filter_id): ): cv.positive_time_period_milliseconds, } ), + cv.Length(max=254), ), ) async def autorepeat_filter_to_code(config, filter_id): @@ -283,7 +284,7 @@ async def autorepeat_filter_to_code(config, filter_id): ), ) ] - var = cg.new_Pvariable(filter_id, timings) + var = cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings) await cg.register_component(var, {}) return var diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 5d525e967db..914060ce138 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -76,14 +76,11 @@ float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARD optional InvertFilter::new_value(bool value) { return !value; } -AutorepeatFilter::AutorepeatFilter(std::initializer_list timings) : timings_(timings) {} - -optional AutorepeatFilter::new_value(bool value) { +// AutorepeatFilterBase +optional AutorepeatFilterBase::new_value(bool value) { if (value) { - // Ignore if already running if (this->active_timing_ != 0) return {}; - this->next_timing_(); return true; } else { @@ -94,34 +91,26 @@ optional AutorepeatFilter::new_value(bool value) { } } -void AutorepeatFilter::next_timing_() { - // Entering this method - // 1st time: starts waiting the first delay - // 2nd time: starts waiting the second delay and starts toggling with the first time_off / _on - // last time: no delay to start but have to bump the index to reflect the last - if (this->active_timing_ < this->timings_.size()) { +void AutorepeatFilterBase::next_timing_() { + if (this->active_timing_ < this->timings_count_) { this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); }); } - - if (this->active_timing_ <= this->timings_.size()) { + if (this->active_timing_ <= this->timings_count_) { this->active_timing_++; } - if (this->active_timing_ == 2) this->next_value_(false); - - // Leaving this method: if the toggling is started, it has to use [active_timing_ - 2] for the intervals } -void AutorepeatFilter::next_value_(bool val) { +void AutorepeatFilterBase::next_value_(bool val) { const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2]; - this->output(val); // This is at least the second one so not initial + this->output(val); this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off, [this, val]() { this->next_value_(!val); }); } -float AutorepeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } +float AutorepeatFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } LambdaFilter::LambdaFilter(std::function(bool)> f) : f_(std::move(f)) {} diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 0813847ca21..37c6bf0092c 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_BINARY_SENSOR_FILTER +#include + #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -86,22 +88,39 @@ struct AutorepeatFilterTiming { uint32_t time_on; }; -class AutorepeatFilter : public Filter, public Component { +/// Non-template base for AutorepeatFilter — all methods in filter.cpp. +/// Lambdas capture this base pointer, so set_timeout/cancel_timeout are instantiated once. +class AutorepeatFilterBase : public Filter, public Component { public: - explicit AutorepeatFilter(std::initializer_list timings); - optional new_value(bool value) override; - float get_setup_priority() const override; + AutorepeatFilterBase(const AutorepeatFilterBase &) = delete; + AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete; protected: + AutorepeatFilterBase() = default; void next_timing_(); void next_value_(bool val); - FixedVector timings_; + const AutorepeatFilterTiming *timings_{nullptr}; + uint8_t timings_count_{0}; uint8_t active_timing_{0}; }; +/// Template wrapper that provides inline std::array storage for timings. +/// N is set by code generation to match the exact number of timings configured in YAML. +template class AutorepeatFilter : public AutorepeatFilterBase { + public: + explicit AutorepeatFilter(std::initializer_list timings) { + init_array_from(this->timings_storage_, timings); + this->timings_ = this->timings_storage_.data(); + this->timings_count_ = N; + } + + protected: + std::array timings_storage_{}; +}; + class LambdaFilter : public Filter { public: explicit LambdaFilter(std::function(bool)> f); From 2e1d6e4d580d889f3e2fa9a0d9b1d0c149c90a24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 13:52:29 -1000 Subject: [PATCH 07/19] [api] Move HELPER_LOG_PACKETS logging to call sites before fast path LOG_PACKET_SENDING was in write_raw_iov_ which is only called on the slow path. Moved to write_protobuf_packet and write_protobuf_messages so all packets are logged regardless of which write path is taken. --- esphome/components/api/api_frame_helper.cpp | 6 ------ esphome/components/api/api_frame_helper_noise.cpp | 6 ++++++ esphome/components/api/api_frame_helper_plaintext.cpp | 6 ++++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 89ae9b3975f..a8d0ec9d71f 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -123,12 +123,6 @@ APIError APIFrameHelper::write_raw_buf_(const void *data, uint16_t len, ssize_t // 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_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); - } -#endif - if (sent == -1) { // Either the fast path write returned -1, or we were called directly (cold path) if (!this->overflow_buf_.empty()) { diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 156fcec314f..e5ed1d86578 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -505,6 +505,7 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff if (aerr != APIError::OK) return aerr; // buf_start and iov.iov_base point to the same location + LOG_PACKET_SENDING(buf_start, iov.iov_len); return this->write_raw_fast_buf_(buf_start, static_cast(iov.iov_len)); } @@ -528,6 +529,11 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s total_write_len += iov.iov_len; } +#ifdef HELPER_LOG_PACKETS + for (const auto &iov : iovs) { + LOG_PACKET_SENDING(reinterpret_cast(iov.iov_base), iov.iov_len); + } +#endif return this->write_raw_fast_iov_(iovs.data(), iovs.size(), total_write_len); } diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 6be20f89f37..ee0661c998c 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -292,6 +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); + LOG_PACKET_SENDING(msg_start, msg_len); return this->write_raw_fast_buf_(msg_start, msg_len); } @@ -315,6 +316,11 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe total_write_len += msg_len; } +#ifdef HELPER_LOG_PACKETS + for (const auto &iov : iovs) { + LOG_PACKET_SENDING(reinterpret_cast(iov.iov_base), iov.iov_len); + } +#endif return this->write_raw_fast_iov_(iovs.data(), iovs.size(), total_write_len); } From 5f2ad82a67541d9a1a8f41ce3449c4848791800b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:10:11 -1000 Subject: [PATCH 08/19] [api] Use named sentinels to avoid redundant syscall on write failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WRITE_NOT_ATTEMPTED (-1): cold path, no write tried yet — try write WRITE_FAILED (-2): fast path write() returned -1 — skip retry, check errno This avoids a redundant write() syscall when the fast path already got EWOULDBLOCK and fell through to the slow path. --- esphome/components/api/api_frame_helper.cpp | 35 +++++++++++---------- esphome/components/api/api_frame_helper.h | 26 ++++++++++----- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index a8d0ec9d71f..e3b8fd28041 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -119,26 +119,27 @@ APIError APIFrameHelper::write_raw_buf_(const void *data, uint16_t len, ssize_t } // Handles partial writes, errors, and overflow buffering. -// Called when the inline fast path in the header couldn't complete the write, +// Called when the inline fast path 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_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { - if (sent == -1) { - // Either the fast path write returned -1, or we were called directly (cold path) - if (!this->overflow_buf_.empty()) { - // Drain existing backlog first - APIError err = this->drain_overflow_and_handle_errors_(); - if (err != APIError::OK) - return err; + if (sent <= 0) { + if (sent == WRITE_NOT_ATTEMPTED) { + // Cold path: no write attempted yet, drain overflow and try + if (!this->overflow_buf_.empty()) { + APIError err = this->drain_overflow_and_handle_errors_(); + if (err != APIError::OK) + return err; + } + 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; + // Partial write or -1: fall through to error check / enqueue below + } } - // Try write if backlog is clear (either was empty, or drain succeeded) - 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; - // Partial write or -1: fall through to error check / enqueue below - } - if (sent == -1) { + // WRITE_FAILED or write above returned -1: check errno + if (sent == WRITE_FAILED || sent == -1) { int err = errno; if (err != EWOULDBLOCK && err != EAGAIN) { this->state_ = State::FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 94007eeb3d7..35047e68d01 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -192,32 +192,42 @@ class APIFrameHelper { // Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors. APIError drain_overflow_and_handle_errors_(); + // Sentinel values for the sent parameter in write_raw_ methods + static constexpr ssize_t WRITE_NOT_ATTEMPTED = -1; // Cold path: no write attempted yet + static constexpr ssize_t WRITE_FAILED = -2; // Fast path: write() returned -1 + // 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_buf_(const void *data, uint16_t len) { - ssize_t sent = -1; if (this->overflow_buf_.empty()) [[likely]] { - sent = this->socket_->write(data, len); + ssize_t sent = this->socket_->write(data, len); if (sent == static_cast(len)) [[likely]] return APIError::OK; + if (sent == -1) + return this->write_raw_buf_(data, len, WRITE_FAILED); + return this->write_raw_buf_(data, len, sent); } - return this->write_raw_buf_(data, len, sent); + return this->write_raw_buf_(data, len, WRITE_NOT_ATTEMPTED); } 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); + ssize_t sent = this->socket_->writev(iov, iovcnt); if (sent == static_cast(total_write_len)) [[likely]] return APIError::OK; + if (sent == -1) + return this->write_raw_iov_(iov, iovcnt, total_write_len, WRITE_FAILED); + return this->write_raw_iov_(iov, iovcnt, total_write_len, sent); } - return this->write_raw_iov_(iov, iovcnt, total_write_len, sent); + return this->write_raw_iov_(iov, iovcnt, total_write_len, WRITE_NOT_ATTEMPTED); } // Out-of-line write paths: handle partial writes, errors, overflow buffering - 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); + // sent: WRITE_NOT_ATTEMPTED (cold path), WRITE_FAILED (fast path write returned -1), or bytes sent (partial write) + APIError write_raw_buf_(const void *data, uint16_t len, ssize_t sent = WRITE_NOT_ATTEMPTED); + APIError write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, + ssize_t sent = WRITE_NOT_ATTEMPTED); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; From d91484c19fcf76d026d4811198b390d17131bfc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:12:31 -1000 Subject: [PATCH 09/19] [api] Swap sentinel values: WRITE_FAILED=-1 matches write() return WRITE_FAILED=-1 matches the socket write() return value directly, so the fast path can pass sent through without remapping. Simplifies both the inline fast path and the slow path errno check. --- esphome/components/api/api_frame_helper.cpp | 4 ++-- esphome/components/api/api_frame_helper.h | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index e3b8fd28041..7beabdcac38 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -138,8 +138,8 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin // Partial write or -1: fall through to error check / enqueue below } } - // WRITE_FAILED or write above returned -1: check errno - if (sent == WRITE_FAILED || sent == -1) { + // WRITE_FAILED (-1): fast path or retry write returned -1, check errno + if (sent == WRITE_FAILED) { int err = errno; if (err != EWOULDBLOCK && err != EAGAIN) { this->state_ = State::FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 35047e68d01..812e360ea70 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -193,8 +193,8 @@ class APIFrameHelper { APIError drain_overflow_and_handle_errors_(); // Sentinel values for the sent parameter in write_raw_ methods - static constexpr ssize_t WRITE_NOT_ATTEMPTED = -1; // Cold path: no write attempted yet - static constexpr ssize_t WRITE_FAILED = -2; // Fast path: write() returned -1 + static constexpr ssize_t WRITE_FAILED = -1; // Fast path: write()/writev() returned -1 + static constexpr ssize_t WRITE_NOT_ATTEMPTED = -2; // Cold path: no write attempted yet // 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 @@ -204,8 +204,7 @@ class APIFrameHelper { ssize_t sent = this->socket_->write(data, len); if (sent == static_cast(len)) [[likely]] return APIError::OK; - if (sent == -1) - return this->write_raw_buf_(data, len, WRITE_FAILED); + // sent is -1 (WRITE_FAILED) or partial write count return this->write_raw_buf_(data, len, sent); } return this->write_raw_buf_(data, len, WRITE_NOT_ATTEMPTED); @@ -216,8 +215,7 @@ class APIFrameHelper { ssize_t sent = this->socket_->writev(iov, iovcnt); if (sent == static_cast(total_write_len)) [[likely]] return APIError::OK; - if (sent == -1) - return this->write_raw_iov_(iov, iovcnt, total_write_len, WRITE_FAILED); + // sent is -1 (WRITE_FAILED) or partial write count return this->write_raw_iov_(iov, iovcnt, total_write_len, sent); } return this->write_raw_iov_(iov, iovcnt, total_write_len, WRITE_NOT_ATTEMPTED); From a84d36cb37d24e2a212f32f5c2ee30fd3efb450a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:18:01 -1000 Subject: [PATCH 10/19] preen --- esphome/components/api/api_frame_helper.cpp | 3 +-- esphome/components/api/api_frame_helper.h | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 7beabdcac38..b8e65e655c0 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -131,8 +131,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin return err; } if (this->overflow_buf_.empty()) { - sent = - (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); + sent = this->write_iov_to_socket_(iov, iovcnt); if (sent == static_cast(total_write_len)) return APIError::OK; // Partial write or -1: fall through to error check / enqueue below diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 812e360ea70..9c0f072e3b9 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -196,6 +196,11 @@ class APIFrameHelper { static constexpr ssize_t WRITE_FAILED = -1; // Fast path: write()/writev() returned -1 static constexpr ssize_t WRITE_NOT_ATTEMPTED = -2; // Cold path: no write attempted yet + // Dispatch to write() or writev() based on iovec count + inline ssize_t ESPHOME_ALWAYS_INLINE write_iov_to_socket_(const struct iovec *iov, int iovcnt) { + return (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); + } + // 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. From 66754fa376b8495885c7718acacaf66b0872f7f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:24:32 -1000 Subject: [PATCH 11/19] [text_sensor] Use std::array in SubstituteFilter (#15266) --- esphome/components/text_sensor/__init__.py | 4 +++- esphome/components/text_sensor/filter.cpp | 20 +++++++------------- esphome/components/text_sensor/filter.h | 16 +++++++++++----- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 78a7a3a41b3..5b07dd29156 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -116,7 +116,9 @@ async def substitute_filter_to_code(config, filter_id): ) for conf in config ] - return cg.new_Pvariable(filter_id, substitutions) + return cg.new_Pvariable( + filter_id, cg.TemplateArguments(len(substitutions)), substitutions + ) @FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping)) diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index bc044f3a737..d4e6b5b9bbf 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -73,20 +73,14 @@ bool PrependFilter::new_value(std::string &value) { return true; } -// Substitute -SubstituteFilter::SubstituteFilter(const std::initializer_list &substitutions) - : substitutions_(substitutions) {} - -bool SubstituteFilter::new_value(std::string &value) { - for (const auto &sub : this->substitutions_) { - // Compute lengths once per substitution (strlen is fast, called infrequently) - const size_t from_len = strlen(sub.from); - const size_t to_len = strlen(sub.to); +// Substitute — non-template helper +bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value) { + for (size_t i = 0; i < count; i++) { + const size_t from_len = strlen(substitutions[i].from); + const size_t to_len = strlen(substitutions[i].to); std::size_t pos = 0; - while ((pos = value.find(sub.from, pos, from_len)) != std::string::npos) { - value.replace(pos, from_len, sub.to, to_len); - // Advance past the replacement to avoid infinite loop when - // the replacement contains the search pattern (e.g., f -> foo) + while ((pos = value.find(substitutions[i].from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, substitutions[i].to, to_len); pos += to_len; } } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 07832af9e2b..6db76dcb640 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -123,14 +123,20 @@ struct Substitution { const char *to; }; -/// A simple filter that replaces a substring with another substring -class SubstituteFilter : public Filter { +/// Non-template helper (implementation in filter.cpp) +bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value); + +/// A simple filter that replaces a substring with another substring. +/// N is set by code generation to match the exact number of substitutions configured in YAML. +template class SubstituteFilter : public Filter { public: - explicit SubstituteFilter(const std::initializer_list &substitutions); - bool new_value(std::string &value) override; + explicit SubstituteFilter(const std::initializer_list &substitutions) { + init_array_from(this->substitutions_, substitutions); + } + bool new_value(std::string &value) override { return substitute_filter_apply(this->substitutions_.data(), N, value); } protected: - FixedVector substitutions_; + std::array substitutions_{}; }; /// Non-template helper (implementation in filter.cpp) From a19a6f2a0e1c11fe4f45473d380cf10db1078a20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:26:50 -1000 Subject: [PATCH 12/19] [api] Return header length from write_plaintext_header instead of pointer Returning total_header_len lets callers compute msg_len directly (header_len + payload_size) instead of reloading frame_header_padding_ from memory and doing pointer subtraction after encode_varint calls clobber registers. Saves ~10 bytes in write_protobuf_packet. --- .../api/api_frame_helper_plaintext.cpp | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index ee0661c998c..53ee58590de 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -235,9 +235,9 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { return APIError::OK; } // Write plaintext header into pre-allocated padding before payload. -// Returns pointer to start of frame (header + payload are contiguous). -ESPHOME_ALWAYS_INLINE static inline uint8_t *write_plaintext_header(uint8_t *buf_start, const MessageInfo &msg, - uint8_t frame_header_padding) { +// Returns the total header length (indicator + varints). +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 @@ -279,7 +279,7 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t *write_plaintext_header(uint8_t *buf encode_varint_to_buffer(msg.payload_size, buf_start + header_offset + 1); encode_varint_to_buffer(msg.message_type, buf_start + header_offset + 1 + size_varint_len); - return buf_start + header_offset; + return total_header_len; } APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { @@ -289,9 +289,9 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite 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); - uint16_t msg_len = static_cast(msg_header_len + msg.payload_size); + uint8_t header_len = write_plaintext_header(buffer_data, msg, frame_header_padding_); + uint8_t *msg_start = buffer_data + frame_header_padding_ - header_len; + uint16_t msg_len = static_cast(header_len + msg.payload_size); LOG_PACKET_SENDING(msg_start, msg_len); return this->write_raw_fast_buf_(msg_start, msg_len); } @@ -309,9 +309,9 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe const uint8_t padding = frame_header_padding_; for (const auto &msg : messages) { - uint8_t *msg_start = write_plaintext_header(buffer_data + msg.offset, msg, padding); - uint8_t msg_header_len = static_cast((buffer_data + msg.offset + padding) - msg_start); - size_t msg_len = static_cast(msg_header_len + msg.payload_size); + uint8_t header_len = write_plaintext_header(buffer_data + msg.offset, msg, padding); + uint8_t *msg_start = buffer_data + msg.offset + padding - header_len; + size_t msg_len = static_cast(header_len + msg.payload_size); iovs.push_back({msg_start, msg_len}); total_write_len += msg_len; } From ccafd442a7ec4515e8c2da710f599f8b8d38089f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:32:47 -1000 Subject: [PATCH 13/19] [api] Use static constexpr HEADER_PADDING instead of loading member field The header padding is a compile-time constant per protocol (6 for plaintext, 7 for noise). Using constexpr eliminates memory loads on the hot path. The value is defined once per class with the component breakdown in the comment. --- esphome/components/api/api_frame_helper_noise.cpp | 3 +-- esphome/components/api/api_frame_helper_noise.h | 14 ++++++++------ .../components/api/api_frame_helper_plaintext.cpp | 12 +++++------- .../components/api/api_frame_helper_plaintext.h | 14 ++++++++------ 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index e5ed1d86578..62073bb4401 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -497,8 +497,7 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff 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_)}; + MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - HEADER_PADDING - frame_footer_size_)}; uint8_t *buf_start = buffer.get_buffer()->data(); struct iovec iov; APIError aerr = this->encrypt_noise_message_(buf_start, msg, iov); diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index e56006d955a..53c18431d61 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -9,14 +9,16 @@ namespace esphome::api { class APINoiseFrameHelper final : public APIFrameHelper { public: + // Noise header structure: + // Pos 0: indicator (0x01) + // Pos 1-2: encrypted payload size (16-bit big-endian) + // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) + // Pos 7+: actual payload data + static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len + APINoiseFrameHelper(std::unique_ptr socket, APINoiseContext &ctx) : APIFrameHelper(std::move(socket)), ctx_(ctx) { - // Noise header structure: - // Pos 0: indicator (0x01) - // Pos 1-2: encrypted payload size (16-bit big-endian) - // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) - // Pos 7+: actual payload data - frame_header_padding_ = 7; + frame_header_padding_ = HEADER_PADDING; } ~APINoiseFrameHelper() override; APIError init() override; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 53ee58590de..b74b215e153 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -287,10 +287,10 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite assert(this->state_ == State::DATA); #endif - MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - frame_header_padding_)}; + MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - HEADER_PADDING)}; uint8_t *buffer_data = buffer.get_buffer()->data(); - uint8_t header_len = write_plaintext_header(buffer_data, msg, frame_header_padding_); - uint8_t *msg_start = buffer_data + frame_header_padding_ - header_len; + uint8_t header_len = write_plaintext_header(buffer_data, msg, HEADER_PADDING); + uint8_t *msg_start = buffer_data + HEADER_PADDING - header_len; uint16_t msg_len = static_cast(header_len + msg.payload_size); LOG_PACKET_SENDING(msg_start, msg_len); return this->write_raw_fast_buf_(msg_start, msg_len); @@ -302,15 +302,13 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe assert(this->state_ == State::DATA); assert(!messages.empty()); #endif - uint8_t *buffer_data = buffer.get_buffer()->data(); StaticVector iovs; uint16_t total_write_len = 0; - const uint8_t padding = frame_header_padding_; for (const auto &msg : messages) { - uint8_t header_len = write_plaintext_header(buffer_data + msg.offset, msg, padding); - uint8_t *msg_start = buffer_data + msg.offset + padding - header_len; + uint8_t header_len = write_plaintext_header(buffer_data + msg.offset, msg, HEADER_PADDING); + uint8_t *msg_start = buffer_data + msg.offset + HEADER_PADDING - header_len; size_t msg_len = static_cast(header_len + msg.payload_size); iovs.push_back({msg_start, msg_len}); total_write_len += msg_len; diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 96d47e9c7bf..8314754715f 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -7,13 +7,15 @@ namespace esphome::api { class APIPlaintextFrameHelper final : public APIFrameHelper { public: + // Plaintext header structure (worst case): + // Pos 0: indicator (0x00) + // Pos 1-3: payload size varint (up to 3 bytes) + // Pos 4-5: message type varint (up to 2 bytes) + // Pos 6+: actual payload data + static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint + explicit APIPlaintextFrameHelper(std::unique_ptr socket) : APIFrameHelper(std::move(socket)) { - // Plaintext header structure (worst case): - // Pos 0: indicator (0x00) - // Pos 1-3: payload size varint (up to 3 bytes) - // Pos 4-5: message type varint (up to 2 bytes) - // Pos 6+: actual payload data - frame_header_padding_ = 6; + frame_header_padding_ = HEADER_PADDING; } ~APIPlaintextFrameHelper() override = default; APIError init() override; From adf0ce7fbdec4ac591f1b67cd880aec8adeb7555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:33:51 -1000 Subject: [PATCH 14/19] [api] Remove redundant frame_header_padding arg from write_plaintext_header It's always HEADER_PADDING (constexpr 6). Using it directly inside the function eliminates an argument and lets the compiler constant-fold. --- esphome/components/api/api_frame_helper_plaintext.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index b74b215e153..d641a97650a 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 the total header length (indicator + varints). -ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, const MessageInfo &msg, - uint8_t frame_header_padding) { +ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, const MessageInfo &msg) { + constexpr uint8_t frame_header_padding = APIPlaintextFrameHelper::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 @@ -289,7 +289,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - HEADER_PADDING)}; uint8_t *buffer_data = buffer.get_buffer()->data(); - uint8_t header_len = write_plaintext_header(buffer_data, msg, HEADER_PADDING); + uint8_t header_len = write_plaintext_header(buffer_data, msg); uint8_t *msg_start = buffer_data + HEADER_PADDING - header_len; uint16_t msg_len = static_cast(header_len + msg.payload_size); LOG_PACKET_SENDING(msg_start, msg_len); @@ -307,7 +307,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe uint16_t total_write_len = 0; for (const auto &msg : messages) { - uint8_t header_len = write_plaintext_header(buffer_data + msg.offset, msg, HEADER_PADDING); + uint8_t header_len = write_plaintext_header(buffer_data + msg.offset, msg); uint8_t *msg_start = buffer_data + msg.offset + HEADER_PADDING - header_len; size_t msg_len = static_cast(header_len + msg.payload_size); iovs.push_back({msg_start, msg_len}); From 2e68b89fffc04109a372799716c6b833e8068aec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:34:29 -1000 Subject: [PATCH 15/19] [api] Use HEADER_PADDING directly instead of local alias --- esphome/components/api/api_frame_helper_plaintext.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index d641a97650a..06b8dd6e7b5 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -237,7 +237,6 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // Write plaintext header into pre-allocated padding before payload. // Returns the total header length (indicator + varints). ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, const MessageInfo &msg) { - constexpr uint8_t frame_header_padding = APIPlaintextFrameHelper::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 @@ -269,8 +268,8 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_ // [6...] - Actual payload data // // The message starts at offset + frame_header_padding - // So we write the header starting at offset + frame_header_padding - total_header_len - uint32_t header_offset = frame_header_padding - total_header_len; + // So we write the header starting at offset + HEADER_PADDING - total_header_len + uint32_t header_offset = APIPlaintextFrameHelper::HEADER_PADDING - total_header_len; // Write the plaintext header buf_start[header_offset] = 0x00; // indicator From 508ec295a4d9b8b920b27ce4d3cee6818997c39d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:55:46 -1000 Subject: [PATCH 16/19] [sensor] Use std::array in OrFilter (#15262) --- esphome/components/sensor/__init__.py | 2 +- esphome/components/sensor/filter.cpp | 30 ++++++++----------------- esphome/components/sensor/filter.h | 32 ++++++++++++++++++++------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5569567de12..8bbaa73e2ed 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -620,7 +620,7 @@ async def delta_filter_to_code(config, filter_id): @FILTER_REGISTRY.register("or", OrFilter, validate_filters) async def or_filter_to_code(config, filter_id): filters = await build_filters(config) - return cg.new_Pvariable(filter_id, filters) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), filters) @FILTER_REGISTRY.register( diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 66a9e9555bc..dad09ff0217 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -295,32 +295,20 @@ optional DeltaFilter::new_value(float value) { return {}; } -// OrFilter -OrFilter::OrFilter(std::initializer_list filters) : filters_(filters), phi_(this) {} -OrFilter::PhiNode::PhiNode(OrFilter *or_parent) : or_parent_(or_parent) {} - -optional OrFilter::PhiNode::new_value(float value) { - if (!this->or_parent_->has_value_) { - this->or_parent_->output(value); - this->or_parent_->has_value_ = true; +// OrFilter helpers +void or_filter_initialize(Filter **filters, size_t count, Sensor *parent, Filter *phi) { + for (size_t i = 0; i < count; i++) { + filters[i]->initialize(parent, phi); } - - return {}; + phi->initialize(parent, nullptr); } -optional OrFilter::new_value(float value) { - this->has_value_ = false; - for (auto *filter : this->filters_) - filter->input(value); +optional or_filter_new_value(Filter **filters, size_t count, float value, bool &has_value) { + has_value = false; + for (size_t i = 0; i < count; i++) + filters[i]->input(value); return {}; } -void OrFilter::initialize(Sensor *parent, Filter *next) { - Filter::initialize(parent, next); - for (auto *filter : this->filters_) { - filter->initialize(parent, &this->phi_); - } - this->phi_.initialize(parent, nullptr); -} // TimeoutFilterBase - shared loop logic void TimeoutFilterBase::loop() { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 80fa14742c2..deaaa27f19b 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -489,26 +489,42 @@ class DeltaFilter : public Filter { float last_value_{NAN}; }; -class OrFilter : public Filter { +/// Non-template helpers for OrFilter (implementation in filter.cpp) +void or_filter_initialize(Filter **filters, size_t count, Sensor *parent, Filter *phi); +optional or_filter_new_value(Filter **filters, size_t count, float value, bool &has_value); + +/// N is set by code generation to match the exact number of filters configured in YAML. +template class OrFilter : public Filter { public: - explicit OrFilter(std::initializer_list filters); + explicit OrFilter(std::initializer_list filters) { init_array_from(this->filters_, filters); } - void initialize(Sensor *parent, Filter *next) override; + void initialize(Sensor *parent, Filter *next) override { + Filter::initialize(parent, next); + or_filter_initialize(this->filters_.data(), N, parent, &this->phi_); + } - optional new_value(float value) override; + optional new_value(float value) override { + return or_filter_new_value(this->filters_.data(), N, value, this->has_value_); + } protected: class PhiNode : public Filter { public: - PhiNode(OrFilter *or_parent); - optional new_value(float value) override; + PhiNode(OrFilter *or_parent) : or_parent_(or_parent) {} + optional new_value(float value) override { + if (!this->or_parent_->has_value_) { + this->or_parent_->output(value); + this->or_parent_->has_value_ = true; + } + return {}; + } protected: OrFilter *or_parent_; }; - FixedVector filters_; - PhiNode phi_; + std::array filters_{}; + PhiNode phi_{this}; bool has_value_{false}; }; From d51b047f6381c407cb8c879a96d73c4b5c36f528 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:56:04 -1000 Subject: [PATCH 17/19] [sensor] Use std::array in CalibratePolynomialFilter (#15264) --- esphome/components/sensor/__init__.py | 2 +- esphome/components/sensor/filter.cpp | 9 +++------ esphome/components/sensor/filter.h | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8bbaa73e2ed..8abba17ff9f 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -808,7 +808,7 @@ async def calibrate_polynomial_filter_to_code(config, filter_id): # Column vector b = [[v] for v in y] res = [v[0] for v in _lstsq(a, b)] - return cg.new_Pvariable(filter_id, res) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(res)), res) def validate_clamp(config): diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index dad09ff0217..7b7a968f48f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -396,14 +396,11 @@ optional CalibrateLinearFilter::new_value(float value) { return NAN; } -CalibratePolynomialFilter::CalibratePolynomialFilter(std::initializer_list coefficients) - : coefficients_(coefficients) {} - -optional CalibratePolynomialFilter::new_value(float value) { +optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value) { float res = 0.0f; float x = 1.0f; - for (const auto &coefficient : this->coefficients_) { - res += x * coefficient; + for (size_t i = 0; i < count; i++) { + res += x * coefficients[i]; x *= value; } return res; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index deaaa27f19b..26a03acde57 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -537,13 +537,21 @@ class CalibrateLinearFilter : public Filter { FixedVector> linear_functions_; }; -class CalibratePolynomialFilter : public Filter { +/// Non-template helper for polynomial calibration (implementation in filter.cpp) +optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value); + +/// N is set by code generation to match the exact number of polynomial coefficients. +template class CalibratePolynomialFilter : public Filter { public: - explicit CalibratePolynomialFilter(std::initializer_list coefficients); - optional new_value(float value) override; + explicit CalibratePolynomialFilter(std::initializer_list coefficients) { + init_array_from(this->coefficients_, coefficients); + } + optional new_value(float value) override { + return calibrate_polynomial_compute(this->coefficients_.data(), N, value); + } protected: - FixedVector coefficients_; + std::array coefficients_{}; }; class ClampFilter : public Filter { From 17afbeb87b32c8b30a983c5926060d09ed78846d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:57:15 -1000 Subject: [PATCH 18/19] [binary_sensor] Use std::array in MultiClickTrigger (#15267) --- esphome/components/binary_sensor/__init__.py | 13 ++++++--- .../components/binary_sensor/automation.cpp | 18 ++++++------- esphome/components/binary_sensor/automation.h | 27 ++++++++++++++++--- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 8d072904b07..660f75ccd9e 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -124,9 +124,10 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa DoubleClickTrigger = binary_sensor_ns.class_( "DoubleClickTrigger", automation.Trigger.template() ) -MultiClickTrigger = binary_sensor_ns.class_( - "MultiClickTrigger", automation.Trigger.template(), cg.Component +MultiClickTriggerBase = binary_sensor_ns.class_( + "MultiClickTriggerBase", automation.Trigger.template(), cg.Component ) +MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") BinarySensorPublishAction = binary_sensor_ns.class_( @@ -484,7 +485,9 @@ _BINARY_SENSOR_SCHEMA = ( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MultiClickTrigger), cv.Required(CONF_TIMING): cv.All( - [parse_multi_click_timing_str], validate_multi_click_timing + [parse_multi_click_timing_str], + validate_multi_click_timing, + cv.Length(min=1, max=255), ), cv.Optional( CONF_INVALID_COOLDOWN, default="1s" @@ -561,7 +564,9 @@ async def _build_binary_sensor_automations(var, config): ) for tim in conf[CONF_TIMING] ] - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, timings) + trigger = cg.new_Pvariable( + conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings + ) if CONF_INVALID_COOLDOWN in conf: cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN])) await cg.register_component(trigger, conf) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index 7e43d42357c..eb68abce3b7 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -13,7 +13,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1; constexpr uint32_t MULTICLICK_IS_VALID_ID = 2; constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3; -void MultiClickTrigger::on_state_(bool state) { +void MultiClickTriggerBase::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { return; @@ -32,7 +32,7 @@ void MultiClickTrigger::on_state_(bool state) { ESP_LOGV(TAG, "START min=%" PRIu32 " max=%" PRIu32, evt.min_length, evt.max_length); ESP_LOGV(TAG, "Multi Click: Starting multi click action!"); this->at_index_ = 1; - if (this->timing_.size() == 1 && evt.max_length == 4294967294UL) { + if (this->timing_count_ == 1 && evt.max_length == 4294967294UL) { this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); } else { this->schedule_is_valid_(evt.min_length); @@ -50,7 +50,7 @@ void MultiClickTrigger::on_state_(bool state) { return; } - if (*this->at_index_ == this->timing_.size()) { + if (*this->at_index_ == this->timing_count_) { this->trigger_(); return; } @@ -61,7 +61,7 @@ void MultiClickTrigger::on_state_(bool state) { ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, *this->at_index_, evt.min_length, evt.max_length); // NOLINT this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); - } else if (*this->at_index_ + 1 != this->timing_.size()) { + } else if (*this->at_index_ + 1 != this->timing_count_) { ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); @@ -74,7 +74,7 @@ void MultiClickTrigger::on_state_(bool state) { *this->at_index_ = *this->at_index_ + 1; } -void MultiClickTrigger::schedule_cooldown_() { +void MultiClickTriggerBase::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() { @@ -86,7 +86,7 @@ void MultiClickTrigger::schedule_cooldown_() { this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); } -void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { +void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { this->is_valid_ = true; return; @@ -97,19 +97,19 @@ void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { this->is_valid_ = true; }); } -void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { +void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS"); this->is_valid_ = false; this->schedule_cooldown_(); }); } -void MultiClickTrigger::cancel() { +void MultiClickTriggerBase::cancel() { ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled."); this->is_valid_ = false; this->schedule_cooldown_(); } -void MultiClickTrigger::trigger_() { +void MultiClickTriggerBase::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); this->at_index_.reset(); this->cancel_timeout(MULTICLICK_TRIGGER_ID); diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index f30f9d32792..1875910affd 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -89,10 +90,10 @@ class DoubleClickTrigger : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -class MultiClickTrigger : public Trigger<>, public Component { +/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header). +class MultiClickTriggerBase : public Trigger<>, public Component { public: - explicit MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) - : parent_(parent), timing_(timing) {} + explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {} void setup() override { this->last_state_ = this->parent_->get_state_default(false); @@ -104,6 +105,8 @@ class MultiClickTrigger : public Trigger<>, public Component { void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; } void cancel(); + MultiClickTriggerBase(const MultiClickTriggerBase &) = delete; + MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete; protected: void on_state_(bool state); @@ -113,14 +116,30 @@ class MultiClickTrigger : public Trigger<>, public Component { void trigger_(); BinarySensor *parent_; - FixedVector timing_; + const MultiClickTriggerEvent *timing_{nullptr}; uint32_t invalid_cooldown_{1000}; optional at_index_{}; + uint8_t timing_count_{0}; bool last_state_{false}; bool is_in_cooldown_{false}; bool is_valid_{false}; }; +/// Template wrapper that provides inline std::array storage for timing events. +/// N is set by code generation to match the exact number of timing events configured in YAML. +template class MultiClickTrigger : public MultiClickTriggerBase { + public: + MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) + : MultiClickTriggerBase(parent) { + init_array_from(this->timing_storage_, timing); + this->timing_ = this->timing_storage_.data(); + this->timing_count_ = N; + } + + protected: + std::array timing_storage_{}; +}; + class StateTrigger : public Trigger { public: explicit StateTrigger(BinarySensor *parent) { From 3ad35a1b60fece06a0c39c326eadc122c9867976 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 15:06:23 -1000 Subject: [PATCH 19/19] fix refactoring error --- esphome/components/api/api_frame_helper.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index b8e65e655c0..06fcb5beece 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -135,6 +135,9 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin if (sent == static_cast(total_write_len)) return APIError::OK; // Partial write or -1: fall through to error check / enqueue below + } else { + // Overflow backlog remains after drain; skip socket write, enqueue everything + sent = 0; } } // WRITE_FAILED (-1): fast path or retry write returned -1, check errno