diff --git a/esphome/automation.py b/esphome/automation.py index 32f423d2a34..7fdeb904ec6 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -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) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 5822c95c7ef..8d507abcdd6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -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) diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index b3d9dfaffbe..3d12a7d3d15 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -26,27 +26,4 @@ class ValueRangeTrigger final : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class NumberInRangeCondition final : public Condition { - 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 diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79c6785be24..ab7e21739e4 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -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): diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index fdd20461e77..faff964d212 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -58,27 +58,4 @@ class ValueRangeTrigger final : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class SensorInRangeCondition final : public Condition { - 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 diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index b56d0a2920e..6b90dcc33f7 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -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