1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-15 22:35:46 +00:00

Compare commits

..

6 Commits

Author SHA1 Message Date
J. Nick Koston
6a05c64e2a Merge branch 'sensor_timeout_filter' into timeout_filter_scheduler_churn_fix 2025-11-15 13:11:37 -06:00
J. Nick Koston
af77dfeacc helper 2025-11-15 13:11:04 -06:00
J. Nick Koston
aca74e34b8 Add tests for sensor timeout filters
ahead of optimization effort in https://github.com/esphome/esphome/pull/11922
2025-11-15 13:07:56 -06:00
J. Nick Koston
6f5f45f1e9 cover 2025-11-15 13:06:54 -06:00
J. Nick Koston
6cca3617d8 cover 2025-11-15 13:06:06 -06:00
J. Nick Koston
894ba341ba [sensor] Replace timeout filter scheduler with loop-based implementation 2025-11-15 12:56:25 -06:00
5 changed files with 412 additions and 18 deletions

View File

@@ -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, {})

View File

@@ -339,20 +339,43 @@ void OrFilter::initialize(Sensor *parent, Filter *next) {
this->phi_.initialize(parent, nullptr);
}
// TimeoutFilter
optional<float> 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<float> 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<float> &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<float> 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<float> DebounceFilter::new_value(float value) {

View File

@@ -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<float> &new_value);
optional<float> new_value(float value) override;
void loop() override;
float get_setup_priority() const override;
protected:
uint32_t time_period_;
optional<TemplatableValue<float>> 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<float> 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<float> &new_value)
: TimeoutFilterBase(time_period), value_(new_value) {}
optional<float> new_value(float value) override;
protected:
float get_output_value() override { return this->value_.value(); }
TemplatableValue<float> 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 {

View File

@@ -0,0 +1,150 @@
esphome:
name: test-timeout-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template sensors that we'll use to publish values
sensor:
- platform: template
name: "Source Timeout Last"
id: source_timeout_last
accuracy_decimals: 1
- platform: template
name: "Source Timeout Reset"
id: source_timeout_reset
accuracy_decimals: 1
- platform: template
name: "Source Timeout Static"
id: source_timeout_static
accuracy_decimals: 1
- platform: template
name: "Source Timeout Lambda"
id: source_timeout_lambda
accuracy_decimals: 1
# Test 1: TimeoutFilter - "last" mode (outputs last received value)
- platform: copy
source_id: source_timeout_last
name: "Timeout Last Sensor"
id: timeout_last_sensor
filters:
- timeout:
timeout: 100ms
value: last # Explicitly specify "last" mode to use TimeoutFilter class
# Test 2: TimeoutFilter - reset behavior (same filter, different source)
- platform: copy
source_id: source_timeout_reset
name: "Timeout Reset Sensor"
id: timeout_reset_sensor
filters:
- timeout:
timeout: 100ms
value: last # Explicitly specify "last" mode
# Test 3: TimeoutFilterConfigured - static value mode
- platform: copy
source_id: source_timeout_static
name: "Timeout Static Sensor"
id: timeout_static_sensor
filters:
- timeout:
timeout: 100ms
value: 99.9
# Test 4: TimeoutFilterConfigured - lambda mode
- platform: copy
source_id: source_timeout_lambda
name: "Timeout Lambda Sensor"
id: timeout_lambda_sensor
filters:
- timeout:
timeout: 100ms
value: !lambda "return -1.0;"
# Scripts to publish values with controlled timing
script:
# Test 1: Single value followed by timeout
- id: test_timeout_last_script
then:
# Publish initial value
- sensor.template.publish:
id: source_timeout_last
state: 42.0
# Wait for timeout to fire (100ms + margin)
- delay: 150ms
# Test 2: Multiple values before timeout (should reset timer)
- id: test_timeout_reset_script
then:
# Publish first value
- sensor.template.publish:
id: source_timeout_reset
state: 10.0
# Wait 50ms (halfway to timeout)
- delay: 50ms
# Publish second value (resets timeout)
- sensor.template.publish:
id: source_timeout_reset
state: 20.0
# Wait 50ms (halfway to timeout again)
- delay: 50ms
# Publish third value (resets timeout)
- sensor.template.publish:
id: source_timeout_reset
state: 30.0
# Wait for timeout to fire (100ms + margin)
- delay: 150ms
# Test 3: Static value timeout
- id: test_timeout_static_script
then:
# Publish initial value
- sensor.template.publish:
id: source_timeout_static
state: 55.5
# Wait for timeout to fire
- delay: 150ms
# Test 4: Lambda value timeout
- id: test_timeout_lambda_script
then:
# Publish initial value
- sensor.template.publish:
id: source_timeout_lambda
state: 77.7
# Wait for timeout to fire
- delay: 150ms
# Buttons to trigger each test scenario
button:
- platform: template
name: "Test Timeout Last Button"
id: test_timeout_last_button
on_press:
- script.execute: test_timeout_last_script
- platform: template
name: "Test Timeout Reset Button"
id: test_timeout_reset_button
on_press:
- script.execute: test_timeout_reset_script
- platform: template
name: "Test Timeout Static Button"
id: test_timeout_static_button
on_press:
- script.execute: test_timeout_static_script
- platform: template
name: "Test Timeout Lambda Button"
id: test_timeout_lambda_button
on_press:
- script.execute: test_timeout_lambda_script

View File

@@ -0,0 +1,185 @@
"""Test sensor timeout filter functionality."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_sensor_timeout_filter(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test TimeoutFilter and TimeoutFilterConfigured with all modes."""
loop = asyncio.get_running_loop()
# Track state changes for all sensors
timeout_last_states: list[float] = []
timeout_reset_states: list[float] = []
timeout_static_states: list[float] = []
timeout_lambda_states: list[float] = []
# Futures for each test scenario
test1_complete = loop.create_future() # TimeoutFilter - last mode
test2_complete = loop.create_future() # TimeoutFilter - reset behavior
test3_complete = loop.create_future() # TimeoutFilterConfigured - static value
test4_complete = loop.create_future() # TimeoutFilterConfigured - lambda
def on_state(state: EntityState) -> None:
"""Track sensor state updates."""
if not isinstance(state, SensorState):
return
if state.missing_state:
return
sensor_name = key_to_sensor.get(state.key)
# Test 1: TimeoutFilter - last mode
if sensor_name == "timeout_last_sensor":
timeout_last_states.append(state.state)
# Expect 2 values: initial 42.0 + timeout fires with 42.0
if len(timeout_last_states) >= 2 and not test1_complete.done():
test1_complete.set_result(True)
# Test 2: TimeoutFilter - reset behavior
elif sensor_name == "timeout_reset_sensor":
timeout_reset_states.append(state.state)
# Expect 4 values: 10.0, 20.0, 30.0, then timeout fires with 30.0
if len(timeout_reset_states) >= 4 and not test2_complete.done():
test2_complete.set_result(True)
# Test 3: TimeoutFilterConfigured - static value
elif sensor_name == "timeout_static_sensor":
timeout_static_states.append(state.state)
# Expect 2 values: initial 55.5 + timeout fires with 99.9
if len(timeout_static_states) >= 2 and not test3_complete.done():
test3_complete.set_result(True)
# Test 4: TimeoutFilterConfigured - lambda
elif sensor_name == "timeout_lambda_sensor":
timeout_lambda_states.append(state.state)
# Expect 2 values: initial 77.7 + timeout fires with -1.0
if len(timeout_lambda_states) >= 2 and not test4_complete.done():
test4_complete.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, services = await client.list_entities_services()
key_to_sensor = build_key_to_entity_mapping(
entities,
[
"timeout_last_sensor",
"timeout_reset_sensor",
"timeout_static_sensor",
"timeout_lambda_sensor",
],
)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Helper to find buttons by object_id substring
def find_button(object_id_substring: str) -> int:
"""Find a button by object_id substring and return its key."""
button = next(
(e for e in entities if object_id_substring in e.object_id.lower()),
None,
)
assert button is not None, f"Button '{object_id_substring}' not found"
return button.key
# Find all test buttons
test1_button_key = find_button("test_timeout_last_button")
test2_button_key = find_button("test_timeout_reset_button")
test3_button_key = find_button("test_timeout_static_button")
test4_button_key = find_button("test_timeout_lambda_button")
# === Test 1: TimeoutFilter - last mode ===
client.button_command(test1_button_key)
try:
await asyncio.wait_for(test1_complete, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 1 timeout. Received states: {timeout_last_states}")
assert len(timeout_last_states) == 2, (
f"Test 1: Should have 2 states, got {len(timeout_last_states)}: {timeout_last_states}"
)
assert timeout_last_states[0] == pytest.approx(42.0), (
f"Test 1: First state should be 42.0, got {timeout_last_states[0]}"
)
assert timeout_last_states[1] == pytest.approx(42.0), (
f"Test 1: Timeout should output last value (42.0), got {timeout_last_states[1]}"
)
# === Test 2: TimeoutFilter - reset behavior ===
client.button_command(test2_button_key)
try:
await asyncio.wait_for(test2_complete, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 2 timeout. Received states: {timeout_reset_states}")
assert len(timeout_reset_states) == 4, (
f"Test 2: Should have 4 states, got {len(timeout_reset_states)}: {timeout_reset_states}"
)
assert timeout_reset_states[0] == pytest.approx(10.0), (
f"Test 2: First state should be 10.0, got {timeout_reset_states[0]}"
)
assert timeout_reset_states[1] == pytest.approx(20.0), (
f"Test 2: Second state should be 20.0, got {timeout_reset_states[1]}"
)
assert timeout_reset_states[2] == pytest.approx(30.0), (
f"Test 2: Third state should be 30.0, got {timeout_reset_states[2]}"
)
assert timeout_reset_states[3] == pytest.approx(30.0), (
f"Test 2: Timeout should output last value (30.0), got {timeout_reset_states[3]}"
)
# === Test 3: TimeoutFilterConfigured - static value ===
client.button_command(test3_button_key)
try:
await asyncio.wait_for(test3_complete, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 3 timeout. Received states: {timeout_static_states}")
assert len(timeout_static_states) == 2, (
f"Test 3: Should have 2 states, got {len(timeout_static_states)}: {timeout_static_states}"
)
assert timeout_static_states[0] == pytest.approx(55.5), (
f"Test 3: First state should be 55.5, got {timeout_static_states[0]}"
)
assert timeout_static_states[1] == pytest.approx(99.9), (
f"Test 3: Timeout should output configured value (99.9), got {timeout_static_states[1]}"
)
# === Test 4: TimeoutFilterConfigured - lambda ===
client.button_command(test4_button_key)
try:
await asyncio.wait_for(test4_complete, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 4 timeout. Received states: {timeout_lambda_states}")
assert len(timeout_lambda_states) == 2, (
f"Test 4: Should have 2 states, got {len(timeout_lambda_states)}: {timeout_lambda_states}"
)
assert timeout_lambda_states[0] == pytest.approx(77.7), (
f"Test 4: First state should be 77.7, got {timeout_lambda_states[0]}"
)
assert timeout_lambda_states[1] == pytest.approx(-1.0), (
f"Test 4: Timeout should evaluate lambda (-1.0), got {timeout_lambda_states[1]}"
)