Compare commits

...
Author SHA1 Message Date
J. Nick Koston 049c0e7aa0 Merge remote-tracking branch 'origin/dev' into in-range-apply-helper
# Conflicts:
#	esphome/automation.py
2026-10-22 08:15:42 -10:00
J. Nick Koston c5999377ba Merge remote-tracking branch 'origin/dev' into in-range-apply-helper
# Conflicts:
#	tests/unit_tests/test_automation.py
2026-10-21 14:44:58 -10:00
J. Nick Koston 110f7d27c7 Merge remote-tracking branch 'origin/dev' into in-range-apply-helper
# Conflicts:
#	esphome/components/number/__init__.py
#	esphome/components/sensor/__init__.py
2026-10-20 03:20:39 -10:00
J. Nick Koston db9c2db761 Merge branch 'dev' into in-range-apply-helper 2026-10-18 16:28:14 -10:00
J. Nick Koston c10081416b Test that register_apply_condition defaults fill absent keys 2026-10-17 14:08:13 -10:00
J. Nick Koston 10f817c1a8 Merge remote-tracking branch 'origin/in-range-test-coverage' into in-range-apply-helper
# Conflicts:
#	tests/unit_tests/test_automation.py
2026-10-17 12:59:28 -10:00
J. Nick Koston c248b7375c Merge remote-tracking branch 'origin/dev' into in-range-test-coverage 2026-10-17 12:59:08 -10:00
J. Nick Koston 8fcd37c2ef Fill the in_range bounds at code generation so the validated config is unchanged 2026-10-15 14:17:49 -10:00
J. Nick Koston f4d640bbd2 Merge branch 'in-range-test-coverage' into in-range-apply-helper 2026-10-15 14:10:11 -10:00
J. Nick Koston 1c79e65e4f [sensor][number] Add an in_range integration test with a fixed truth table 2026-10-15 14:10:10 -10:00
J. Nick Koston 3901ccccba [sensor][number] Build in_range with register_apply_condition 2026-10-15 14:04:35 -10:00
J. Nick Koston 20d0ab9ed0 [sensor][number] Test in_range with only above, only below and both bounds 2026-10-15 14:03:09 -10:00
6 changed files with 81 additions and 74 deletions
+30 -1
View File
@@ -1,15 +1,18 @@
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import logging
import math
import string
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
CONF_ABOVE,
CONF_ALL,
CONF_ANY,
CONF_AUTOMATION_ID,
CONF_BELOW,
CONF_CONDITION,
CONF_COUNT,
CONF_ELSE,
@@ -361,6 +364,25 @@ class ApplyField:
return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),))
def _in_range_low(config: ConfigType, value: float) -> str:
return "-INFINITY" if math.isnan(value) else str(cg.safe_exp(value))
def _in_range_high(config: ConfigType, value: float) -> str:
# With no lower bound, an unset upper bound stays NaN so the check is false, as before.
if math.isnan(value):
return "NAN" if math.isnan(config[CONF_ABOVE]) else "INFINITY"
return str(cg.safe_exp(value))
# ``above <= state <= below`` on the parent's float ``state``; register with ``IN_RANGE_DEFAULTS``.
IN_RANGE_CHECK = ApplyCall(
"state >= {} && {parent}->state <= {}",
((CONF_ABOVE, cg.float_, _in_range_low), (CONF_BELOW, cg.float_, _in_range_high)),
)
IN_RANGE_DEFAULTS = {CONF_ABOVE: math.nan, CONF_BELOW: math.nan}
@dataclass(frozen=True)
class ApplyBytes:
"""A byte payload key forwarded as ``target``, whose ``{}`` stands for ``data, len``.
@@ -675,7 +697,11 @@ async def _render_check(
def register_apply_condition(
name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID
name: str,
schema: cv.Schema,
check: str | ApplyCall,
id_key: str = CONF_ID,
defaults: ConfigType | None = None,
) -> None:
"""Register a condition that is one expression on its parent, with no C++ class.
@@ -684,6 +710,7 @@ def register_apply_condition(
against config values, all of which must be present. Write ``== false`` to negate.
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
``defaults`` fills absent keys at code generation only, so the validated config is unchanged.
Generates one static predicate and an ``ApplyCondition`` templated on it.
"""
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
@@ -698,6 +725,8 @@ def register_apply_condition(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
if defaults:
config = {**defaults, **config}
parent = await _apply_parent(config, id_key)
lambda_args = _apply_lambda_args(args)
values = _apply_values(config, members)
+5 -15
View File
@@ -168,11 +168,6 @@ ValueRangeTrigger = number_ns.class_(
"ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component
)
# Conditions
NumberInRangeCondition = number_ns.class_(
"NumberInRangeCondition", automation.Condition
)
NumberMode = number_ns.enum("NumberMode")
# Schema default that also matches the C++ initializer in number_traits.h; codegen
@@ -347,17 +342,12 @@ NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All(
)
@automation.register_condition(
"number.in_range", NumberInRangeCondition, NUMBER_IN_RANGE_CONDITION_SCHEMA
automation.register_apply_condition(
"number.in_range",
NUMBER_IN_RANGE_CONDITION_SCHEMA,
automation.IN_RANGE_CHECK,
defaults=automation.IN_RANGE_DEFAULTS,
)
async def number_in_range_to_code(config, condition_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(condition_id, template_arg, paren)
cg.add_optional(var.set_min, config.get(CONF_ABOVE))
cg.add_optional(var.set_max, config.get(CONF_BELOW))
return var
@coroutine_with_priority(CoroPriority.CORE)
-23
View File
@@ -26,27 +26,4 @@ class ValueRangeTrigger final : public Trigger<float>, public Component {
TemplatableFn<float, float> max_{[](float) -> float { return NAN; }};
};
template<typename... Ts> class NumberInRangeCondition final : public Condition<Ts...> {
public:
NumberInRangeCondition(Number *parent) : parent_(parent) {}
void set_min(float min) { this->min_ = min; }
void set_max(float max) { this->max_ = max; }
bool check(const Ts &...x) override {
const float state = this->parent_->state;
if (std::isnan(this->min_)) {
return state <= this->max_;
} else if (std::isnan(this->max_)) {
return state >= this->min_;
} else {
return this->min_ <= state && state <= this->max_;
}
}
protected:
Number *parent_;
float min_{NAN};
float max_{NAN};
};
} // namespace esphome::number
+5 -11
View File
@@ -315,7 +315,6 @@ ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter)
ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter)
RHCorrectionFilter = sensor_ns.class_("RHCorrectionFilter", Filter)
CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter)
SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter)
ClampFilter = sensor_ns.class_("ClampFilter", Filter)
RoundFilter = sensor_ns.class_("RoundFilter", Filter)
RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter)
@@ -1112,17 +1111,12 @@ SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
)
@automation.register_condition(
"sensor.in_range", SensorInRangeCondition, SENSOR_IN_RANGE_CONDITION_SCHEMA
automation.register_apply_condition(
"sensor.in_range",
SENSOR_IN_RANGE_CONDITION_SCHEMA,
automation.IN_RANGE_CHECK,
defaults=automation.IN_RANGE_DEFAULTS,
)
async def sensor_in_range_to_code(config, condition_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(condition_id, template_arg, paren)
cg.add_optional(var.set_min, config.get(CONF_ABOVE))
cg.add_optional(var.set_max, config.get(CONF_BELOW))
return var
def validate_ntc_calibration_parameter(value):
-23
View File
@@ -58,27 +58,4 @@ class ValueRangeTrigger final : public Trigger<float>, public Component {
TemplatableFn<float, float> max_{[](float) -> float { return NAN; }};
};
template<typename... Ts> class SensorInRangeCondition final : public Condition<Ts...> {
public:
SensorInRangeCondition(Sensor *parent) : parent_(parent) {}
void set_min(float min) { this->min_ = min; }
void set_max(float max) { this->max_ = max; }
bool check(const Ts &...x) override {
const float state = this->parent_->state;
if (std::isnan(this->min_)) {
return state <= this->max_;
} else if (std::isnan(this->max_)) {
return state >= this->min_;
} else {
return this->min_ <= state && state <= this->max_;
}
}
protected:
Sensor *parent_;
float min_{NAN};
float max_{NAN};
};
} // namespace esphome::sensor
+41 -1
View File
@@ -11,6 +11,8 @@ from esphome.automation import (
ACTION_REGISTRY,
CONF_CHOICE_ID,
CONF_CHOOSE,
IN_RANGE_CHECK,
IN_RANGE_DEFAULTS,
ApplyAction,
ApplyBytes,
ApplyCall,
@@ -988,10 +990,11 @@ async def _run_apply_condition(
args: list[tuple[object, str]] | None = None,
platform: str = "esp32",
id_key: str = CONF_ID,
defaults: dict[str, object] | None = None,
) -> RegistryEntry:
"""Register an apply condition and run its builder with the given config."""
_, conditions = registries
register_apply_condition("my.check", None, check, id_key=id_key)
register_apply_condition("my.check", None, check, id_key=id_key, defaults=defaults)
return await _run_entry(conditions["my.check"], config, args, platform, id_key)
@@ -1486,6 +1489,21 @@ async def test_apply_condition_compares_config_value(
await _run_apply_condition(registries, check, {})
@pytest.mark.asyncio
async def test_apply_condition_defaults_fill_absent_keys(
registries: tuple[Registry, Registry], mock_cg: MockCodegen
) -> None:
config = {"above": 10.0}
await _run_apply_condition(
registries, IN_RANGE_CHECK, config, defaults=IN_RANGE_DEFAULTS
)
assert (
f"return ::{PARENT_OBJ}->state >= 10.0f && ::{PARENT_OBJ}->state <= INFINITY;"
in _apply_definition(mock_cg)
)
assert config == {"above": 10.0}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("platform", "value", "expected"),
@@ -2007,6 +2025,28 @@ async def test_nested_repeat_lambda_parameters_are_distinct() -> None:
assert "uint32_t iteration, uint32_t iteration)" not in text
def _render_in_range(config: dict) -> tuple[str, str]:
config = {**IN_RANGE_DEFAULTS, **config}
(above_key, _, low), (below_key, _, high) = IN_RANGE_CHECK.members
return low(config, config[above_key]), high(config, config[below_key])
@pytest.mark.parametrize(
("config", "expected"),
[
({"above": 10.0}, ("10.0f", "INFINITY")),
({"below": 30.0}, ("-INFINITY", "30.0f")),
({"above": 10.0, "below": 30.0}, ("10.0f", "30.0f")),
# A NaN bound counted as unset before; with no lower bound a NaN upper bound stays false.
({"above": float("nan"), "below": 30.0}, ("-INFINITY", "30.0f")),
({"above": 10.0, "below": float("nan")}, ("10.0f", "INFINITY")),
({"above": float("nan"), "below": float("nan")}, ("-INFINITY", "NAN")),
],
)
def test_in_range_check_renders_bounds(config: dict, expected: tuple[str, str]) -> None:
assert _render_in_range(config) == expected
def test_maybe_simple_id_schema_extract() -> None:
validator = maybe_simple_id({cv.Required(CONF_ID): cv.string})
_, key = validator(SCHEMA_EXTRACT)