Compare commits

...
Author SHA1 Message Date
J. Nick Koston fc75f9088b Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:39:18 -06:00
J. Nick Koston 6484a80d91 Merge remote-tracking branch 'upstream/dev' into sensor-delta-apply
# Conflicts:
#	esphome/automation.py
2026-11-11 18:03:49 -06:00
J. Nick Koston 51cab6782d Merge remote-tracking branch 'origin/dev' into sensor-delta-apply 2026-11-11 13:01:10 -06:00
Jonathan Swoboda 09885332a4 Merge branch 'dev' into sensor-delta-apply 2026-11-10 19:12:57 -05:00
J. Nick Koston caaf1d6c2c Sync the shared function helper: accept Statement lines in the body 2026-11-09 21:34:17 -08:00
J. Nick Koston 41ca592057 Keep the value range function name; sync the shared function helper; drop a repeated test 2026-11-09 21:09:36 -08:00
J. Nick Koston c0a82bdcf8 Merge remote-tracking branch 'upstream/dev' into sensor-delta-apply
# Conflicts:
#	esphome/automation.py
2026-11-09 20:51:20 -08:00
J. Nick Koston 0cc04502d8 Merge branch 'dev' into sensor-delta-apply 2026-11-04 19:14:53 -06:00
J. Nick Koston e75837af0f Merge remote-tracking branch 'origin/dev' into sensor-delta-apply
# Conflicts:
#	tests/unit_tests/test_cpp_generator.py
2026-11-04 18:36:08 -06:00
J. Nick Koston 65cae5b9fa Merge remote-tracking branch 'origin/sensor-delta-tests' into sensor-delta-apply
# Conflicts:
#	tests/unit_tests/test_automation.py
2026-10-31 14:04:47 -10:00
J. Nick Koston 7e93c0ca77 Merge remote-tracking branch 'upstream/dev' into sensor-delta-tests 2026-10-31 14:03:28 -10:00
J. Nick Koston 0a294f8921 Merge sensor-delta-tests into sensor-delta-apply
# Conflicts:
#	esphome/components/sensor/__init__.py
2026-10-29 08:37:40 -10:00
J. Nick Koston 95c7fd1d0f Merge dev into sensor-delta-tests 2026-10-29 08:35:47 -10:00
J. Nick Koston 92e81ce042 [sensor] Render the delta call with the codegen call helpers 2026-10-28 21:29:39 -10:00
J. Nick Koston ac7cb5ade8 Merge remote-tracking branch 'origin/codegen-shared-static-function' into sensor-delta-apply 2026-10-28 18:50:51 -10:00
J. Nick Koston 2be6a24507 Record apply functions where shared_static_function emits them 2026-10-28 18:43:42 -10:00
J. Nick Koston 206ab92115 Give unshared apply functions config-id-safe names too
apply_static_function always goes through cg.shared_static_function, so
an unshared function also gets a name no config id uses.
2026-10-28 18:29:30 -10:00
J. Nick Koston 416f982ed3 Merge remote-tracking branch 'origin/codegen-shared-static-function' into sensor-delta-apply 2026-10-28 18:20:42 -10:00
J. Nick Koston 70b7e921b6 [sensor] Name the delta value parameter through one constant
A plain name such as last would hide a user id of the same name in a
baseline lambda, so the prefixed name stays, defined once.
2026-10-28 18:20:22 -10:00
J. Nick Koston 907b3c6d6a [core] Key shared static functions on the rendered body text 2026-10-28 18:06:43 -10:00
J. Nick Koston d6c1c3e5ca Merge commit 'e6bebc298f7ddf77c4d7d6322f79b22a08170ba5' into sensor-delta-tests 2026-10-28 17:58:07 -10:00
J. Nick Koston 4011736537 Merge branch 'sensor-delta-tests' into sensor-delta-apply 2026-10-28 17:58:07 -10:00
J. Nick Koston bb20821785 Merge branch 'sensor-delta-helper-call' into sensor-delta-apply 2026-10-28 17:56:32 -10:00
J. Nick Koston 460142df4d [sensor] Check delta NaNs with a bit test instead of std::isnan 2026-10-28 17:53:47 -10:00
J. Nick Koston 1c80bb5a6a [sensor] Parenthesize the templated delta call inside EXPECT_FALSE 2026-10-28 17:52:15 -10:00
J. Nick Koston 00fac5167d [sensor] Pass one coefficient per delta bound and share functions through codegen
A percentage bound is now fabsf(ref * b) chosen at compile time, so the
unused zero terms no longer cost soft-float calls. Identical delta
functions are shared by apply_static_function(share=...) instead of a
sensor-local cache. Trim the C++ tests to what the integration test
cannot reach.
2026-10-28 17:45:02 -10:00
J. Nick Koston f2eeab42cc Merge branch 'codegen-shared-static-function' into sensor-delta-helper-call 2026-10-28 17:43:30 -10:00
J. Nick Koston edd6ce487c Merge branch 'sensor-delta-tests' into sensor-delta-helper-call 2026-10-28 17:43:29 -10:00
J. Nick Koston 3c5104e8eb [sensor] Drop accuracy_decimals from the delta test's done sensor 2026-10-28 17:43:23 -10:00
J. Nick Koston 0e1731669f [sensor] Inline the delta helpers and drop the max check when there is none
Inlined, the constant bounds fold into each generated function, so a
config without percentages builds to the same size as the hand-written
expression; a missing max now uses a 3-argument form instead of
comparing against infinity.
2026-10-28 17:29:57 -10:00
J. Nick Koston b25dc1f054 [sensor] Use a percentage-free delta helper when both bounds are absolute
Absolute bounds need no ref * a1 math, so the common delta: 0.5 config
skips the soft-float multiply-adds; delta_passes_pct handles the rest.
2026-10-28 17:26:51 -10:00
J. Nick Koston 182bcfa916 [sensor] Generate the delta accept function as one call to a C++ helper
The thresholds are computed by sensor::delta_passes with the same
fabsf(a0 + ref * a1) bounds as before, so the generated function only
passes the baseline and the coefficients, and the infinity special case
is gone.
2026-10-28 17:17:43 -10:00
J. Nick Koston 0bc2167494 Note in the description why removing the codegen-only classes is not breaking 2026-10-28 16:36:50 -10:00
J. Nick Koston b593034a2f Make shared static function names unique and add share to apply_static_function 2026-10-28 16:34:16 -10:00
J. Nick Koston 9f6b78930b [core] Add cg.shared_static_function to emit one function per distinct body 2026-10-28 16:25:42 -10:00
J. Nick Koston 1180913f14 Revert "[sensor] Use a TypedDict for the sensor codegen state to keep import fast"
This reverts commit 1e14254337.
2026-10-28 16:01:57 -10:00
J. Nick Koston 1e14254337 [sensor] Use a TypedDict for the sensor codegen state to keep import fast 2026-10-28 16:01:32 -10:00
J. Nick Koston b1d4e0917e [sensor] Cover number baselines in the delta codegen test and tidy comments 2026-10-28 15:49:22 -10:00
J. Nick Koston 69e4423073 Merge branch 'sensor-delta-tests' into sensor-delta-apply 2026-10-28 15:46:54 -10:00
J. Nick Koston cb7b402d24 [sensor] Describe the infinite delta test case by its behaviour 2026-10-28 15:46:43 -10:00
J. Nick Koston e21e16d4d0 [sensor] Prefix the delta accept function's names so baseline ids are not shadowed 2026-10-28 15:37:59 -10:00
J. Nick Koston 19fe531731 [sensor] Check the infinite delta test value is positive infinity 2026-10-28 15:13:30 -10:00
J. Nick Koston 68531dee5e Merge branch 'sensor-delta-tests' into sensor-delta-apply 2026-10-28 15:13:30 -10:00
J. Nick Koston 1491606e3b [sensor] Test that identical delta filters share one accept function 2026-10-28 14:32:25 -10:00
J. Nick Koston fc3f62b65c Merge branch 'sensor-delta-tests' into sensor-delta-apply 2026-10-28 14:19:36 -10:00
J. Nick Koston 96fd89592b [sensor] Keep one delta filter class and share identical accept functions
A template per filter cost a vtable and a new_value thunk per instance.
The filter now stores the generated function as a pointer, identical
delta configs share one function, and the infinity check is emitted only
where the bounds would not already reject an infinite baseline.
2026-10-28 14:19:36 -10:00
J. Nick Koston 30c1e33785 [sensor] Test that delta drops every value after an infinite one 2026-10-28 14:19:30 -10:00
J. Nick Koston 39f50a5563 [sensor] Check the delta baseline for infinity with a bit test
std::isinf costs two soft-float library calls on ESP8266.
2026-10-28 13:59:39 -10:00
J. Nick Koston afdfcf0aad [sensor] Generate the delta filter thresholds and baseline as a function
The delta filter kept four float coefficients and a baseline function
pointer per instance. Codegen now folds the thresholds and inlines the
baseline into a generated function passed as a template argument, so
each filter only stores its last value.
2026-10-28 13:23:31 -10:00
J. Nick Koston ec1122e206 [sensor] Test delta filter with min and max together and a percentage max 2026-10-28 13:06:40 -10:00
9 changed files with 178 additions and 94 deletions

