[sensor] Merge the radar timeout and throttle filters into one (#20118)

This commit is contained in:
J. Nick Koston
2026-10-09 09:19:22 -10:00
committed by GitHub
parent 456e3d8979
commit 94e6bd90d8
11 changed files with 333 additions and 274 deletions
+8 -72
View File
@@ -34,83 +34,35 @@ CONFIG_SCHEMA = cv.Schema(
cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component),
cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_SIGNAL,
unit_of_measurement=UNIT_CENTIMETER,
),
cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_SIGNAL,
unit_of_measurement=UNIT_CENTIMETER,
),
cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema(
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_MOTION_SENSOR,
unit_of_measurement=UNIT_PERCENT,
),
cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema(
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_FLASH,
unit_of_measurement=UNIT_PERCENT,
),
cv.Optional(CONF_LIGHT): sensor.sensor_schema(
device_class=DEVICE_CLASS_ILLUMINANCE,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_LIGHTBULB,
),
cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_SIGNAL,
unit_of_measurement=UNIT_CENTIMETER,
),
@@ -123,29 +75,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
{
cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_MOTION_SENSOR,
unit_of_measurement=UNIT_PERCENT,
),
cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_FLASH,
unit_of_measurement=UNIT_PERCENT,
),
+8 -72
View File
@@ -34,84 +34,36 @@ CONFIG_SCHEMA = cv.Schema(
cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component),
cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_SIGNAL,
unit_of_measurement=UNIT_CENTIMETER,
),
cv.Optional(CONF_LIGHT): sensor.sensor_schema(
device_class=DEVICE_CLASS_ILLUMINANCE,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_LIGHTBULB,
unit_of_measurement=UNIT_EMPTY, # No standard unit for this light sensor
),
cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_SIGNAL,
unit_of_measurement=UNIT_CENTIMETER,
),
cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema(
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_MOTION_SENSOR,
unit_of_measurement=UNIT_PERCENT,
),
cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_SIGNAL,
unit_of_measurement=UNIT_CENTIMETER,
),
cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema(
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_FLASH,
unit_of_measurement=UNIT_PERCENT,
),
@@ -124,29 +76,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
{
cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_MOTION_SENSOR,
unit_of_measurement=UNIT_PERCENT,
),
cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_FLASH,
unit_of_measurement=UNIT_PERCENT,
),
+12 -108
View File
@@ -46,41 +46,17 @@ CONFIG_SCHEMA = cv.Schema(
cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component),
cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_ACCOUNT_GROUP,
),
cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_HUMAN_GREETING_PROXIMITY,
),
cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_ACCOUNT_SWITCH,
),
}
@@ -92,85 +68,37 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
{
cv.Optional(CONF_X): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_ALPHA_X_BOX_OUTLINE,
unit_of_measurement=UNIT_MILLIMETER,
),
cv.Optional(CONF_Y): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_ALPHA_Y_BOX_OUTLINE,
unit_of_measurement=UNIT_MILLIMETER,
),
cv.Optional(CONF_SPEED): sensor.sensor_schema(
device_class=DEVICE_CLASS_SPEED,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_SPEEDOMETER_SLOW,
unit_of_measurement=UNIT_MILLIMETER_PER_SECOND,
),
cv.Optional(CONF_ANGLE): sensor.sensor_schema(
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP,
unit_of_measurement=UNIT_DEGREES,
accuracy_decimals=1,
),
cv.Optional(CONF_DISTANCE): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_MAP_MARKER_DISTANCE,
unit_of_measurement=UNIT_MILLIMETER,
),
cv.Optional(CONF_RESOLUTION): sensor.sensor_schema(
device_class=DEVICE_CLASS_DISTANCE,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_RELATION_ZERO_OR_ONE_TO_ZERO_OR_ONE,
unit_of_measurement=UNIT_MILLIMETER,
),
@@ -183,41 +111,17 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
{
cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_MAP_MARKER_ACCOUNT,
),
cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_MAP_MARKER_ACCOUNT,
),
cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
filters=[
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
],
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
icon=ICON_MAP_MARKER_ACCOUNT,
),
}
+4 -16
View File
@@ -30,44 +30,32 @@ from .const import (
DEPENDENCIES = ["ld6002b"]
# The ld2450 defaults for a streamed value: hold the last reading for a second so a
# dropped frame does not read as absence, then rate-limit what reaches the frontend.
_VALUE_SENSOR_FILTERS = [
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
]
TARGET_SCHEMA = cv.Schema(
{
cv.Optional(CONF_X): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
filters=_VALUE_SENSOR_FILTERS,
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_Y): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
filters=_VALUE_SENSOR_FILTERS,
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_Z): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
filters=_VALUE_SENSOR_FILTERS,
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema(
accuracy_decimals=0,
filters=_VALUE_SENSOR_FILTERS,
filters=sensor.TIMEOUT_THROTTLE_FILTERS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema(
+55 -2
View File
@@ -1,5 +1,6 @@
import logging
import math
from typing import Any
from esphome import automation
import esphome.codegen as cg
@@ -43,6 +44,7 @@ from esphome.const import (
CONF_TIMEOUT,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE,
CONF_WEB_SERVER,
@@ -222,6 +224,13 @@ def validate_send_first_at(value):
FILTER_REGISTRY = Registry()
validate_filters = cv.validate_registry("filter", FILTER_REGISTRY)
# Default for streamed readings (radar sensors): hold the last value for a second so a dropped
# frame doesn't read as absence, then rate limit. Codegen turns the pair into one TimeoutThrottleFilter.
TIMEOUT_THROTTLE_FILTERS = [
{"timeout": {"timeout": cv.TimePeriod(milliseconds=1000), "value": "last"}},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
]
def validate_datapoint(value):
if isinstance(value, dict):
@@ -293,6 +302,7 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_(
TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase)
TimeoutThrottleFilter = sensor_ns.class_("TimeoutThrottleFilter", TimeoutFilterLast)
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter)
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter)
DeltaFilter = sensor_ns.class_("DeltaFilter", Filter)
@@ -683,6 +693,12 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value(
)
def _is_nan_only(values: Any) -> bool:
if not isinstance(values, list):
values = [values]
return bool(values) and all(isinstance(v, float) and math.isnan(v) for v in values)
@FILTER_REGISTRY.register(
"throttle_with_priority",
ThrottleWithPriorityFilter,
@@ -696,7 +712,7 @@ async def throttle_with_priority_filter_to_code(config, filter_id):
# omits `value:`) to avoid the TemplatableFn<float> array + NaN lambda the
# generic ValueListFilter path requires. Behavior is identical: NaN sensor
# readings always bypass the throttle.
if values and all(isinstance(v, float) and math.isnan(v) for v in values):
if _is_nan_only(values):
filter_id = filter_id.copy()
filter_id.type = ThrottleWithPriorityNanFilter
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT])
@@ -941,6 +957,43 @@ async def build_filters(config):
return await cg.build_registry_list(FILTER_REGISTRY, config)
def _timeout_throttle_period(
first: ConfigType, second: ConfigType
) -> cv.TimePeriod | None:
"""Period of a `timeout` (value `last`) directly followed by a NaN only `throttle_with_priority`."""
timeout = first.get("timeout")
throttle = second.get("throttle_with_priority")
if not isinstance(timeout, dict) or not isinstance(throttle, dict):
return None
if timeout[CONF_VALUE] != "last" or timeout[CONF_TIMEOUT] != throttle[CONF_TIMEOUT]:
return None
if not _is_nan_only(throttle[CONF_VALUE]):
return None
return timeout[CONF_TIMEOUT]
async def _build_chain_filters(config: list[ConfigType]) -> list:
"""Like build_filters, but merges the radar `timeout` + `throttle_with_priority` pair into one filter."""
filters = []
i = 0
while i < len(config):
if (
i + 1 < len(config)
and (period := _timeout_throttle_period(config[i], config[i + 1]))
is not None
):
filter_id = config[i][CONF_TYPE_ID].copy()
filter_id.type = TimeoutThrottleFilter
var = cg.new_Pvariable(filter_id, period)
await cg.register_component(var, {})
filters.append(var)
i += 2
continue
filters.append(await cg.build_registry_entry(FILTER_REGISTRY, config[i]))
i += 1
return filters
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_VALUE, "add_on_state_callback", [(float, "x")]
@@ -979,7 +1032,7 @@ async def setup_sensor_core_(var, config):
cg.add(var.set_force_update(True))
if config.get(CONF_FILTERS): # must exist and not be empty
cg.add_define("USE_SENSOR_FILTER")
filters = await build_filters(config[CONF_FILTERS])
filters = await _build_chain_filters(config[CONF_FILTERS])
cg.add(var.set_filters(filters))
CORE.add_job(_build_sensor_automations, var, config)
+29 -4
View File
@@ -13,6 +13,17 @@ namespace esphome::sensor {
ESPHOME_LOG_TAG(TAG, "sensor.filter");
/// Shared pass check for throttle_with_priority (NaN only): passes and stamps `last_input` when the
/// period has elapsed, on the first value, or for NaN.
static bool throttle_nan_passes(uint32_t &last_input, uint32_t period, float value) {
const uint32_t now = App.get_loop_component_start_time();
if (last_input == 0 || now - last_input >= period || std::isnan(value)) {
last_input = now;
return true;
}
return false;
}
// Filter
void Filter::input(float value) {
ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value);
@@ -266,11 +277,8 @@ optional<float> throttle_with_priority_new_value(Sensor *parent, float value, co
ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs)
: min_time_between_inputs_(min_time_between_inputs) {}
optional<float> ThrottleWithPriorityNanFilter::new_value(float value) {
const uint32_t now = App.get_loop_component_start_time();
if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) {
this->last_input_ = now;
if (throttle_nan_passes(this->last_input_, this->min_time_between_inputs_, value))
return value;
}
return {};
}
@@ -355,6 +363,23 @@ optional<float> TimeoutFilterConfigured::new_value(float value) {
return value;
}
// TimeoutThrottleFilter
optional<float> TimeoutThrottleFilter::new_value(float value) {
TimeoutFilterLast::new_value(value);
if (throttle_nan_passes(this->last_input_, this->time_period_, value))
return value;
return {};
}
void TimeoutThrottleFilter::loop() {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->timeout_start_time_ >= this->time_period_) {
if (throttle_nan_passes(this->last_input_, this->time_period_, this->pending_value_))
this->output(this->pending_value_);
this->disable_loop();
}
}
// DebounceFilter
optional<float> DebounceFilter::new_value(float value) {
App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); });
+13
View File
@@ -479,6 +479,19 @@ class TimeoutFilterConfigured : public TimeoutFilterBase {
// Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead
};
/// Codegen use only: `timeout` (value `last`) followed by `throttle_with_priority` (NaN only) with the
/// same period, as one object. Output matches the two filter chain exactly.
class TimeoutThrottleFilter : public TimeoutFilterLast {
public:
explicit TimeoutThrottleFilter(uint32_t time_period) : TimeoutFilterLast(time_period) {}
optional<float> new_value(float value) override;
void loop() override;
protected:
uint32_t last_input_{0}; // When the throttle stage last let a value through
};
class DebounceFilter : public Filter {
public:
explicit DebounceFilter(uint32_t time_period);
@@ -0,0 +1,24 @@
"""timeout (value last) followed by throttle_with_priority with the same period becomes one filter."""
from collections.abc import Callable
from pathlib import Path
import re
def test_timeout_throttle_pair_is_merged(
generate_main: Callable[[str | Path], str],
) -> None:
main_cpp = generate_main("tests/component_tests/sensor/timeout_throttle.yaml")
# Only the `merged` sensor's adjacent pair becomes one object
assert (
len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp))
== 1
)
merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp)
assert merged is not None
assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp
# Every other shape keeps its two filters
for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"):
call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp)
assert call is not None and len(call.group(1).split(",")) == 2, sensor_id
@@ -0,0 +1,52 @@
esphome:
name: test
esp32:
board: esp32dev
sensor:
- platform: template
id: merged
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 1s
- platform: template
id: other_periods
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 2s
- platform: template
id: other_order
filters:
- throttle_with_priority: 1s
- timeout:
timeout: 1s
value: last
- platform: template
id: value_list
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority:
timeout: 1s
value: [0.0]
- platform: template
id: configured_value
filters:
- timeout:
timeout: 1s
value: 0.0
- throttle_with_priority: 1s
- platform: template
id: inside_or
filters:
- or:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 1s
@@ -0,0 +1,67 @@
esphome:
name: test-timeout-throttle
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Source"
id: source_sensor
accuracy_decimals: 2
# The default radar pair; codegen merges it into one TimeoutThrottleFilter
- platform: copy
source_id: source_sensor
name: "Merged"
filters:
- timeout:
timeout: 200ms
value: last
- throttle_with_priority: 200ms
# Same chain with an identity filter in between, which keeps the two filter objects
- platform: copy
source_id: source_sensor
name: "Reference"
filters:
- timeout:
timeout: 200ms
value: last
- multiply: 1.0
- throttle_with_priority: 200ms
script:
- id: drive
then:
# Burst faster than the period: first passes, the rest are dropped, the timeout republishes the last
- sensor.template.publish: {id: source_sensor, state: 1.0}
- delay: 30ms
- sensor.template.publish: {id: source_sensor, state: 2.0}
- delay: 30ms
- sensor.template.publish: {id: source_sensor, state: 3.0}
- delay: 450ms
# NaN inside a burst always passes
- sensor.template.publish: {id: source_sensor, state: 4.0}
- delay: 30ms
- sensor.template.publish: {id: source_sensor, state: !lambda "return NAN;"}
- delay: 30ms
- sensor.template.publish: {id: source_sensor, state: 5.0}
- delay: 450ms
# Values right around the period
- sensor.template.publish: {id: source_sensor, state: 6.0}
- delay: 250ms
- sensor.template.publish: {id: source_sensor, state: 7.0}
- delay: 190ms
- sensor.template.publish: {id: source_sensor, state: 8.0}
- delay: 600ms
- sensor.template.publish: {id: source_sensor, state: 99.0}
button:
- platform: template
name: "Drive"
on_press:
- script.execute: drive
@@ -0,0 +1,61 @@
"""The merged timeout + throttle_with_priority filter publishes exactly what the two filter chain does."""
from __future__ import annotations
import asyncio
import math
from aioesphomeapi import ButtonInfo, 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_throttle_filter(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
loop = asyncio.get_running_loop()
values: dict[str, list[float]] = {"merged": [], "reference": []}
done = {name: loop.create_future() for name in values}
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState):
return
name = key_to_sensor.get(state.key)
if name not in values:
return
values[name].append(math.nan if state.missing_state else state.state)
# 99 is published last and republished once by the timeout
if values[name].count(99.0) == 2 and not done[name].done():
done[name].set_result(True)
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
key_to_sensor = build_key_to_entity_mapping(
entities, {"merged": "Merged", "reference": "Reference"}
)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
button = next(
e.key for e in entities if isinstance(e, ButtonInfo) and e.name == "Drive"
)
client.button_command(button)
try:
await asyncio.wait_for(asyncio.gather(*done.values()), timeout=5.0)
except TimeoutError:
pytest.fail(f"Timed out: {values}")
merged, reference = values["merged"], values["reference"]
assert len(merged) == len(reference), (merged, reference)
for got, want in zip(merged, reference, strict=True):
assert (math.isnan(got) and math.isnan(want)) or got == want, (
merged,
reference,
)
assert any(math.isnan(v) for v in merged)