mirror of
https://github.com/esphome/esphome.git
synced 2025-11-15 22:35:46 +00:00
Compare commits
6 Commits
dev
...
timeout_fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a05c64e2a | ||
|
|
af77dfeacc | ||
|
|
aca74e34b8 | ||
|
|
6f5f45f1e9 | ||
|
|
6cca3617d8 | ||
|
|
894ba341ba |
@@ -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, {})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
150
tests/integration/fixtures/sensor_timeout_filter.yaml
Normal file
150
tests/integration/fixtures/sensor_timeout_filter.yaml
Normal 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
|
||||
185
tests/integration/test_sensor_timeout_filter.py
Normal file
185
tests/integration/test_sensor_timeout_filter.py
Normal 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]}"
|
||||
)
|
||||
Reference in New Issue
Block a user