No files matched your search

+39 -20
View File
@@ -134,7 +134,7 @@ from esphome.core.entity_helpers import (
setup_entity,
setup_unit_of_measurement,
)
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.cpp_generator import MockObj, MockObjClass, SafeExpType, call_lambda
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from esphome.util import Registry
@@ -672,29 +672,48 @@ DELTA_SCHEMA = cv.Any(
)
def _get_delta(value):
# Any plain name could be a user id that a baseline lambda reads, which this parameter would hide
DELTA_VALUE = "esphome_delta_value_"
def _delta_bound(value: float | str) -> tuple[float, bool]:
"""Return a delta bound and whether it is a fraction of the baseline."""
if isinstance(value, str):
assert value.endswith("%")
return 0.0, float(value[:-1]) / 100.0
return value, 0.0
return float(value[:-1]) / 100.0, True
return value, False
@FILTER_REGISTRY.register("delta", DeltaFilter, DELTA_SCHEMA)
async def delta_filter_to_code(config, filter_id):
# 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 := config.get(CONF_BASELINE)) is not None:
# A plain number is wrapped in a lambda that returns it
baseline = await cg.templatable(baseline, [(float, "x")], float)
cg.add(var.set_baseline(baseline))
return var
async def delta_filter_to_code(config: Any, filter_id: ID) -> MockObj:
if not isinstance(config, dict):
config = {CONF_MIN_VALUE: config}
ref: SafeExpType = MockObj("x")
lambda_ = None
if (baseline := config.get(CONF_BASELINE)) is not None:
if isinstance(baseline, Lambda):
lambda_ = await cg.process_lambda(
baseline, [(float, "x")], return_type=float
)
ref = call_lambda(lambda_)
else:
ref = baseline
bounds = [_delta_bound(config[CONF_MIN_VALUE])]
if (max_value := config.get(CONF_MAX_VALUE)) is not None:
bounds.append(_delta_bound(max_value))
pcts = [pct for _, pct in bounds]
while pcts and not pcts[-1]:
pcts.pop()
passes = sensor_ns.delta_passes.template(*pcts) if pcts else sensor_ns.delta_passes
call = passes(MockObj(DELTA_VALUE), ref, *(v for v, _ in bounds))
# A baseline lambda may keep static state, so its function is never shared.
fn = automation.apply_static_function(
filter_id,
bool,
[(float, "x"), (float, DELTA_VALUE)],
[f"return {call};"],
share=lambda_ is None,
)
return cg.new_Pvariable(filter_id, fn)
@FILTER_REGISTRY.register("or", OrFilter, validate_filters)
+8 -21
View File
@@ -281,31 +281,18 @@ optional<float> ThrottleWithPriorityNanFilter::new_value(float value) {
}
// DeltaFilter
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; }
// Bit test: std::isnan is a soft-float call on ESP8266
static bool delta_is_nan(float value) { return (bit_cast<uint32_t>(value) & 0x7FFFFFFF) > 0x7F800000; }
optional<float> DeltaFilter::new_value(float value) {
const bool no_value = std::isnan(value);
const bool no_reference = std::isnan(this->last_value_);
const bool no_value = delta_is_nan(value);
const bool no_reference = delta_is_nan(this->last_value_);
if (no_value && no_reference)
return {};
if (no_value || no_reference) {
this->last_value_ = value;
return 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);
// accept only if within range
if (delta > min && delta <= max) {
this->last_value_ = value;
return value;
}
return {};
if (!no_value && !no_reference && !this->accept_(this->last_value_, value))
return {};
this->last_value_ = value;
return value;
}
// OrFilter helpers
+27 -10
View File
@@ -492,22 +492,39 @@ class HeartbeatFilter : public Filter {
bool optimistic_{false};
};
/// Whether value differs from ref by more than min (and at most max); a *_PCT bound is that
/// fraction of ref. An infinite or NaN ref never passes.
template<bool MIN_PCT = false> inline bool delta_passes(float value, float ref, float min) {
if constexpr (MIN_PCT) {
return fabsf(value - ref) > fabsf(ref * min);
} else {
// Bit test: std::isinf is two soft-float calls on ESP8266
return (bit_cast<uint32_t>(ref) & 0x7FFFFFFF) != 0x7F800000 && fabsf(value - ref) > min;
}
}
template<bool MIN_PCT = false, bool MAX_PCT = false>
inline bool delta_passes(float value, float ref, float min, float max) {
if constexpr (!MIN_PCT && MAX_PCT) {
if ((bit_cast<uint32_t>(ref) & 0x7FFFFFFF) == 0x7F800000)
return false;
}
const float delta = fabsf(value - ref);
if constexpr (MIN_PCT)
min = fabsf(ref * min);
if constexpr (MAX_PCT)
max = fabsf(ref * max);
return delta > min && delta <= max;
}
class DeltaFilter : public Filter {
public:
explicit DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1);
void set_baseline(float (*fn)(float));
/// accept(last, value) is generated from the config with the thresholds and baseline baked in.
explicit DeltaFilter(bool (*accept)(float, float)) : accept_(accept) {}
optional<float> new_value(float value) override;
protected:
// 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; };
bool (*accept_)(float, float);
float last_value_{NAN};
};
@@ -0,0 +1,44 @@
esphome:
name: test
esp8266:
board: d1_mini_lite
sensor:
- platform: template
id: s_1
filters:
- delta: 0.1
- platform: template
id: s_2
filters:
- delta: 0.1
- delta:
min_value: 1
max_value: 10%
- platform: template
id: s_3
filters:
- delta:
min_value: 5%
- delta:
min_value: 1
max_value: 10
# A baseline lambda is inlined and never shared, even when identical
- delta:
max_value: 10
baseline: !lambda return id(value).state;
- delta:
max_value: 10
baseline: !lambda return id(value).state;
- platform: template
id: s_4
filters:
- delta:
max_value: 10
baseline: 0
- delta:
max_value: 10
baseline: 20.0
- platform: template
id: value
@@ -1,16 +0,0 @@
"""Tests for the sensor delta filter baseline."""
from collections.abc import Callable
import re
def test_delta_baseline_number(generate_main: Callable[[str], str]) -> None:
"""A plain number baseline, including 0, becomes a lambda returning it."""
main_cpp = generate_main("tests/component_tests/sensor/test_delta_baseline.yaml")
# A user lambda body also carries #line directives around the return.
for body in (r"return 20\.0f;", r"return 0\.0f;", r"return x \* 2\.0f;"):
assert re.search(
rf"set_baseline\(\[\]\(float x\) -> float \{{[^}}]*?{body}[^}}]*\}}\);",
main_cpp,
), body
@@ -1,19 +0,0 @@
esphome:
name: test
esp8266:
board: d1_mini_lite
sensor:
- platform: template
id: s_1
filters:
- delta:
max_value: 3
baseline: 20.0
- delta:
max_value: 3
baseline: 0
- delta:
max_value: 3
baseline: !lambda return x * 2.0f;
@@ -3,6 +3,7 @@
from collections.abc import Callable
import re
from esphome.components.sensor import DELTA_VALUE
from tests.component_tests.helpers import extract_packed_value
@@ -42,3 +43,32 @@ def test_calibration_tables_are_shared_progmem(
# Table names never reuse a user id.
assert exact != "sensor_calibrate_linear"
assert "sensor_calibratelinearfilter_id_data" not in (exact, least_squares)
def test_delta_accept_functions_are_shared(
generate_main: Callable[[str], str],
) -> None:
"""Identical delta configs share one accept function; a baseline lambda never does."""
main_cpp = generate_main("tests/component_tests/sensor/test_delta.yaml")
bodies = re.findall(
rf"static bool (\w+)\(float x, float {DELTA_VALUE}\) \{{\n(.*?)\n\}}",
main_cpp,
re.DOTALL,
)
calls = sorted(body.strip() for _, body in bodies)
call = f"return sensor::delta_passes{{}}({DELTA_VALUE}, {{}});"
# The two identical `delta: 0.1` filters share one function
assert calls == sorted(
[
call.format("", "x, 0.1f"),
call.format("<false, true>", "x, 1.0f, 0.1f"),
call.format("<true>", "x, 0.05f"),
call.format("", "x, 1.0f, 10.0f"),
call.format("", "0.0f, 0.0f, 10.0f"),
call.format("", "20.0f, 0.0f, 10.0f"),
# A baseline lambda is never shared, even when identical
call.format("", "static_cast<float>(value->state), 0.0f, 10.0f"),
call.format("", "static_cast<float>(value->state), 0.0f, 10.0f"),
]
)
+15 -8
View File
@@ -29,6 +29,21 @@ sensor:
name: "Publish Target"
id: sensor_publish_target
- platform: copy
source_id: source_sensor
name: "Delta Filters"
filters:
- delta: 5%
- delta:
min_value: 1
max_value: 10%
baseline: !lambda |-
float half = x / 2.0f;
return half * 2.0f;
- delta:
max_value: 3
baseline: 20.0
# Streaming filters (window_size == send_every) - uses StreamingFilter base class
- platform: copy
source_id: source_sensor
@@ -284,11 +299,3 @@ sensor:
- rh_correction:
sensor_id: source_sensor
temperature_offset: !lambda return -10.0;
- platform: copy
source_id: source_sensor
name: "Delta Filter Number Baseline"
filters:
- delta:
max_value: 3
baseline: 20.0
+15
View File
@@ -63,4 +63,19 @@ TEST(RHCorrectionFilter, DropsTheUpdateOnNaN) {
EXPECT_FALSE(nan_offset.filter.new_value(40.0f).has_value());
}
// The integration test cannot publish these references or an infinite value without a max
TEST(DeltaFilter, InfiniteOrNaNReferenceNeverPasses) {
EXPECT_FALSE(delta_passes(5.0f, INFINITY, 1.0f));
EXPECT_FALSE(delta_passes(5.0f, -INFINITY, 0.0f));
EXPECT_FALSE(delta_passes(5.0f, NAN, 0.0f));
EXPECT_FALSE(delta_passes<true>(5.0f, -INFINITY, 0.05f));
EXPECT_FALSE(delta_passes<true>(5.0f, NAN, 0.05f));
EXPECT_FALSE((delta_passes<false, true>(5.0f, -INFINITY, 1.0f, 0.5f)));
}
TEST(DeltaFilter, InfiniteValueWithoutMaxPasses) {
EXPECT_TRUE(delta_passes(INFINITY, 10.0f, 2.0f));
EXPECT_TRUE(delta_passes(-INFINITY, 10.0f, 2.0f));
}
} // namespace esphome::sensor::testing