diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index db82290750..a9a09363fc 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -148,7 +148,7 @@ DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter) DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter) DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter) InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter) -AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component) +AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter) LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter) StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter) SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter) @@ -282,9 +282,7 @@ async def autorepeat_filter_to_code(config, filter_id): ), ) ] - var = cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings) @register_filter("lambda", LambdaFilter, cv.returning_lambda) diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 0a463ee9a9..8b882212c8 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -10,11 +10,6 @@ namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; -// AutorepeatFilter still inherits Component (it schedules two distinct timer -// purposes), so it keeps the (Component *, id) scheduler API. -constexpr uint32_t AUTOREPEAT_TIMING_ID = 0; -constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1; - void Filter::output(bool value) { if (this->next_ == nullptr) { this->parent_->send_state_internal(value); @@ -69,6 +64,10 @@ optional DelayedOffFilter::new_value(bool value) { optional InvertFilter::new_value(bool value) { return !value; } // AutorepeatFilterBase +// Two independent timers per instance, keyed off two stable addresses inside +// the filter: `this` for the timing-step timer, `&active_timing_` for the +// on/off timer. Both are unique per instance and don't collide with anything +// else, so the self-keyed scheduler API is sufficient. optional AutorepeatFilterBase::new_value(bool value) { if (value) { if (this->active_timing_ != 0) @@ -76,8 +75,8 @@ optional AutorepeatFilterBase::new_value(bool value) { this->next_timing_(); return true; } else { - this->cancel_timeout(AUTOREPEAT_TIMING_ID); - this->cancel_timeout(AUTOREPEAT_ON_OFF_ID); + App.scheduler.cancel_timeout(this); + App.scheduler.cancel_timeout(&this->active_timing_); this->active_timing_ = 0; return false; } @@ -85,8 +84,7 @@ optional AutorepeatFilterBase::new_value(bool value) { 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_(); }); + App.scheduler.set_timeout(this, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); }); } if (this->active_timing_ <= this->timings_count_) { this->active_timing_++; @@ -98,12 +96,10 @@ void AutorepeatFilterBase::next_timing_() { void AutorepeatFilterBase::next_value_(bool val) { const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2]; this->output(val); - this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off, - [this, val]() { this->next_value_(!val); }); + App.scheduler.set_timeout(&this->active_timing_, val ? timing.time_on : timing.time_off, + [this, val]() { this->next_value_(!val); }); } -float AutorepeatFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } - LambdaFilter::LambdaFilter(std::function(bool)> f) : f_(std::move(f)) {} optional LambdaFilter::new_value(bool value) { return this->f_(value); } diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 8ff57cab0c..6887de35e1 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -84,10 +84,11 @@ struct AutorepeatFilterTiming { /// 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 { +/// The two scheduled timers are keyed off `this` and `&active_timing_`; since the address +/// of `active_timing_` is taken as a scheduler key, the class must not be copied or moved. +class AutorepeatFilterBase : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; AutorepeatFilterBase(const AutorepeatFilterBase &) = delete; AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete; diff --git a/tests/integration/fixtures/binary_sensor_autorepeat_filter.yaml b/tests/integration/fixtures/binary_sensor_autorepeat_filter.yaml new file mode 100644 index 0000000000..5799ece00c --- /dev/null +++ b/tests/integration/fixtures/binary_sensor_autorepeat_filter.yaml @@ -0,0 +1,40 @@ +esphome: + name: test-autorepeat-filter + +host: +api: + batch_delay: 0ms # Disable batching to receive every state transition +logger: + level: DEBUG + +binary_sensor: + # The autorepeat filter is applied directly to the template sensor, so each + # write through `binary_sensor.template.publish` runs through the filter + # chain. With the source true the filter must oscillate after `delay`; once + # the source returns to false the filter must cancel both timers and emit a + # final false. + - platform: template + name: "Autorepeat Sensor" + id: autorepeat_sensor + filters: + - autorepeat: + - delay: 200ms + time_off: 100ms + time_on: 100ms + +button: + - platform: template + name: "Press" + id: press_button + on_press: + - binary_sensor.template.publish: + id: autorepeat_sensor + state: true + + - platform: template + name: "Release" + id: release_button + on_press: + - binary_sensor.template.publish: + id: autorepeat_sensor + state: false diff --git a/tests/integration/test_binary_sensor_autorepeat_filter.py b/tests/integration/test_binary_sensor_autorepeat_filter.py new file mode 100644 index 0000000000..443d5293f2 --- /dev/null +++ b/tests/integration/test_binary_sensor_autorepeat_filter.py @@ -0,0 +1,123 @@ +"""Integration test for the binary_sensor autorepeat filter. + +Verifies that the autorepeat filter: + +1. Passes the initial true through unchanged. +2. Begins oscillating after the configured ``delay`` while the source stays true. +3. Stops oscillating and emits a final false when the source goes false. + +This exercises both scheduled timers in ``AutorepeatFilter`` (the per-step +``delay`` timer keyed off the filter ``this`` pointer and the on/off toggle +timer keyed off ``&active_timing_``). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_binary_sensor_autorepeat_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Drive the source true and verify the downstream sensor oscillates.""" + collector = SensorStateCollector( + sensor_names=[], + binary_sensor_names=["autorepeat_sensor"], + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-autorepeat-filter" + + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + + press_button = require_entity(entities, "press", description="Press button") + release_button = require_entity( + entities, "release", description="Release button" + ) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + autorepeat_states = collector.binary_states["autorepeat_sensor"] + + # Press: source becomes true, autorepeat passes the initial true through + # and then oscillates after the configured delay. + # Configured timings: delay=200ms, time_on=100ms, time_off=100ms. + # Expected within ~700ms: + # true (0ms), false (200ms), true (300ms), false (400ms), + # true (500ms), false (600ms) + client.button_command(press_button.key) + + # Wait for at least 5 transitions to verify the oscillation pattern. + oscillation_seen = collector.add_waiter(lambda: len(autorepeat_states) >= 5) + try: + await asyncio.wait_for(oscillation_seen, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Expected at least 5 autorepeat transitions, got {autorepeat_states}" + ) + + assert autorepeat_states[0] is True, ( + f"First transition should be the pass-through true, got {autorepeat_states}" + ) + # After the initial true and the configured delay, the filter must + # toggle false/true/false/... — verify the alternation pattern. + for index, value in enumerate(autorepeat_states): + expected = index % 2 == 0 + assert value is expected, ( + f"Expected alternating values starting with True, " + f"got {autorepeat_states} (mismatch at index {index})" + ) + + # Release: source becomes false, autorepeat must cancel both timers + # and settle on false. If the most recent oscillation was already + # false, the binary sensor will dedup and not emit a new state event; + # if it was true, exactly one final false transition arrives. Either + # way, the steady state must be false and no further toggles should + # arrive after a settle window longer than time_on + time_off. + was_true_before_release = autorepeat_states[-1] is True + before_count = len(autorepeat_states) + client.button_command(release_button.key) + + if was_true_before_release: + settle_seen = collector.add_waiter( + lambda: len(autorepeat_states) > before_count + ) + try: + await asyncio.wait_for(settle_seen, timeout=2.0) + except TimeoutError: + pytest.fail("Timeout waiting for autorepeat to settle to false") + assert autorepeat_states[-1] is False, ( + f"After release, final state should be False, got {autorepeat_states}" + ) + + steady_count = len(autorepeat_states) + await asyncio.sleep(0.5) + assert len(autorepeat_states) == steady_count, ( + f"Expected no further toggles after release, " + f"got {autorepeat_states[steady_count:]}" + ) + assert autorepeat_states[-1] is False, ( + f"Final autorepeat state should be False, got {autorepeat_states}" + )