From 894ba341ba23f64b7e7e87b0982824c0651e398d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 12:56:25 -0600 Subject: [PATCH] [sensor] Replace timeout filter scheduler with loop-based implementation --- esphome/components/sensor/__init__.py | 8 +++++ esphome/components/sensor/filter.cpp | 43 ++++++++++++++++++++------ esphome/components/sensor/filter.h | 44 ++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index e8fec222a1..f7d7513b7b 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -271,6 +271,9 @@ ThrottleWithPriorityFilter = sensor_ns.class_( "ThrottleWithPriorityFilter", ValueListFilter ) TimeoutFilter = sensor_ns.class_("TimeoutFilter", Filter, cg.Component) +TimeoutFilterConfigured = sensor_ns.class_( + "TimeoutFilterConfigured", Filter, cg.Component +) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component) HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) @@ -684,8 +687,13 @@ TIMEOUT_SCHEMA = cv.maybe_simple_value( @FILTER_REGISTRY.register("timeout", TimeoutFilter, TIMEOUT_SCHEMA) async def timeout_filter_to_code(config, filter_id): if config[CONF_VALUE] == "last": + # Use TimeoutFilter for "last" mode (smaller, more common - LD2450, LD2412, etc.) var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) else: + # Use TimeoutFilterConfigured for configured value mode + # Change the type to TimeoutFilterConfigured (similar to stateless lambda pattern) + filter_id = filter_id.copy() + filter_id.type = TimeoutFilterConfigured template_ = await cg.templatable(config[CONF_VALUE], [], float) var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) await cg.register_component(var, {}) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 65d8dea31c..6825dfebd0 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -339,20 +339,43 @@ void OrFilter::initialize(Sensor *parent, Filter *next) { this->phi_.initialize(parent, nullptr); } -// TimeoutFilter -optional TimeoutFilter::new_value(float value) { - if (this->value_.has_value()) { - this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_.value().value()); }); - } else { - this->set_timeout("timeout", this->time_period_, [this, value]() { this->output(value); }); +// TimeoutFilterBase - shared loop logic +void TimeoutFilterBase::loop() { + // Check if timeout period has elapsed + // Use cached loop start time to avoid repeated millis() calls + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->timeout_start_time_ >= this->time_period_) { + // Timeout fired - get output value from derived class and output it + this->output(this->get_output_value()); + + // Disable loop until next value arrives + this->disable_loop(); } +} + +float TimeoutFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } + +// TimeoutFilter - "last" mode implementation +optional TimeoutFilter::new_value(float value) { + // Store the value to output when timeout fires + this->pending_value_ = value; + + // Record when timeout started and enable loop + this->timeout_start_time_ = millis(); + this->enable_loop(); + return value; } -TimeoutFilter::TimeoutFilter(uint32_t time_period) : time_period_(time_period) {} -TimeoutFilter::TimeoutFilter(uint32_t time_period, const TemplatableValue &new_value) - : time_period_(time_period), value_(new_value) {} -float TimeoutFilter::get_setup_priority() const { return setup_priority::HARDWARE; } +// TimeoutFilterConfigured - configured value mode implementation +optional TimeoutFilterConfigured::new_value(float value) { + // Record when timeout started and enable loop + // Note: we don't store the incoming value since we have a configured value + this->timeout_start_time_ = millis(); + this->enable_loop(); + + return value; +} // DebounceFilter optional DebounceFilter::new_value(float value) { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 75e28a1efe..102ae2fea6 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -380,18 +380,46 @@ class ThrottleWithPriorityFilter : public ValueListFilter { uint32_t min_time_between_inputs_; }; -class TimeoutFilter : public Filter, public Component { +// Base class for timeout filters - contains common loop logic +class TimeoutFilterBase : public Filter, public Component { public: - explicit TimeoutFilter(uint32_t time_period); - explicit TimeoutFilter(uint32_t time_period, const TemplatableValue &new_value); - - optional new_value(float value) override; - + void loop() override; float get_setup_priority() const override; protected: - uint32_t time_period_; - optional> value_; + explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); } + virtual float get_output_value() = 0; + + uint32_t time_period_; // 4 bytes (timeout duration in ms) + uint32_t timeout_start_time_{0}; // 4 bytes (when the timeout was started) + // Total base: 8 bytes +}; + +// Timeout filter for "last" mode - outputs the last received value after timeout +class TimeoutFilter : public TimeoutFilterBase { + public: + explicit TimeoutFilter(uint32_t time_period) : TimeoutFilterBase(time_period) {} + + optional new_value(float value) override; + + protected: + float get_output_value() override { return this->pending_value_; } + float pending_value_{0}; // 4 bytes (value to output when timeout fires) + // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead +}; + +// Timeout filter with configured value - evaluates TemplatableValue after timeout +class TimeoutFilterConfigured : public TimeoutFilterBase { + public: + explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableValue &new_value) + : TimeoutFilterBase(time_period), value_(new_value) {} + + optional new_value(float value) override; + + protected: + float get_output_value() override { return this->value_.value(); } + TemplatableValue value_; // 16 bytes (configured output value, can be lambda) + // Total: 8 (base) + 16 = 24 bytes + vtable ptr + Component overhead }; class DebounceFilter : public Filter, public Component {