mirror of
https://github.com/esphome/esphome.git
synced 2026-06-01 09:25:09 +08:00
[binary_sensor] Drop Component from AutorepeatFilter, use self-keyed scheduler (#16191)
This commit is contained in:
@@ -148,7 +148,7 @@ DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter)
|
|||||||
DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter)
|
DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter)
|
||||||
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter)
|
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter)
|
||||||
InvertFilter = binary_sensor_ns.class_("InvertFilter", 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)
|
LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter)
|
||||||
StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter)
|
StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter)
|
||||||
SettleFilter = binary_sensor_ns.class_("SettleFilter", 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)
|
return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings)
|
||||||
await cg.register_component(var, {})
|
|
||||||
return var
|
|
||||||
|
|
||||||
|
|
||||||
@register_filter("lambda", LambdaFilter, cv.returning_lambda)
|
@register_filter("lambda", LambdaFilter, cv.returning_lambda)
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ namespace esphome::binary_sensor {
|
|||||||
|
|
||||||
static const char *const TAG = "sensor.filter";
|
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) {
|
void Filter::output(bool value) {
|
||||||
if (this->next_ == nullptr) {
|
if (this->next_ == nullptr) {
|
||||||
this->parent_->send_state_internal(value);
|
this->parent_->send_state_internal(value);
|
||||||
@@ -69,6 +64,10 @@ optional<bool> DelayedOffFilter::new_value(bool value) {
|
|||||||
optional<bool> InvertFilter::new_value(bool value) { return !value; }
|
optional<bool> InvertFilter::new_value(bool value) { return !value; }
|
||||||
|
|
||||||
// AutorepeatFilterBase
|
// 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<bool> AutorepeatFilterBase::new_value(bool value) {
|
optional<bool> AutorepeatFilterBase::new_value(bool value) {
|
||||||
if (value) {
|
if (value) {
|
||||||
if (this->active_timing_ != 0)
|
if (this->active_timing_ != 0)
|
||||||
@@ -76,8 +75,8 @@ optional<bool> AutorepeatFilterBase::new_value(bool value) {
|
|||||||
this->next_timing_();
|
this->next_timing_();
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
this->cancel_timeout(AUTOREPEAT_TIMING_ID);
|
App.scheduler.cancel_timeout(this);
|
||||||
this->cancel_timeout(AUTOREPEAT_ON_OFF_ID);
|
App.scheduler.cancel_timeout(&this->active_timing_);
|
||||||
this->active_timing_ = 0;
|
this->active_timing_ = 0;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -85,8 +84,7 @@ optional<bool> AutorepeatFilterBase::new_value(bool value) {
|
|||||||
|
|
||||||
void AutorepeatFilterBase::next_timing_() {
|
void AutorepeatFilterBase::next_timing_() {
|
||||||
if (this->active_timing_ < this->timings_count_) {
|
if (this->active_timing_ < this->timings_count_) {
|
||||||
this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay,
|
App.scheduler.set_timeout(this, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); });
|
||||||
[this]() { this->next_timing_(); });
|
|
||||||
}
|
}
|
||||||
if (this->active_timing_ <= this->timings_count_) {
|
if (this->active_timing_ <= this->timings_count_) {
|
||||||
this->active_timing_++;
|
this->active_timing_++;
|
||||||
@@ -98,12 +96,10 @@ void AutorepeatFilterBase::next_timing_() {
|
|||||||
void AutorepeatFilterBase::next_value_(bool val) {
|
void AutorepeatFilterBase::next_value_(bool val) {
|
||||||
const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2];
|
const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2];
|
||||||
this->output(val);
|
this->output(val);
|
||||||
this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off,
|
App.scheduler.set_timeout(&this->active_timing_, val ? timing.time_on : timing.time_off,
|
||||||
[this, val]() { this->next_value_(!val); });
|
[this, val]() { this->next_value_(!val); });
|
||||||
}
|
}
|
||||||
|
|
||||||
float AutorepeatFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; }
|
|
||||||
|
|
||||||
LambdaFilter::LambdaFilter(std::function<optional<bool>(bool)> f) : f_(std::move(f)) {}
|
LambdaFilter::LambdaFilter(std::function<optional<bool>(bool)> f) : f_(std::move(f)) {}
|
||||||
|
|
||||||
optional<bool> LambdaFilter::new_value(bool value) { return this->f_(value); }
|
optional<bool> LambdaFilter::new_value(bool value) { return this->f_(value); }
|
||||||
|
|||||||
@@ -84,10 +84,11 @@ struct AutorepeatFilterTiming {
|
|||||||
|
|
||||||
/// Non-template base for AutorepeatFilter — all methods in filter.cpp.
|
/// Non-template base for AutorepeatFilter — all methods in filter.cpp.
|
||||||
/// Lambdas capture this base pointer, so set_timeout/cancel_timeout are instantiated once.
|
/// 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:
|
public:
|
||||||
optional<bool> new_value(bool value) override;
|
optional<bool> new_value(bool value) override;
|
||||||
float get_setup_priority() const override;
|
|
||||||
AutorepeatFilterBase(const AutorepeatFilterBase &) = delete;
|
AutorepeatFilterBase(const AutorepeatFilterBase &) = delete;
|
||||||
AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete;
|
AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user