[sensor][number] Build in_range with register_apply_condition

This commit is contained in:
J. Nick Koston
2026-10-15 14:04:35 -10:00
parent 20d0ab9ed0
commit 3901ccccba
6 changed files with 56 additions and 74 deletions
+26
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,
@@ -352,6 +355,29 @@ class ApplyField:
return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),))
def fill_in_range_bounds(config: ConfigType) -> ConfigType:
"""Give an absent ``above`` or ``below`` the value NaN, which ``IN_RANGE_CHECK`` renders."""
return {CONF_ABOVE: math.nan, CONF_BELOW: math.nan, **config}
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``, after ``fill_in_range_bounds``.
IN_RANGE_CHECK = ApplyCall(
"state >= {} && {parent}->state <= {}",
((CONF_ABOVE, cg.float_, _in_range_low), (CONF_BELOW, cg.float_, _in_range_high)),
)
def _config_lookup(config: ConfigType, key: str | tuple[str, ...]) -> Any:
if isinstance(key, str):
return config.get(key)
+3 -15
View File
@@ -167,9 +167,6 @@ ValueRangeTrigger = number_ns.class_(
)
# Conditions
NumberInRangeCondition = number_ns.class_(
"NumberInRangeCondition", automation.Condition
)
NumberMode = number_ns.enum("NumberMode")
@@ -342,22 +339,13 @@ NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All(
cv.Optional(CONF_BELOW): cv.float_,
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
automation.fill_in_range_bounds,
)
@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
)
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)
if (above := config.get(CONF_ABOVE)) is not None:
cg.add(var.set_min(above))
if (below := config.get(CONF_BELOW)) is not None:
cg.add(var.set_max(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
+3 -13
View File
@@ -311,7 +311,6 @@ CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter)
ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", 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)
@@ -1107,22 +1106,13 @@ SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
cv.Optional(CONF_BELOW): cv.float_,
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
automation.fill_in_range_bounds,
)
@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
)
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)
if (above := config.get(CONF_ABOVE)) is not None:
cg.add(var.set_min(above))
if (below := config.get(CONF_BELOW)) is not None:
cg.add(var.set_max(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
+24
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from esphome.automation import (
IN_RANGE_CHECK,
ApplyAction,
ApplyCall,
ApplyCondition,
@@ -25,6 +26,7 @@ from esphome.automation import (
build_parent_callback_automation,
build_trigger_automations,
build_trigger_callback,
fill_in_range_bounds,
has_non_synchronous_actions,
literal_with_length,
maybe_simple_id,
@@ -1608,3 +1610,25 @@ async def test_nested_repeat_lambda_parameters_are_distinct() -> None:
in text
)
assert "uint32_t iteration, uint32_t iteration)" not in text
def _render_in_range(config: dict) -> tuple[str, str]:
config = fill_in_range_bounds(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