mirror of
https://github.com/esphome/esphome.git
synced 2026-01-20 18:09:10 -07:00
Compare commits
12 Commits
no_new_to_
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c0f43db9e | ||
|
|
6edecd3d45 | ||
|
|
055c00f1ac | ||
|
|
7dc40881e2 | ||
|
|
b04373687e | ||
|
|
b89c127f62 | ||
|
|
47dc5d0a1f | ||
|
|
21886dd3ac | ||
|
|
85a5a26519 | ||
|
|
79ccacd6d6 | ||
|
|
e2319ba651 | ||
|
|
ed4ebffa74 |
@@ -25,9 +25,7 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
|
||||
|
||||
private:
|
||||
// Helper to convert value to string - handles the case where value is already a string
|
||||
template<typename T> static std::string value_to_string(T &&val) {
|
||||
return to_string(std::forward<T>(val)); // NOLINT
|
||||
}
|
||||
template<typename T> static std::string value_to_string(T &&val) { return to_string(std::forward<T>(val)); }
|
||||
|
||||
// Overloads for string types - needed because std::to_string doesn't support them
|
||||
static std::string value_to_string(char *val) {
|
||||
|
||||
@@ -48,7 +48,7 @@ class ESPBTUUID {
|
||||
|
||||
// Remove before 2026.8.0
|
||||
ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0")
|
||||
std::string to_string() const; // NOLINT
|
||||
std::string to_string() const;
|
||||
const char *to_str(std::span<char, UUID_STR_LEN> output) const;
|
||||
|
||||
protected:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -83,7 +83,7 @@ struct Timer {
|
||||
}
|
||||
// Remove before 2026.8.0
|
||||
ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0")
|
||||
std::string to_string() const { // NOLINT
|
||||
std::string to_string() const {
|
||||
char buffer[TO_STR_BUFFER_SIZE];
|
||||
return this->to_str(buffer);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace x9c {
|
||||
|
||||
static const char *const TAG = "x9c.output";
|
||||
|
||||
void X9cOutput::trim_value(int change_amount) {
|
||||
void X9cOutput::trim_value(int32_t change_amount) {
|
||||
if (change_amount == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -47,17 +47,17 @@ void X9cOutput::setup() {
|
||||
|
||||
if (this->initial_value_ <= 0.50) {
|
||||
this->trim_value(-101); // Set min value (beyond 0)
|
||||
this->trim_value(static_cast<uint32_t>(roundf(this->initial_value_ * 100)));
|
||||
this->trim_value(lroundf(this->initial_value_ * 100));
|
||||
} else {
|
||||
this->trim_value(101); // Set max value (beyond 100)
|
||||
this->trim_value(static_cast<uint32_t>(roundf(this->initial_value_ * 100) - 100));
|
||||
this->trim_value(lroundf(this->initial_value_ * 100) - 100);
|
||||
}
|
||||
this->pot_value_ = this->initial_value_;
|
||||
this->write_state(this->initial_value_);
|
||||
}
|
||||
|
||||
void X9cOutput::write_state(float state) {
|
||||
this->trim_value(static_cast<uint32_t>(roundf((state - this->pot_value_) * 100)));
|
||||
this->trim_value(lroundf((state - this->pot_value_) * 100));
|
||||
this->pot_value_ = state;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class X9cOutput : public output::FloatOutput, public Component {
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void trim_value(int change_amount);
|
||||
void trim_value(int32_t change_amount);
|
||||
|
||||
protected:
|
||||
void write_state(float state) override;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -732,47 +732,6 @@ def lint_no_heap_allocating_helpers(fname, match):
|
||||
)
|
||||
|
||||
|
||||
@lint_re_check(
|
||||
# Match std::to_string() or unqualified to_string() calls
|
||||
# The esphome namespace has "using std::to_string;" so unqualified calls resolve to std::to_string
|
||||
# Use negative lookbehind to avoid matching:
|
||||
# - Function definitions: "const char *to_string(" or "std::string to_string("
|
||||
# - Method definitions: "Class::to_string("
|
||||
# - Method calls: ".to_string(" or "->to_string("
|
||||
# - Other identifiers: "_to_string("
|
||||
r"(?<![*&.\w>:])to_string\s*\(" + CPP_RE_EOL,
|
||||
include=cpp_include,
|
||||
exclude=[
|
||||
# Vendored library
|
||||
"esphome/components/http_request/httplib.h",
|
||||
# Deprecated helpers that return std::string
|
||||
"esphome/core/helpers.cpp",
|
||||
# The using declaration itself
|
||||
"esphome/core/helpers.h",
|
||||
# Test fixtures - not production embedded code
|
||||
"tests/integration/fixtures/*",
|
||||
],
|
||||
)
|
||||
def lint_no_std_to_string(fname, match):
|
||||
return (
|
||||
f"{highlight('std::to_string()')} allocates heap memory. On long-running embedded "
|
||||
f"devices, repeated heap allocations fragment memory over time.\n"
|
||||
f"Please use {highlight('snprintf()')} with a stack buffer instead.\n"
|
||||
f"\n"
|
||||
f"Buffer sizes and format specifiers:\n"
|
||||
f" uint8_t/int8_t: 4 chars - %u / %d (or PRIu8/PRId8)\n"
|
||||
f" uint16_t/int16_t: 6 chars - %u / %d (or PRIu16/PRId16)\n"
|
||||
f" uint32_t/int32_t: 11 chars - %" + "PRIu32 / %" + "PRId32\n"
|
||||
" uint64_t/int64_t: 21 chars - %" + "PRIu64 / %" + "PRId64\n"
|
||||
f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n"
|
||||
f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n"
|
||||
f"\n"
|
||||
f"For sensor values, use value_accuracy_to_buf() from helpers.h.\n"
|
||||
f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n'
|
||||
f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)"
|
||||
)
|
||||
|
||||
|
||||
@lint_content_find_check(
|
||||
"ESP_LOG",
|
||||
include=["*.h", "*.tcc"],
|
||||
|
||||
@@ -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
|
||||
|
||||
180
tests/integration/fixtures/sensor_filters_delta.yaml
Normal file
180
tests/integration/fixtures/sensor_filters_delta.yaml
Normal 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
|
||||
163
tests/integration/test_sensor_filters_delta.py
Normal file
163
tests/integration/test_sensor_filters_delta.py
Normal 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']}"
|
||||
)
|
||||
Reference in New Issue
Block a user