mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 09:36:04 +00:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc75f9088b | ||
|
|
6484a80d91 | ||
|
|
51cab6782d | ||
|
|
09885332a4 | ||
|
|
caaf1d6c2c | ||
|
|
41ca592057 | ||
|
|
c0a82bdcf8 | ||
|
|
0cc04502d8 | ||
|
|
e75837af0f | ||
|
|
65cae5b9fa | ||
|
|
7e93c0ca77 | ||
|
|
0a294f8921 | ||
|
|
95c7fd1d0f | ||
|
|
92e81ce042 | ||
|
|
ac7cb5ade8 | ||
|
|
2be6a24507 | ||
|
|
206ab92115 | ||
|
|
416f982ed3 | ||
|
|
70b7e921b6 | ||
|
|
907b3c6d6a | ||
|
|
d6c1c3e5ca | ||
|
|
4011736537 | ||
|
|
bb20821785 | ||
|
|
460142df4d | ||
|
|
1c80bb5a6a | ||
|
|
00fac5167d | ||
|
|
f2eeab42cc | ||
|
|
edd6ce487c | ||
|
|
3c5104e8eb | ||
|
|
0e1731669f | ||
|
|
b25dc1f054 | ||
|
|
182bcfa916 | ||
|
|
0bc2167494 | ||
|
|
b593034a2f | ||
|
|
9f6b78930b | ||
|
|
1180913f14 | ||
|
|
1e14254337 | ||
|
|
b1d4e0917e | ||
|
|
69e4423073 | ||
|
|
cb7b402d24 | ||
|
|
e21e16d4d0 | ||
|
|
19fe531731 | ||
|
|
68531dee5e | ||
|
|
1491606e3b | ||
|
|
fc3f62b65c | ||
|
|
96fd89592b | ||
|
|
30c1e33785 | ||
|
|
39f50a5563 | ||
|
|
afdfcf0aad | ||
|
|
ec1122e206 |
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user