diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index 5b523d0944..a8636f3df6 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -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, ), diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index e236a7d5f3..f350fb7050 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -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, ), diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index e714d353d1..6f2056330b 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -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, ), } diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index 5653f8b944..d2285187f6 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -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( diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 4a00904c4b..2b404442a4 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -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 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) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 28f7e6100d..732db5b0b4 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -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 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 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 TimeoutFilterConfigured::new_value(float value) { return value; } +// TimeoutThrottleFilter +optional 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 DebounceFilter::new_value(float value) { App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); }); diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index bc086e3805..510ee94c6a 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -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 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); diff --git a/tests/component_tests/sensor/test_timeout_throttle.py b/tests/component_tests/sensor/test_timeout_throttle.py new file mode 100644 index 0000000000..8154471e47 --- /dev/null +++ b/tests/component_tests/sensor/test_timeout_throttle.py @@ -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 diff --git a/tests/component_tests/sensor/timeout_throttle.yaml b/tests/component_tests/sensor/timeout_throttle.yaml new file mode 100644 index 0000000000..2557db683c --- /dev/null +++ b/tests/component_tests/sensor/timeout_throttle.yaml @@ -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 diff --git a/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml b/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml new file mode 100644 index 0000000000..5f1f9556e0 --- /dev/null +++ b/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml @@ -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 diff --git a/tests/integration/test_sensor_timeout_throttle_filter.py b/tests/integration/test_sensor_timeout_throttle_filter.py new file mode 100644 index 0000000000..739421ca3a --- /dev/null +++ b/tests/integration/test_sensor_timeout_throttle_filter.py @@ -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)