Compare commits

..

11 Commits

Author SHA1 Message Date
polyfloyd
3c0f43db9e Add the max_delta filter (#12605)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-01-21 10:58:47 +11:00
Jonathan Swoboda
6edecd3d45 Merge branch 'beta' into dev 2026-01-20 17:01:47 -05:00
Jonathan Swoboda
055c00f1ac Merge pull request #13396 from esphome/bump-2026.1.0b4
2026.1.0b4
2026-01-20 17:01:36 -05:00
Jonathan Swoboda
7dc40881e2 Bump version to 2026.1.0b4 2026-01-20 15:55:03 -05:00
J. Nick Koston
b04373687e [wifi_info] Fix missing state when both IP+DNS or SSID+BSSID configure (#13385) 2026-01-20 15:55:03 -05:00
Jonathan Swoboda
b89c127f62 [x9c] Fix potentiometer unable to decrement (#13382)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 15:55:03 -05:00
Jonathan Swoboda
47dc5d0a1f [core] Fix state leakage and module caching when processing multiple configurations (#13368)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 15:55:03 -05:00
J. Nick Koston
21886dd3ac [api] Fix truncation of Home Assistant attributes longer than 255 characters (#13348) 2026-01-20 15:55:03 -05:00
J. Nick Koston
85a5a26519 [network] Fix IPAddress::str_to() to lowercase IPv6 hex digits (#13325) 2026-01-20 15:55:03 -05:00
Clyde Stubbs
79ccacd6d6 [helpers] Allow reading capacity of FixedVector (#13391) 2026-01-20 09:24:42 -10:00
J. Nick Koston
e2319ba651 [wifi_info] Fix missing state when both IP+DNS or SSID+BSSID configure (#13385) 2026-01-20 07:55:59 -10:00
8 changed files with 433 additions and 67 deletions

View File

@@ -9,6 +9,7 @@ from esphome.const import (
CONF_ABOVE,
CONF_ACCURACY_DECIMALS,
CONF_ALPHA,
CONF_BASELINE,
CONF_BELOW,
CONF_CALIBRATION,
CONF_DEVICE_CLASS,
@@ -38,7 +39,6 @@ from esphome.const import (
CONF_TIMEOUT,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE,
CONF_WEB_SERVER,
@@ -107,7 +107,7 @@ from esphome.const import (
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.cpp_generator import MockObjClass
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.util import Registry
CODEOWNERS = ["@esphome/core"]
@@ -574,38 +574,56 @@ async def lambda_filter_to_code(config, filter_id):
return automation.new_lambda_pvariable(filter_id, lambda_, StatelessLambdaFilter)
DELTA_SCHEMA = cv.Schema(
{
cv.Required(CONF_VALUE): cv.positive_float,
cv.Optional(CONF_TYPE, default="absolute"): cv.one_of(
"absolute", "percentage", lower=True
),
}
def validate_delta_value(value):
if isinstance(value, str) and value.endswith("%"):
# Check it's a well-formed percentage, but return the string as-is
try:
cv.positive_float(value[:-1])
return value
except cv.Invalid as exc:
raise cv.Invalid("Malformed delta % value") from exc
return cv.positive_float(value)
# This ideally would be done with `cv.maybe_simple_value` but it doesn't seem to respect the default for min_value.
DELTA_SCHEMA = cv.Any(
cv.All(
{
# Ideally this would be 'default=float("inf")' but it doesn't translate well to C++
cv.Optional(CONF_MAX_VALUE): validate_delta_value,
cv.Optional(CONF_MIN_VALUE, default="0.0"): validate_delta_value,
cv.Optional(CONF_BASELINE): cv.templatable(cv.float_),
},
cv.has_at_least_one_key(CONF_MAX_VALUE, CONF_MIN_VALUE),
),
validate_delta_value,
)
def validate_delta(config):
try:
value = cv.positive_float(config)
return DELTA_SCHEMA({CONF_VALUE: value, CONF_TYPE: "absolute"})
except cv.Invalid:
pass
try:
value = cv.percentage(config)
return DELTA_SCHEMA({CONF_VALUE: value, CONF_TYPE: "percentage"})
except cv.Invalid:
pass
raise cv.Invalid("Delta filter requires a positive number or percentage value.")
def _get_delta(value):
if isinstance(value, str):
assert value.endswith("%")
return 0.0, float(value[:-1])
return value, 0.0
@FILTER_REGISTRY.register("delta", DeltaFilter, cv.Any(DELTA_SCHEMA, validate_delta))
@FILTER_REGISTRY.register("delta", DeltaFilter, DELTA_SCHEMA)
async def delta_filter_to_code(config, filter_id):
percentage = config[CONF_TYPE] == "percentage"
return cg.new_Pvariable(
filter_id,
config[CONF_VALUE],
percentage,
)
# The config could be just the min_value, or it could be a dict.
max = MockObj("std::numeric_limits<float>::infinity()"), 0
if isinstance(config, dict):
min = _get_delta(config[CONF_MIN_VALUE])
if CONF_MAX_VALUE in config:
max = _get_delta(config[CONF_MAX_VALUE])
else:
min = _get_delta(config)
var = cg.new_Pvariable(filter_id, *min, *max)
if isinstance(config, dict) and (baseline_lambda := config.get(CONF_BASELINE)):
baseline = await cg.process_lambda(
baseline_lambda, [(float, "x")], return_type=float
)
cg.add(var.set_baseline(baseline))
return var
@FILTER_REGISTRY.register("or", OrFilter, validate_filters)

View File

@@ -291,22 +291,27 @@ optional<float> ThrottleWithPriorityFilter::new_value(float value) {
}
// DeltaFilter
DeltaFilter::DeltaFilter(float delta, bool percentage_mode)
: delta_(delta), current_delta_(delta), last_value_(NAN), percentage_mode_(percentage_mode) {}
DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1)
: min_a0_(min_a0), min_a1_(min_a1), max_a0_(max_a0), max_a1_(max_a1) {}
void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; }
optional<float> DeltaFilter::new_value(float value) {
if (std::isnan(value)) {
if (std::isnan(this->last_value_)) {
return {};
} else {
return this->last_value_ = value;
}
// Always yield the first value.
if (std::isnan(this->last_value_)) {
this->last_value_ = value;
return value;
}
float diff = fabsf(value - this->last_value_);
if (std::isnan(this->last_value_) || (diff > 0.0f && diff >= this->current_delta_)) {
if (this->percentage_mode_) {
this->current_delta_ = fabsf(value * this->delta_);
}
return this->last_value_ = value;
// calculate min and max using the linear equation
float ref = this->baseline_(this->last_value_);
float min = fabsf(this->min_a0_ + ref * this->min_a1_);
float max = fabsf(this->max_a0_ + ref * this->max_a1_);
float delta = fabsf(value - ref);
// if there is no reference, e.g. for the first value, just accept this one,
// otherwise accept only if within range.
if (delta > min && delta <= max) {
this->last_value_ = value;
return value;
}
return {};
}

View File

@@ -452,15 +452,21 @@ class HeartbeatFilter : public Filter, public Component {
class DeltaFilter : public Filter {
public:
explicit DeltaFilter(float delta, bool percentage_mode);
explicit DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1);
void set_baseline(float (*fn)(float));
optional<float> new_value(float value) override;
protected:
float delta_;
float current_delta_;
// These values represent linear equations for the min and max values but in practice only one of a0 and a1 will be
// non-zero Each limit is calculated as fabs(a0 + value * a1)
float min_a0_, min_a1_, max_a0_, max_a1_;
// default baseline is the previous value
float (*baseline_)(float) = [](float last_value) { return last_value; };
float last_value_{NAN};
bool percentage_mode_;
};
class OrFilter : public Filter {

View File

@@ -79,13 +79,17 @@ async def setup_conf(config, key):
async def to_code(config):
# Request specific WiFi listeners based on which sensors are configured
# Each sensor needs its own listener slot - call request for EACH sensor
# SSID and BSSID use WiFiConnectStateListener
if CONF_SSID in config or CONF_BSSID in config:
wifi.request_wifi_connect_state_listener()
for key in (CONF_SSID, CONF_BSSID):
if key in config:
wifi.request_wifi_connect_state_listener()
# IP address and DNS use WiFiIPStateListener
if CONF_IP_ADDRESS in config or CONF_DNS_ADDRESS in config:
wifi.request_wifi_ip_state_listener()
for key in (CONF_IP_ADDRESS, CONF_DNS_ADDRESS):
if key in config:
wifi.request_wifi_ip_state_listener()
# Scan results use WiFiScanResultsListener
if CONF_SCAN_RESULTS in config:

View File

@@ -348,6 +348,8 @@ template<typename T> class FixedVector {
size_t size() const { return size_; }
bool empty() const { return size_ == 0; }
size_t capacity() const { return capacity_; }
bool full() const { return size_ == capacity_; }
/// Access element without bounds checking (matches std::vector behavior)
/// Caller must ensure index is valid (i < size())
@@ -1343,30 +1345,16 @@ template<typename... X> class LazyCallbackManager;
*
* Memory overhead comparison (32-bit systems):
* - CallbackManager: 12 bytes (empty std::vector)
* - LazyCallbackManager: 4 bytes (nullptr pointer)
*
* Uses plain pointer instead of unique_ptr to avoid template instantiation overhead.
* The class is explicitly non-copyable/non-movable for Rule of Five compliance.
* - LazyCallbackManager: 4 bytes (nullptr unique_ptr)
*
* @tparam Ts The arguments for the callbacks, wrapped in void().
*/
template<typename... Ts> class LazyCallbackManager<void(Ts...)> {
public:
LazyCallbackManager() = default;
/// Destructor - clean up allocated CallbackManager if any.
/// In practice this never runs (entities live for device lifetime) but included for correctness.
~LazyCallbackManager() { delete this->callbacks_; }
// Non-copyable and non-movable (entities are never copied or moved)
LazyCallbackManager(const LazyCallbackManager &) = delete;
LazyCallbackManager &operator=(const LazyCallbackManager &) = delete;
LazyCallbackManager(LazyCallbackManager &&) = delete;
LazyCallbackManager &operator=(LazyCallbackManager &&) = delete;
/// Add a callback to the list. Allocates the underlying CallbackManager on first use.
void add(std::function<void(Ts...)> &&callback) {
if (!this->callbacks_) {
this->callbacks_ = new CallbackManager<void(Ts...)>();
this->callbacks_ = make_unique<CallbackManager<void(Ts...)>>();
}
this->callbacks_->add(std::move(callback));
}
@@ -1388,7 +1376,7 @@ template<typename... Ts> class LazyCallbackManager<void(Ts...)> {
void operator()(Ts... args) { this->call(args...); }
protected:
CallbackManager<void(Ts...)> *callbacks_{nullptr};
std::unique_ptr<CallbackManager<void(Ts...)>> callbacks_;
};
/// Helper class to deduplicate items in a series of values.

View File

@@ -121,6 +121,8 @@ sensor:
min_value: -10.0
- debounce: 0.1s
- delta: 5.0
- delta:
max_value: 2%
- exponential_moving_average:
alpha: 0.1
send_every: 15

View File

@@ -0,0 +1,180 @@
esphome:
name: test-delta-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Source Sensor 1"
id: source_sensor_1
accuracy_decimals: 1
- platform: template
name: "Source Sensor 2"
id: source_sensor_2
accuracy_decimals: 1
- platform: template
name: "Source Sensor 3"
id: source_sensor_3
accuracy_decimals: 1
- platform: template
name: "Source Sensor 4"
id: source_sensor_4
accuracy_decimals: 1
- platform: copy
source_id: source_sensor_1
name: "Filter Min"
id: filter_min
filters:
- delta:
min_value: 10
- platform: copy
source_id: source_sensor_2
name: "Filter Max"
id: filter_max
filters:
- delta:
max_value: 10
- platform: copy
source_id: source_sensor_3
id: test_3_baseline
filters:
- median:
window_size: 6
send_every: 1
send_first_at: 1
- platform: copy
source_id: source_sensor_3
name: "Filter Baseline Max"
id: filter_baseline_max
filters:
- delta:
max_value: 10
baseline: !lambda return id(test_3_baseline).state;
- platform: copy
source_id: source_sensor_4
name: "Filter Zero Delta"
id: filter_zero_delta
filters:
- delta: 0
script:
- id: test_filter_min
then:
- sensor.template.publish:
id: source_sensor_1
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 5.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 12.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 8.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: -2.0
- id: test_filter_max
then:
- sensor.template.publish:
id: source_sensor_2
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 5.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 40.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 10.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: -40.0 # Filtered out
- id: test_filter_baseline_max
then:
- sensor.template.publish:
id: source_sensor_3
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_3
state: 2.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_3
state: 3.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_3
state: 40.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_3
state: 20.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_3
state: 20.0
- id: test_filter_zero_delta
then:
- sensor.template.publish:
id: source_sensor_4
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_4
state: 1.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_4
state: 2.0
button:
- platform: template
name: "Test Filter Min"
id: btn_filter_min
on_press:
- script.execute: test_filter_min
- platform: template
name: "Test Filter Max"
id: btn_filter_max
on_press:
- script.execute: test_filter_max
- platform: template
name: "Test Filter Baseline Max"
id: btn_filter_baseline_max
on_press:
- script.execute: test_filter_baseline_max
- platform: template
name: "Test Filter Zero Delta"
id: btn_filter_zero_delta
on_press:
- script.execute: test_filter_zero_delta

View File

@@ -0,0 +1,163 @@
"""Test sensor DeltaFilter functionality."""
from __future__ import annotations
import asyncio
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_filters_delta(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
loop = asyncio.get_running_loop()
sensor_values: dict[str, list[float]] = {
"filter_min": [],
"filter_max": [],
"filter_baseline_max": [],
"filter_zero_delta": [],
}
filter_min_done = loop.create_future()
filter_max_done = loop.create_future()
filter_baseline_max_done = loop.create_future()
filter_zero_delta_done = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState) or state.missing_state:
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name not in sensor_values:
return
sensor_values[sensor_name].append(state.state)
# Check completion conditions
if (
sensor_name == "filter_min"
and len(sensor_values[sensor_name]) == 3
and not filter_min_done.done()
):
filter_min_done.set_result(True)
elif (
sensor_name == "filter_max"
and len(sensor_values[sensor_name]) == 3
and not filter_max_done.done()
):
filter_max_done.set_result(True)
elif (
sensor_name == "filter_baseline_max"
and len(sensor_values[sensor_name]) == 4
and not filter_baseline_max_done.done()
):
filter_baseline_max_done.set_result(True)
elif (
sensor_name == "filter_zero_delta"
and len(sensor_values[sensor_name]) == 2
and not filter_zero_delta_done.done()
):
filter_zero_delta_done.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# Get entities and build key mapping
entities, _ = await client.list_entities_services()
key_to_sensor = build_key_to_entity_mapping(
entities,
{
"filter_min": "Filter Min",
"filter_max": "Filter Max",
"filter_baseline_max": "Filter Baseline Max",
"filter_zero_delta": "Filter Zero Delta",
},
)
# Set up initial state helper with all entities
initial_state_helper = InitialStateHelper(entities)
# Subscribe to state changes with wrapper
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial states
await initial_state_helper.wait_for_initial_states()
# Find all buttons
button_name_map = {
"Test Filter Min": "filter_min",
"Test Filter Max": "filter_max",
"Test Filter Baseline Max": "filter_baseline_max",
"Test Filter Zero Delta": "filter_zero_delta",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
client.button_command(buttons["filter_min"])
try:
await asyncio.wait_for(filter_min_done, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 1 timed out. Values: {sensor_values['filter_min']}")
expected = [1.0, 12.0, -2.0]
assert sensor_values["filter_min"] == pytest.approx(expected), (
f"Test 1 failed: expected {expected}, got {sensor_values['filter_min']}"
)
# Test 2: Max
sensor_values["filter_max"].clear()
client.button_command(buttons["filter_max"])
try:
await asyncio.wait_for(filter_max_done, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 2 timed out. Values: {sensor_values['filter_max']}")
expected = [1.0, 5.0, 10.0]
assert sensor_values["filter_max"] == pytest.approx(expected), (
f"Test 2 failed: expected {expected}, got {sensor_values['filter_max']}"
)
# Test 3: Baseline Max
sensor_values["filter_baseline_max"].clear()
client.button_command(buttons["filter_baseline_max"])
try:
await asyncio.wait_for(filter_baseline_max_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 3 timed out. Values: {sensor_values['filter_baseline_max']}"
)
expected = [1.0, 2.0, 3.0, 20.0]
assert sensor_values["filter_baseline_max"] == pytest.approx(expected), (
f"Test 3 failed: expected {expected}, got {sensor_values['filter_baseline_max']}"
)
# Test 4: Zero Delta
sensor_values["filter_zero_delta"].clear()
client.button_command(buttons["filter_zero_delta"])
try:
await asyncio.wait_for(filter_zero_delta_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 4 timed out. Values: {sensor_values['filter_zero_delta']}"
)
expected = [1.0, 2.0]
assert sensor_values["filter_zero_delta"] == pytest.approx(expected), (
f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}"
)