Compare commits

..
Author SHA1 Message Date
J. Nick Koston 222d330464 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:39:15 -06:00
J. Nick Koston 0fffaa5c4f [sensor] Cover single segment exact calibrate_linear 2026-11-11 18:43:11 -06:00
J. Nick Koston 55a6c1183c Merge branch 'dev' into apply-filter-sensor2 2026-11-11 18:15:45 -06:00
J. Nick Koston a0ab3d2634 [sensor] Emit clamp bounds as written so the compiler rounds them 2026-11-11 18:08:46 -06:00
J. Nick Koston f8598751bc Merge remote-tracking branch 'upstream/dev' into apply-filter-sensor2
# Conflicts:
#	esphome/components/sensor/__init__.py
#	esphome/components/sensor/filter.h
2026-11-11 18:01:54 -06:00
J. Nick Koston 89961864cf Merge remote-tracking branch 'origin/apply-filter-helper2' into apply-filter-sensor2 2026-11-11 13:02:30 -06:00
J. Nick Koston 7a51ca87b4 Merge remote-tracking branch 'origin/dev' into apply-filter-helper2 2026-11-11 13:01:08 -06:00
J. Nick Koston 87e7acdc2b Pass the schema to APPLY_FILTER.build for calibrate_linear 2026-11-10 11:02:34 -08:00
J. Nick Koston 247e31e4d2 Merge remote-tracking branch 'origin/apply-filter-helper2' into st-apply-filter-sensor2 2026-11-10 11:00:10 -08:00
J. Nick Koston 6521dd8269 Require the schema in ApplyFilterDomain.build so a misspelled key always fails 2026-11-10 10:58:38 -08:00
J. Nick Koston bdf4643b97 Sync the shared function helper: accept Statement lines in the body 2026-11-09 21:34:14 -08:00
J. Nick Koston bb9ec63e6b Reject a filter with no statements 2026-11-09 21:23:58 -08:00
J. Nick Koston aeb77b1e6b Keep the value range function name; sync the shared function helper; trim docstrings and tests 2026-11-09 21:08:48 -08:00
J. Nick Koston e627f13b42 Merge remote-tracking branch 'upstream/dev' into apply-filter-helper2
# Conflicts:
#	esphome/automation.py
2026-11-09 20:50:25 -08:00
J. Nick Koston c4ebe611dd [atm90e32] Use ApplyFilter in the threshold test now MultiplyFilter is gone 2026-11-04 19:07:44 -06:00
J. Nick Koston 3061f9a9c1 Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-11-04 18:35:35 -06:00
J. Nick Koston c4133a1af5 Merge remote-tracking branch 'origin/dev' into apply-filter-helper2
# Conflicts:
#	tests/unit_tests/test_cpp_generator.py
2026-11-04 18:33:16 -06:00
J. Nick Koston c2f651b803 Test that build() with a schema accepts valid keys 2026-11-03 19:43:43 -06:00
J. Nick Koston c7472e9068 Merge remote-tracking branch 'origin/dev' into apply-filter-helper2 2026-11-03 19:30:00 -06:00
J. Nick Koston 2fdd4434dd Reject a skipped last apply filter statement and let build check schema keys 2026-11-03 19:09:25 -06:00
J. Nick Koston acc7fd2143 [core] Adapt apply filters to the dev apply function changes
Filter functions keep the parameters the filter class passes, so they are
emitted directly instead of through _apply_function, which now turns value
parameters into const references. The filter body renders through
_render_call, which replaced _render_values.
2026-11-03 18:52:25 -06:00
J. Nick Koston fc9743e5c7 Merge remote-tracking branch 'origin/dev' into apply-filter-helper2
# Conflicts:
#	esphome/automation.py
2026-11-03 18:50:21 -06:00
J. Nick Koston 08880dda0a Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-11-02 20:41:58 -06:00
J. Nick Koston a26ab932e2 Merge remote-tracking branch 'origin/dev' into apply-filter-helper2
# Conflicts:
#	tests/unit_tests/test_automation.py
2026-11-02 20:41:39 -06:00
J. Nick Koston 3fd62a666c Merge dev 2026-11-02 09:36:15 -06:00
J. Nick Koston 79f1c56ca8 Merge remote-tracking branch 'origin/apply-filter-helper2' into apply-filter-sensor2 2026-10-30 20:11:45 -10:00
J. Nick Koston 3ebb022f55 Merge remote-tracking branch 'origin/dev' into apply-filter-helper2
# Conflicts:
#	esphome/automation.py
#	tests/unit_tests/test_automation.py
2026-10-30 20:11:25 -10:00
J. Nick Koston cf5c08c07b Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 18:45:20 -10:00
J. Nick Koston e732191329 Merge remote-tracking branch 'origin/codegen-shared-static-function' into apply-filter-helper2 2026-10-28 18:45:19 -10:00
J. Nick Koston 2be6a24507 Record apply functions where shared_static_function emits them 2026-10-28 18:43:42 -10:00
J. Nick Koston 2b355fcb14 Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 18:34:42 -10:00
J. Nick Koston 5a838cf0e6 Name the binary sensor apply filter's parameter plain filter
The generated function only holds statements from code generation; user
lambda code is always in functions of its own.
2026-10-28 18:34:13 -10:00
J. Nick Koston ff8a3ea2be Round clamp bounds to float as the old filter did, and spell generated calls as plain strings
A bound that rounds to FLT_MAX stays a bound and one beyond float range
is no bound, matching the float literals ClampFilter took. The clamp,
NTC and linear calibration statements are plain ApplyCall strings.
2026-10-28 18:31:45 -10:00
J. Nick Koston 3a8e90615a Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 18:31:05 -10:00
J. Nick Koston d3963a82db Merge remote-tracking branch 'origin/codegen-shared-static-function' into apply-filter-helper2
# Conflicts:
#	esphome/automation.py
2026-10-28 18:29:57 -10:00
J. Nick Koston 206ab92115 Give unshared apply functions config-id-safe names too
apply_static_function always goes through cg.shared_static_function, so
an unshared function also gets a name no config id uses.
2026-10-28 18:29:30 -10:00
J. Nick Koston 426efc1666 Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 18:22:58 -10:00
J. Nick Koston 2c7748606e Merge remote-tracking branch 'origin/codegen-shared-static-function' into apply-filter-helper2 2026-10-28 18:22:56 -10:00
J. Nick Koston 907b3c6d6a [core] Key shared static functions on the rendered body text 2026-10-28 18:06:43 -10:00
J. Nick Koston 967c61da4c Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 18:00:21 -10:00
J. Nick Koston 7f7e4af3ea Name the binary sensor apply filter's filter parameter in one constant 2026-10-28 18:00:05 -10:00
J. Nick Koston a230ffd928 Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 17:58:05 -10:00
J. Nick Koston 62d074aae6 Merge branch 'filter-behaviour-tests' into apply-filter-helper2 2026-10-28 17:58:04 -10:00
J. Nick Koston 250ed963cc Merge commit 'e6bebc298f7ddf77c4d7d6322f79b22a08170ba5' into filter-behaviour-tests 2026-10-28 17:58:04 -10:00
J. Nick Koston 007eadedb3 Generate the clamp filter as one call and drop OffsetFilter and MultiplyFilter
clamp calls clamp_filter_value, the old ClampFilter logic, with a bound
beyond float range passed as NaN. The NTC calls name their functions
through sensor_ns, and a least squares calibration returns its apply
filter directly. OffsetFilter and MultiplyFilter only existed for
codegen; the benchmark builds the generated shape instead.
2026-10-28 17:29:41 -10:00
J. Nick Koston 50f206f9ff Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 17:28:09 -10:00
J. Nick Koston ed076e9210 Share apply filter functions through cg.shared_static_function
The private cache goes; _apply_function passes share on to
apply_static_function. ApplyFilterDomain.new shares only when asked,
value lambda functions are named by apply_static_function, statements
become ApplyCall in one place and render with _render_values, and one
helper checks a call's keys against the schema.
2026-10-28 17:27:28 -10:00
J. Nick Koston 9aa0a835c4 Merge branch 'codegen-shared-static-function' into apply-filter-helper2
# Conflicts:
#	tests/unit_tests/test_automation.py
2026-10-28 17:26:36 -10:00
J. Nick Koston 8c9d117be5 Merge branch 'filter-behaviour-tests' into apply-filter-helper2 2026-10-28 17:26:24 -10:00
J. Nick Koston 1c33d6b960 Pin value lambdas reading an id named x, and keep only the filters the apply stack changes
A sensor offset lambda and a binary delayed_on_off lambda read an entity
with id x. The round, round_to_multiple_of and calibrate_polynomial cases
go, since no change touches those filters; calibrate_linear exact stays
as a control. Each test reads the log lines once.
2026-10-28 17:26:20 -10:00
J. Nick Koston 79ccc2b5c1 Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 16:47:06 -10:00
J. Nick Koston be88427d00 Merge branch 'filter-behaviour-tests' into apply-filter-helper2 2026-10-28 16:46:08 -10:00
J. Nick Koston fbeb90208b Explain why the b_constant to_ntc_resistance case stays NaN 2026-10-28 16:46:04 -10:00
J. Nick Koston 7d5757e78e Note in the description why removing the codegen-only classes is not breaking 2026-10-28 16:36:59 -10:00
J. Nick Koston b593034a2f Make shared static function names unique and add share to apply_static_function 2026-10-28 16:34:16 -10:00
J. Nick Koston 576c635138 Treat a clamp bound that overflows a C++ float as no bound
The filter compares in float, so a bound such as 3.5e38 is infinite
there and was ignored before; check finiteness after the conversion.
2026-10-28 16:28:20 -10:00
J. Nick Koston 1dd7f6045d Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 16:27:35 -10:00
J. Nick Koston a644d8d9a0 Check the timing of the delayed_on_off filter that reads an id x 2026-10-28 16:27:31 -10:00
J. Nick Koston f2651d794d Merge branch 'filter-behaviour-tests' into apply-filter-helper2 2026-10-28 16:27:16 -10:00
J. Nick Koston 2ed6453467 Check when each delayed binary sensor output lands
Each output must come its delay after the source edge it follows, within
a margin for scheduler jitter, so a lost delay fails the test.
2026-10-28 16:27:13 -10:00
J. Nick Koston 9f6b78930b [core] Add cg.shared_static_function to emit one function per distinct body 2026-10-28 16:25:42 -10:00
J. Nick Koston 1bca56ba04 Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 16:22:05 -10:00
J. Nick Koston cc7a9db510 Merge branch 'filter-behaviour-tests' into apply-filter-helper2 2026-10-28 16:21:59 -10:00
J. Nick Koston a20755a857 Fix the fixture indentation for yamllint 2026-10-28 16:21:57 -10:00
J. Nick Koston 9389b5b078 Merge branch 'apply-filter-helper2' into apply-filter-sensor2 2026-10-28 16:20:59 -10:00
J. Nick Koston 5859db98cb Parenthesize the multi-line strings in the shared value test 2026-10-28 16:20:54 -10:00
J. Nick Koston 3161162207 Register the NTC filters as apply filters and drop their classes
The coefficients are ApplyCall key paths into the calibration, and the
conversions are free Steinhart-Hart functions called fully qualified;
ToNTCResistanceFilter and ToNTCTemperatureFilter only existed for
codegen.
2026-10-28 16:02:53 -10:00
J. Nick Koston 12ee725395 Merge branch 'apply-filter-helper2' into apply-filter-sensor2
# Conflicts:
#	esphome/components/sensor/__init__.py
2026-10-28 16:01:52 -10:00
J. Nick Koston 9a8fbf2e2b Let an apply filter builder pick its statements, and keep shared functions in a dataclass
ApplyFilterDomain.build renders the statements over a config, for a
builder that picks them by the shape of its config; prepare is dropped.
The shared functions live in a CORE.data dataclass. Each domain's
APPLY_FILTER sits under its ApplyFilter, and every domain's test checks
the filter has no bytes beyond its base.
2026-10-28 16:01:34 -10:00
J. Nick Koston c0ff8d7e98 Pin text_sensor lambda filters that replace and drop a value
Also mark the to_ntc_resistance b_constant NaN outputs as a known bug.
2026-10-28 16:00:42 -10:00
J. Nick Koston 15aaaae719 Merge branch 'filter-behaviour-tests' into apply-filter-helper2 2026-10-28 16:00:42 -10:00
J. Nick Koston 88eaaa86c8 Give each apply filter value lambda its own function
Inlined into the filter's function, a value lambda saw the function's x
parameter, so an id named x, such as id(x).state, no longer named that
entity and failed to compile. Each value lambda is now a function
without parameters, called from the filter's function, as it was its
own lambda before. The integration tests cover an id named x in a
sensor offset lambda and a binary sensor delayed_on_off lambda.
2026-10-28 15:59:06 -10:00
J. Nick Koston a493403ef5 Generate offset, multiply, clamp, NTC and single segment calibrate_linear filters as apply filters
Each filter is now a generated function with its constants baked in and
value lambdas called inline, and the filter object is only the Filter
base; equal constant filters share one function. The NTC conversions
move to static convert() methods. ClampFilter only existed for codegen
and is removed.
2026-10-28 14:44:33 -10:00
J. Nick Koston aac2f28df2 Let an apply filter map its config before rendering
prepare turns a shorthand value into the mapping the statements read.
2026-10-28 14:33:08 -10:00
J. Nick Koston 5daa9693d0 Add an apply filter for sensor, binary_sensor and text_sensor
ApplyFilter<Fn> in each domain is a filter whose work and constants are
one generated function, so the object is only the Filter base; the
binary sensor one passes the filter to the function so it can schedule
output. ApplyFilterDomain declares a domain's filters: register() builds
the function from ApplyCall statements, with constants baked in and
value lambdas called inline, and new() takes the statements directly.
Identical functions without user code are emitted once, so equal
constant filters share one class. LambdaExpression.body_lines gives a
lambda's statements with their #line marks for a generated function.
2026-10-28 14:31:25 -10:00
J. Nick Koston 1e6a3c55b6 Pin the output of the stateless sensor filters and the binary sensor delay filters
Integration tests run offset, multiply, clamp, NTC, calibrate_linear,
calibrate_polynomial, lambda and round filters over finite, NaN and
infinite inputs, and the binary sensor delayed_on_off, delayed_on,
delayed_off and lambda filters over one timed input sequence.
2026-10-28 13:58:08 -10:00
30 changed files with 219 additions and 578 deletions

No files matched your search

+1 -1
View File
@@ -741,7 +741,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @arpena @glmnet @Rodrigoah
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
@@ -7,8 +7,6 @@
#include "esphome/core/automation.h"
#include "esphome/components/network/ip_address.h"
#include <atomic>
#ifdef USE_ETHERNET
#ifdef USE_ESP32
@@ -368,9 +366,6 @@ class EthernetComponent final : public Component {
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
StaticVector<EthernetIPStateListener *, ESPHOME_ETHERNET_IP_STATE_LISTENERS> ip_state_listeners_;
#ifdef USE_ESP32
std::atomic<uint8_t> ip_state_pending_{0};
#endif
#endif
#ifdef USE_ETHERNET_CONNECT_TRIGGER
@@ -129,14 +129,6 @@ void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *me
void EthernetComponent::loop() {
const uint32_t now = App.get_loop_component_start_time();
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
// The IP events arrive on the event task; the listeners publish states, so notify them here
if (this->ip_state_pending_.load(std::memory_order_acquire) != 0) {
this->ip_state_pending_.store(0, std::memory_order_relaxed);
this->notify_ip_state_listeners_();
}
#endif
switch (this->state_) {
case EthernetComponentState::STOPPED:
if (this->started_) {
@@ -778,8 +770,7 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
event_name = "ETH connected";
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP)
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
global_eth_component->enable_loop_soon_any_context();
global_eth_component->notify_ip_state_listeners_();
#endif
#if USE_NETWORK_IPV6
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
@@ -807,9 +798,6 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
const esp_netif_ip_info_t *ip_info = &event->ip_info;
ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip));
global_eth_component->got_ipv4_address_ = true;
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
@@ -817,6 +805,9 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
global_eth_component->connected_ = true;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif /* USE_NETWORK_IPV6 */
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#if USE_NETWORK_IPV6
@@ -828,9 +819,6 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
// after a link flap fires another event for the same address.
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s);
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ =
global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT);
@@ -839,6 +827,9 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
global_eth_component->connected_ = global_eth_component->got_ipv4_address_;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#endif /* USE_NETWORK_IPV6 */
+67 -62
View File
@@ -1,5 +1,6 @@
import logging
import math
import struct
from typing import Any
from esphome import automation
@@ -301,8 +302,6 @@ ApplyFilter = sensor_ns.class_("ApplyFilter", Filter)
APPLY_FILTER = automation.ApplyFilterDomain(
FILTER_REGISTRY, ApplyFilter, cg.optional.template(cg.float_), ((cg.float_, "x"),)
)
OffsetFilter = sensor_ns.class_("OffsetFilter", Filter)
MultiplyFilter = sensor_ns.class_("MultiplyFilter", Filter)
ValueListFilter = sensor_ns.class_("ValueListFilter", Filter)
FilterOutValueFilter = sensor_ns.class_("FilterOutValueFilter", ValueListFilter)
ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter)
@@ -321,11 +320,8 @@ HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter)
DeltaFilter = sensor_ns.class_("DeltaFilter", Filter)
OrFilter = sensor_ns.class_("OrFilter", Filter)
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter)
ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter)
RHCorrectionFilter = sensor_ns.class_("RHCorrectionFilter", Filter)
CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter)
ClampFilter = sensor_ns.class_("ClampFilter", Filter)
RoundFilter = sensor_ns.class_("RoundFilter", Filter)
RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter)
RoundSignificantDigitsFilter = sensor_ns.class_("RoundSignificantDigitsFilter", Filter)
@@ -424,16 +420,16 @@ def sensor_schema(
return _SENSOR_SCHEMA.extend(schema)
@FILTER_REGISTRY.register("offset", OffsetFilter, cv.templatable(cv.float_))
async def offset_filter_to_code(config, filter_id):
template_ = await cg.templatable(config, [], cg.float_)
return cg.new_Pvariable(filter_id, template_)
@FILTER_REGISTRY.register("multiply", MultiplyFilter, cv.templatable(cv.float_))
async def multiply_filter_to_code(config, filter_id):
template_ = await cg.templatable(config, [], cg.float_)
return cg.new_Pvariable(filter_id, template_)
APPLY_FILTER.register(
"offset",
cv.templatable(cv.float_),
automation.ApplyCall("return x + {};", ((automation.WHOLE_CONFIG, cg.float_),)),
)
APPLY_FILTER.register(
"multiply",
cv.templatable(cv.float_),
automation.ApplyCall("return x * {};", ((automation.WHOLE_CONFIG, cg.float_),)),
)
TemplatableFloat = cg.esphome_ns.class_("TemplatableFn").template(cg.float_)
@@ -838,6 +834,18 @@ def validate_calibrate_linear(config):
LINEAR_SEGMENT = cg.std_ns.class_("array").template(cg.float_, 3)
_LINEAR = automation.ApplyCall(
"return x * {} + {};", (("k", cg.float_), ("b", cg.float_))
)
_LINEAR_SCHEMA = cv.Schema({cv.Required("k"): cv.float_, cv.Required("b"): cv.float_})
async def _linear_filter(filter_id: ID, k: float, b: float) -> MockObj:
return await APPLY_FILTER.build(
"calibrate_linear", {"k": k, "b": b}, filter_id, _LINEAR, schema=_LINEAR_SCHEMA
)
@FILTER_REGISTRY.register(
"calibrate_linear",
CalibrateLinearFilter,
@@ -858,12 +866,12 @@ async def calibrate_linear_filter_to_code(config, filter_id):
x = [conf[CONF_FROM] for conf in config[CONF_DATAPOINTS]]
y = [conf[CONF_TO] for conf in config[CONF_DATAPOINTS]]
linear_functions = []
if config[CONF_METHOD] == "least_squares":
k, b = fit_linear(x, y)
linear_functions = [[k, b, float("NaN")]]
elif config[CONF_METHOD] == "exact":
linear_functions = map_linear(x, y)
return await _linear_filter(filter_id, *fit_linear(x, y))
linear_functions = map_linear(x, y)
if len(linear_functions) == 1:
# One segment needs no table
return await _linear_filter(filter_id, *linear_functions[0][:2])
# The last segment always has a NaN boundary, so the filter needs no count.
table = cg.shared_progmem_array(
"sensor_calibrate_linear", LINEAR_SEGMENT, linear_functions
@@ -935,14 +943,31 @@ CLAMP_SCHEMA = cv.All(
)
@FILTER_REGISTRY.register("clamp", ClampFilter, CLAMP_SCHEMA)
async def clamp_filter_to_code(config, filter_id):
return cg.new_Pvariable(
filter_id,
config[CONF_MIN_VALUE],
config[CONF_MAX_VALUE],
config[CONF_IGNORE_OUT_OF_RANGE],
)
def _clamp_bound(config: ConfigType, value: float) -> str:
"""The bound as written, NAN for no bound.
A literal beyond float range was infinite in the old ClampFilter, so no bound; one that
rounds to FLT_MAX stays a bound. The compiler rounds the literal, as it always did.
"""
try:
bound = struct.unpack("f", struct.pack("f", value))[0]
except OverflowError:
return "NAN"
return str(cg.safe_exp(value)) if math.isfinite(bound) else "NAN"
APPLY_FILTER.register(
"clamp",
CLAMP_SCHEMA,
automation.ApplyCall(
"return sensor::clamp_filter_value(x, {}, {}, {});",
(
(CONF_MIN_VALUE, cg.float_, _clamp_bound),
(CONF_MAX_VALUE, cg.float_, _clamp_bound),
(CONF_IGNORE_OUT_OF_RANGE, cg.bool_),
),
),
)
@FILTER_REGISTRY.register(
@@ -1235,46 +1260,26 @@ def ntc_process_calibration(value):
}
@FILTER_REGISTRY.register(
_NTC_SCHEMA = cv.Schema({cv.Required(CONF_CALIBRATION): ntc_process_calibration})
_NTC_COEFFICIENTS = tuple(
((CONF_CALIBRATION, key), cg.double) for key in (CONF_A, CONF_B, CONF_C)
)
APPLY_FILTER.register(
"to_ntc_resistance",
ToNTCResistanceFilter,
cv.All(
cv.Schema(
{
cv.Required(CONF_CALIBRATION): ntc_process_calibration,
}
),
_NTC_SCHEMA,
automation.ApplyCall(
"return sensor::steinhart_hart_resistance(x, {}, {}, {});",
_NTC_COEFFICIENTS,
),
)
async def calibrate_ntc_resistance_filter_to_code(config, filter_id):
calib = config[CONF_CALIBRATION]
return cg.new_Pvariable(
filter_id,
calib[CONF_A],
calib[CONF_B],
calib[CONF_C],
)
@FILTER_REGISTRY.register(
APPLY_FILTER.register(
"to_ntc_temperature",
ToNTCTemperatureFilter,
cv.All(
cv.Schema(
{
cv.Required(CONF_CALIBRATION): ntc_process_calibration,
}
),
_NTC_SCHEMA,
automation.ApplyCall(
"return sensor::steinhart_hart_temperature(x, {}, {}, {});",
_NTC_COEFFICIENTS,
),
)
async def calibrate_ntc_temperature_filter_to_code(config, filter_id):
calib = config[CONF_CALIBRATION]
return cg.new_Pvariable(
filter_id,
calib[CONF_A],
calib[CONF_B],
calib[CONF_C],
)
@FILTER_REGISTRY.register(
+17 -38
View File
@@ -215,16 +215,6 @@ optional<float> LambdaFilter::new_value(float value) {
return it;
}
// OffsetFilter
OffsetFilter::OffsetFilter(TemplatableFn<float> offset) : offset_(offset) {}
optional<float> OffsetFilter::new_value(float value) { return value + this->offset_.value(); }
// MultiplyFilter
MultiplyFilter::MultiplyFilter(TemplatableFn<float> multiplier) : multiplier_(multiplier) {}
optional<float> MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); }
// ValueListFilter
bool ValueListFilter::value_matches_any_(float sensor_value) {
int8_t accuracy = this->parent_->get_accuracy_decimals();
@@ -433,25 +423,6 @@ optional<float> CalibratePolynomialFilter::new_value(float value) {
return res;
}
ClampFilter::ClampFilter(float min, float max, bool ignore_out_of_range)
: min_(min), max_(max), ignore_out_of_range_(ignore_out_of_range) {}
optional<float> ClampFilter::new_value(float value) {
if (std::isfinite(this->min_) && !(value >= this->min_)) {
if (this->ignore_out_of_range_) {
return {};
}
return this->min_;
}
if (std::isfinite(this->max_) && !(value <= this->max_)) {
if (this->ignore_out_of_range_) {
return {};
}
return this->max_;
}
return value;
}
RoundFilter::RoundFilter(uint8_t precision) : precision_(precision) {}
optional<float> RoundFilter::new_value(float value) {
if (std::isfinite(value)) {
@@ -469,25 +440,33 @@ optional<float> RoundMultipleFilter::new_value(float value) {
return value;
}
optional<float> ToNTCResistanceFilter::new_value(float value) {
if (!std::isfinite(value)) {
optional<float> clamp_filter_value(float value, float min, float max, bool ignore_out_of_range) {
if (std::isfinite(min) && !(value >= min))
return ignore_out_of_range ? optional<float>{} : min;
if (std::isfinite(max) && !(value <= max))
return ignore_out_of_range ? optional<float>{} : max;
return value;
}
float steinhart_hart_resistance(float temperature, double a, double b, double c) {
if (!std::isfinite(temperature)) {
return NAN;
}
double k = 273.15;
// https://de.wikipedia.org/wiki/Steinhart-Hart-Gleichung#cite_note-stein2_s4-3
double t = static_cast<double>(value) + k;
double y = (this->a_ - 1 / (t)) / (2 * this->c_);
double x = sqrt(pow(this->b_ / (3 * this->c_), 3) + y * y);
double t = static_cast<double>(temperature) + k;
double y = (a - 1 / (t)) / (2 * c);
double x = sqrt(pow(b / (3 * c), 3) + y * y);
double resistance = exp(pow(x - y, 1 / 3.0) - pow(x + y, 1 / 3.0));
return resistance;
}
optional<float> ToNTCTemperatureFilter::new_value(float value) {
if (!std::isfinite(value)) {
float steinhart_hart_temperature(float resistance, double a, double b, double c) {
if (!std::isfinite(resistance)) {
return NAN;
}
double lr = log(double(value));
double v = this->a_ + this->b_ * lr + this->c_ * lr * lr * lr;
double lr = log(double(resistance));
double v = a + b * lr + c * lr * lr * lr;
double temp = float(1.0 / v - 273.15);
return temp;
}
+6 -52
View File
@@ -300,27 +300,6 @@ template<auto Fn> class ApplyFilter final : public Filter {
optional<float> new_value(float value) override { return Fn(value); }
};
/// A simple filter that adds `offset` to each value it receives.
class OffsetFilter : public Filter {
public:
explicit OffsetFilter(TemplatableFn<float> offset);
optional<float> new_value(float value) override;
protected:
TemplatableFn<float> offset_;
};
/// A simple filter that multiplies to each value it receives by `multiplier`.
class MultiplyFilter : public Filter {
public:
explicit MultiplyFilter(TemplatableFn<float> multiplier);
optional<float> new_value(float value) override;
protected:
TemplatableFn<float> multiplier_;
};
// Value tables are read in place from flash, which ESP8266 only allows for whole words.
static_assert(sizeof(TemplatableFn<float>) == sizeof(void *), "TemplatableFn<float> must stay a single pointer");
@@ -574,17 +553,6 @@ class CalibratePolynomialFilter : public Filter {
const float *coefficients_; // [count, c0, c1, ...]
};
class ClampFilter : public Filter {
public:
ClampFilter(float min, float max, bool ignore_out_of_range);
optional<float> new_value(float value) override;
protected:
float min_{NAN};
float max_{NAN};
bool ignore_out_of_range_;
};
class RoundFilter : public Filter {
public:
explicit RoundFilter(uint8_t precision);
@@ -616,27 +584,13 @@ template<uint8_t Digits> class RoundSignificantDigitsFilter : public Filter {
}
};
class ToNTCResistanceFilter : public Filter {
public:
ToNTCResistanceFilter(double a, double b, double c) : a_(a), b_(b), c_(c) {}
optional<float> new_value(float value) override;
/// Clamp to [min, max] for the clamp filter; a non-finite bound is no bound, and NaN input fails the first set bound.
/// Out of range gives the bound, or empty to drop the value with `ignore_out_of_range`.
optional<float> clamp_filter_value(float value, float min, float max, bool ignore_out_of_range);
protected:
double a_;
double b_;
double c_;
};
class ToNTCTemperatureFilter : public Filter {
public:
ToNTCTemperatureFilter(double a, double b, double c) : a_(a), b_(b), c_(c) {}
optional<float> new_value(float value) override;
protected:
double a_;
double b_;
double c_;
};
/// Steinhart-Hart conversions in double precision for the to_ntc_* filters; non-finite input gives NaN.
float steinhart_hart_resistance(float temperature, double a, double b, double c);
float steinhart_hart_temperature(float resistance, double a, double b, double c);
/** RH Temperature correction filter
*
-1
View File
@@ -82,7 +82,6 @@ class Sensor : public EntityBase {
*
* sensor->add_filters({
* LambdaFilter([&](float value) -> optional<float> { return 42/value; }),
* OffsetFilter(1),
* SlidingWindowMovingAverageFilter(15, 15), // average over last 15 values
* });
*/
+2 -6
View File
@@ -5,15 +5,13 @@ from esphome.const import CONF_MODEL
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@glmnet", "@arpena", "@Rodrigoah"]
CODEOWNERS = ["@glmnet", "@arpena"]
whirlpool_ns = cg.esphome_ns.namespace("whirlpool")
WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR)
Model = whirlpool_ns.enum("Model")
MODELS = Model.options(
"DG11J1-3A", "DG11J1-91", "HISENSE_ARG", "HITACHI_HCRA31NEWH", prefix="MODEL_"
)
MODELS = Model.options("DG11J1-3A", "DG11J1-91", "HISENSE_ARG", prefix="MODEL_")
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend(
{
@@ -28,5 +26,3 @@ async def to_code(config: ConfigType) -> None:
# cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared
if config[CONF_MODEL] == "HISENSE_ARG":
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
elif config[CONF_MODEL] == "HITACHI_HCRA31NEWH":
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
+15 -61
View File
@@ -2,8 +2,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome::whirlpool {
ESPHOME_LOG_TAG(TAG, "whirlpool.climate");
@@ -46,17 +44,6 @@ static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15
static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15
static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C
// Hitachi HCRA31NEWH model: power is a state in every frame
static constexpr uint8_t HITACHI_BYTE10 = 0x20; // byte 10
static constexpr uint8_t HITACHI_COOL = 0x02; // byte 15
static constexpr uint8_t HITACHI_OFF = 0x01; // byte 15
static constexpr uint8_t HITACHI_BYTE18_ON = 0x38; // byte 18
static constexpr uint8_t HITACHI_BYTE18_OFF = 0x28; // byte 18
static constexpr uint8_t HITACHI_BYTE19 = 0x08; // byte 19
// Header, 21 bytes of 8 bits, two dividers and the footer mark
static constexpr uint32_t WHIRLPOOL_FRAME_TIMINGS = 2 + WHIRLPOOL_STATE_LENGTH * 16 + 2 * 2 + 1;
WhirlpoolClimate::WhirlpoolClimate()
: climate_ir::ClimateIR(
WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true,
@@ -68,19 +55,19 @@ void WhirlpoolClimate::transmit_state() {
this->last_transmit_time_ = millis(); // setting the time of the last transmission.
uint8_t remote_state[WHIRLPOOL_STATE_LENGTH];
memset(remote_state, 0, sizeof(remote_state));
put_be16(remote_state, this->remote_id_());
remote_state[0] = 0x83;
remote_state[1] = 0x06;
remote_state[6] = 0x80;
remote_state[18] = 0x08;
remote_state[18] = 0x08; // the same on every supported model
const bool powered_on = this->mode != climate::CLIMATE_MODE_OFF;
// Hitachi sends power as a state instead of a toggle
const bool power_toggle = !this->is_hitachi_hcra31newh_() && powered_on != this->powered_on_assumed;
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
const bool power_toggle = powered_on != this->powered_on_assumed;
if (power_toggle) {
// Set power toggle command
remote_state[2] = 4;
remote_state[15] = 1;
this->powered_on_assumed = powered_on;
}
this->powered_on_assumed = powered_on;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT_COOL:
// set fan auto
@@ -106,10 +93,6 @@ void WhirlpoolClimate::transmit_state() {
remote_state[15] = 6;
break;
case climate::CLIMATE_MODE_OFF:
// These remotes keep the cool code in the power-off frame
if (this->is_hisense_arg_() || this->is_hitachi_hcra31newh_())
remote_state[3] = WHIRLPOOL_COOL;
break;
default:
break;
}
@@ -119,7 +102,6 @@ void WhirlpoolClimate::transmit_state() {
// Temperature
auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_()));
const auto temp_step = (uint8_t) (temp - this->temperature_min_());
this->target_temperature = temp; // publish what the unit was sent
if (hisense_arg && this->fixed_temp_mode_()) {
remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4;
} else {
@@ -149,6 +131,7 @@ void WhirlpoolClimate::transmit_state() {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
remote_state[8] = WHIRLPOOL_SWING_BYTE8;
}
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
} else if (this->send_swing_cmd_) {
if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
@@ -156,11 +139,6 @@ void WhirlpoolClimate::transmit_state() {
}
}
if (hisense_arg)
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
if (this->is_hitachi_hcra31newh_())
this->encode_hitachi_hcra31newh_(remote_state);
// Checksum
remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]);
remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]);
@@ -170,7 +148,6 @@ void WhirlpoolClimate::transmit_state() {
// Send code
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->reserve(WHIRLPOOL_FRAME_TIMINGS);
data->set_carrier_frequency(38000);
@@ -203,6 +180,10 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY;
switch (this->mode) {
case climate::CLIMATE_MODE_OFF:
// The remote keeps the cool code in the power-off frame
remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL;
break;
case climate::CLIMATE_MODE_DRY:
// The unit runs these fan speeds itself; the user's choice is kept for the next mode change
remote_state[2] &= ~WHIRLPOOL_FAN_MASK;
@@ -231,27 +212,6 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
#endif
}
void WhirlpoolClimate::encode_hitachi_hcra31newh_(uint8_t *remote_state) const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
remote_state[10] = HITACHI_BYTE10;
remote_state[18] = HITACHI_BYTE18_ON;
remote_state[19] = HITACHI_BYTE19;
// The unit takes the whole state from every frame: only the off frame carries the power bit
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
remote_state[15] = HITACHI_COOL;
break;
case climate::CLIMATE_MODE_OFF:
remote_state[2] |= WHIRLPOOL_POWER;
remote_state[15] = HITACHI_OFF;
remote_state[18] = HITACHI_BYTE18_OFF;
break;
default:
break;
}
#endif
}
bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
// Check if the esp isn't currently transmitting.
if (millis() - this->last_transmit_time_ < 500) {
@@ -274,8 +234,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
if (!data.expect_item(WHIRLPOOL_BIT_MARK, WHIRLPOOL_GAP))
return false;
}
// The Hitachi remote always sends all 21 bytes; byte 18 holds its power state
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid() && !this->is_hitachi_hcra31newh_()) {
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid()) {
// Remote control only sent 14 bytes, nothing more to read, not even the footer
ESP_LOGV(TAG, "Remote control only sent %d bytes", i);
skip_footer = true;
@@ -302,18 +261,13 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
ESP_LOGV_HEX(TAG, remote_state, WHIRLPOOL_STATE_LENGTH, WHIRLPOOL_STATE_LENGTH, "Received: %s");
// verify header remote code
if (encode_uint16(remote_state[0], remote_state[1]) != this->remote_id_())
if (remote_state[0] != 0x83 || remote_state[1] != 0x06)
return false;
// powr on/off button
ESP_LOGV(TAG, "Power: %02X", (remote_state[2] & WHIRLPOOL_POWER));
if (this->is_hitachi_hcra31newh_()) {
// Power is a state: byte 18 says whether the unit is off
this->powered_on_assumed = remote_state[18] != HITACHI_BYTE18_OFF;
if (!this->powered_on_assumed)
this->mode = climate::CLIMATE_MODE_OFF;
} else if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
if (powered_on) {
@@ -355,7 +309,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
int temp = remote_state[3] & 0xF0;
ESP_LOGVV(TAG, "Temperature Raw: %02X", temp);
temp = (uint8_t) temp >> 4;
temp = std::min(temp + static_cast<int>(this->temperature_min_()), static_cast<int>(this->temperature_max_()));
temp += static_cast<int>(this->temperature_min_());
ESP_LOGVV(TAG, "Temperature Climate: %u", temp);
this->target_temperature = temp;
}
+4 -19
View File
@@ -6,10 +6,9 @@ namespace esphome::whirlpool {
/// Simple enum to represent models.
enum Model {
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
MODEL_HITACHI_HCRA31NEWH = 3, /// Hitachi with the HCRA31NEWH remote, 16 to 30
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
};
// Temperature
@@ -30,9 +29,7 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
/// Override control to change settings of the climate device.
void control(const climate::ClimateCall &call) override {
// The toggle remotes flip swing on every press, so only send one when the mode really changes
auto swing = call.get_swing_mode();
this->send_swing_cmd_ = swing.has_value() && *swing != this->swing_mode;
send_swing_cmd_ = call.get_swing_mode().has_value();
climate_ir::ClimateIR::control(call);
}
@@ -58,22 +55,10 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
return false;
#endif
}
/// False when the model is compiled out, so the compiler drops the Hitachi branches.
bool is_hitachi_hcra31newh_() const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
return this->model_ == MODEL_HITACHI_HCRA31NEWH;
#else
return false;
#endif
}
/// The remote ID sent in bytes 0 and 1.
uint16_t remote_id_() const { return this->is_hitachi_hcra31newh_() ? 0xA959 : 0x8306; }
/// Modes in which the Hisense ARG model sends a fixed temperature.
bool fixed_temp_mode_() const;
/// Add the Hisense ARG bytes on top of the common whirlpool frame.
void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const;
/// Add the Hitachi HCRA31NEWH bytes on top of the common whirlpool frame.
void encode_hitachi_hcra31newh_(uint8_t *remote_state) const;
/// Set the time of the last transmission.
uint32_t last_transmit_time_{};
+1 -1
View File
@@ -563,7 +563,7 @@ class WiFiComponent final : public Component {
// (In most use cases you won't need these)
/// Setup WiFi interface.
void setup() override;
#if defined(USE_ESP32) || defined(USE_LN882X) || defined(USE_BK72XX)
#if defined(USE_LN882X) || defined(USE_BK72XX)
void on_powerdown() override;
#endif
void start();
@@ -1635,21 +1635,6 @@ bool WiFiComponent::wifi_dpp_start_listen_() {
void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); }
#endif
void WiFiComponent::on_powerdown() {
// Leave the AP before esp_restart() or deep sleep stops Wi-Fi. esp_wifi_stop() while associated
// makes the ESP32 retransmit its Disassociation up to the retry limit although the AP ACKs it
// (32 copies with protected management frames), which some APs handle badly.
// Upstream bug: https://github.com/espressif/esp-idf/issues/19443 - remove this once it is fixed
// in the ESP-IDF versions we support.
// Ask the driver: s_sta_connected lags until the main loop has handled the connect event.
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK)
return;
ESP_LOGD(TAG, "Disconnecting before powerdown");
esp_wifi_disconnect();
delay(100); // NOLINT: once per reboot or deep sleep, lets the disassociation go out
}
bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); }
bssid_t WiFiComponent::wifi_bssid() {
-2
View File
@@ -294,8 +294,6 @@ void Application::reboot() {
for (auto &component : std::ranges::reverse_view(this->components_)) {
component->on_shutdown();
}
// No teardown on a forced reboot, but still power hardware down (Wi-Fi leaves the AP)
this->run_powerdown_hooks();
arch_restart();
}
void Application::safe_reboot() {
+2 -4
View File
@@ -185,12 +185,10 @@ class Component {
*/
virtual bool teardown() { return true; }
/** Called last before a reboot or deep sleep to power down hardware.
/** Called after teardown is complete to power down hardware.
*
* On a safe shutdown this is called after all components have finished their teardown process,
* This is called after all components have finished their teardown process,
* making it safe to power down hardware like ethernet PHY.
* On a forced reboot (Application::reboot()) it is called right after on_shutdown() with no
* teardown, so implementations must not assume teardown() has run.
*/
virtual void on_powerdown() {}
-1
View File
@@ -387,7 +387,6 @@
#define USE_WATER_HEATER
#define USE_WATER_HEATER_VISUAL_OVERRIDES
#define USE_WHIRLPOOL_HISENSE_ARG
#define USE_WHIRLPOOL_HITACHI_HCRA31NEWH
#define USE_ZWAVE_PROXY
// Feature flags which do not work for zephyr
@@ -51,13 +51,17 @@ static void SensorFilter_ExponentialMovingAvg(benchmark::State &state) {
}
BENCHMARK(SensorFilter_ExponentialMovingAvg);
// Mirror the code generated for `offset: 1` and `multiply: 2`
static optional<float> offset_1(float x) { return x + 1.0f; }
static optional<float> multiply_2(float x) { return x * 2.0f; }
// Benchmark: sensor publish through a chain of 3 filters (offset + multiply + sliding window)
static void SensorFilter_Chain3(benchmark::State &state) {
Sensor sensor;
sensor.add_filters({
new OffsetFilter([]() -> float { return 1.0f; }),
new MultiplyFilter([]() -> float { return 2.0f; }),
new ApplyFilter<offset_1>(),
new ApplyFilter<multiply_2>(),
new SlidingWindowMovingAverageFilter(5, 1, 1),
});
@@ -33,6 +33,26 @@ sensor:
- calibrate_linear:
- 0.0 -> 1.0
- 10.0 -> 21.0
# Exact with one segment: two points, and collinear points that merge into one
- platform: template
id: exact_two_points
lambda: return 1.0;
filters:
- calibrate_linear:
method: exact
datapoints:
- 0.0 -> 3.0
- 10.0 -> 23.0
- platform: template
id: exact_collinear
lambda: return 1.0;
filters:
- calibrate_linear:
method: exact
datapoints:
- 0.0 -> 5.0
- 10.0 -> 35.0
- 20.0 -> 65.0
- platform: template
id: polynomial
lambda: return 1.0;
@@ -0,0 +1,46 @@
esphome:
name: test
esp8266:
board: d1_mini_lite
sensor:
# Bounds are compared as C++ floats: just beyond FLT_MAX is infinite there, so no bound
- platform: template
id: clamp_beyond_max
lambda: return 1.0;
filters:
- clamp:
min_value: 1.25
max_value: 3.4028236e38
- platform: template
id: clamp_beyond_min
lambda: return 1.0;
filters:
- clamp:
min_value: -3.4028236e38
max_value: 2.75
ignore_out_of_range: true
# 3.4028235e38 rounds to FLT_MAX, so it stays a bound
- platform: template
id: clamp_flt_max
lambda: return 1.0;
filters:
- clamp:
min_value: 0.5
max_value: 3.4028235e38
- platform: template
id: clamp_negative_flt_max
lambda: return 1.0;
filters:
- clamp:
min_value: -3.4028235e38
max_value: 5.5
# Rounded by the compiler from the literal, not by Python first
- platform: template
id: clamp_float_midpoint
lambda: return 1.0;
filters:
- clamp:
min_value: 1.0000000596046448
ignore_out_of_range: true
+21 -4
View File
@@ -29,10 +29,14 @@ def test_calibration_tables_are_shared_progmem(
linear = re.findall(
r"static constexpr std::array<float, 3> (\w+)\[\] PROGMEM", main_cpp
)
assert len(linear) == 2 # the two exact filters share one table
exact, least_squares = linear
assert len(linear) == 1 # only the exact filters have a table, and they share it
(exact,) = linear
assert main_cpp.count(f"sensor::CalibrateLinearFilter({exact});") == 2
assert f"sensor::CalibrateLinearFilter({least_squares});" in main_cpp
# A single segment needs no table: its function is baked into an apply filter
assert "return x * 2.0f + 1.0f;" in main_cpp
# So does an exact one, from two points or from collinear points
assert "return x * 2.0f + 3.0f;" in main_cpp
assert "return x * 3.0f + 5.0f;" in main_cpp
poly = re.search(
r"static constexpr float (\w+)\[\] PROGMEM = \{2\.0f, 1\.0f, 2\.0f\};", main_cpp
@@ -41,4 +45,17 @@ def test_calibration_tables_are_shared_progmem(
assert f"sensor::CalibratePolynomialFilter({poly.group(1)});" in main_cpp
# Table names never reuse a user id.
assert exact != "sensor_calibrate_linear"
assert "sensor_calibratelinearfilter_id_data" not in (exact, least_squares)
assert exact != "sensor_calibratelinearfilter_id_data"
def test_clamp_bounds_are_compared_as_float(
generate_main: Callable[[str], str],
) -> None:
"""A bound beyond float range is no bound; one that rounds to FLT_MAX stays a bound."""
main_cpp = generate_main("tests/component_tests/sensor/test_clamp.yaml")
assert "clamp_filter_value(x, 1.25f, NAN, false);" in main_cpp
assert "clamp_filter_value(x, NAN, 2.75f, true);" in main_cpp
assert "clamp_filter_value(x, 0.5f, 3.4028235e+38f, false);" in main_cpp
assert "clamp_filter_value(x, -3.4028235e+38f, 5.5f, false);" in main_cpp
assert "clamp_filter_value(x, 1.0000000596046448f, NAN, true);" in main_cpp
+3 -1
View File
@@ -212,9 +212,11 @@ TEST_F(ATM90E32ThresholdRegisters, RefreshesThresholdsAfterRestoringAndClearingS
}
#endif // USE_HOST
static optional<float> double_value(float value) { return value * 2.0f; }
TEST_F(ATM90E32ThresholdRegisters, CombinesNativeCurrentWithOtherPhaseAlarms) {
sensor::Sensor current;
sensor::MultiplyFilter multiply([]() { return 2.0f; });
sensor::ApplyFilter<double_value> multiply;
current.add_filter(&multiply);
current.publish_state(6.0f);
ASSERT_FLOAT_EQ(current.state, 12.0f);
@@ -2,7 +2,3 @@ packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
# IPv6 with an IP sensor: compiles the IPv6 event handler's listener notification
network:
enable_ipv6: true
+1 -2
View File
@@ -10,8 +10,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.auto_load = ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# Compiles in the Hisense ARG and Hitachi models, which climate.py adds only when configured
# Compiles in the Hisense ARG model, which climate.py adds only when it is configured
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
manifest.to_code = to_code_testing
-5
View File
@@ -8,8 +8,3 @@ climate:
model: HISENSE_ARG
transmitter_id: xmitr
receiver_id: rcvr
- platform: whirlpool
name: Whirlpool Hitachi HCRA31NEWH Climate
model: HITACHI_HCRA31NEWH
transmitter_id: xmitr
receiver_id: rcvr
@@ -252,9 +252,6 @@ TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) {
TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already off";
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0x80);
@@ -264,9 +261,6 @@ TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0);
EXPECT_EQ(frame[8], 0x00);
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already vertical";
}
// Cool 24 C (step 8), fan low, powered on assumed
@@ -391,90 +385,4 @@ TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) {
}
}
// Cool 24 C (step 8), fan low, unit on
static Frame hitachi_cool_frame() {
return with_checksums({0xA9, 0x59, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x38, 0x08, 0x00});
}
// The off frame: power bit, cool code, byte 15 0x01 and byte 18 0x28
static Frame hitachi_off_frame() {
return with_checksums({0xA9, 0x59, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x28, 0x08, 0x00});
}
TEST(WhirlpoolHitachiHCRA31NEWH, CoolAndOffMatchKnownFrames) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW);
// Turning on sends the full state with no power bit
expect_frame(f.last_frame(), hitachi_cool_frame());
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
expect_frame(f.last_frame(), hitachi_off_frame());
// A second off command still sends the power bit
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
EXPECT_EQ(f.last_frame()[2] & 0x04, 0x04);
f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW);
const Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x04, 0);
EXPECT_EQ(frame[3], 0x80);
EXPECT_EQ(frame[15], 0x06);
EXPECT_EQ(frame[18], 0x38);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivePowerIsAState) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// An on frame restores the mode even though it has no power bit
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW);
// The same frame again keeps it on
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// A repeated off frame does not toggle
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveChecksRemoteId) {
Fixture hitachi(MODEL_HITACHI_HCRA31NEWH);
EXPECT_FALSE(receive(hitachi.climate, hisense_cool_frame()));
Fixture whirlpool(MODEL_DG11J1_91);
EXPECT_FALSE(receive(whirlpool.climate, hitachi_cool_frame()));
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivedTemperatureIsClamped) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
Frame frame = hitachi_cool_frame();
frame[3] = 0xF2; // step 15 would be 31 C
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_FLOAT_EQ(f.climate.target_temperature, 30);
}
TEST(WhirlpoolDG11J13A, TargetIsClampedAndRoundedToWhatWasSent) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 35, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 32);
f.command(climate::CLIMATE_MODE_COOL, 24.4f, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveRejectsShortFrame) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
remote_base::RawTimings raw = waveform(hitachi_cool_frame());
// Cut after the second divider: 14 bytes, no footer
raw.resize(2 + 14 * 16 + 2 * 2);
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
EXPECT_FALSE(static_cast<remote_base::RemoteReceiverListener &>(f.climate).on_receive(data));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
} // namespace esphome::whirlpool::testing
@@ -1,23 +0,0 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
MULTI_CONF = True
reboot_hook_recorder_ns = cg.esphome_ns.namespace("reboot_hook_recorder")
RebootHookRecorder = reboot_hook_recorder_ns.class_("RebootHookRecorder", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(RebootHookRecorder),
cv.Required(CONF_NAME): cv.string,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_name(config[CONF_NAME]))
@@ -1,10 +0,0 @@
#include "reboot_hook_recorder.h"
#include "esphome/core/log.h"
namespace esphome::reboot_hook_recorder {
ESPHOME_LOG_TAG(TAG, "reboot_hook_recorder");
void RebootHookRecorder::record_(const char *hook) { ESP_LOGI(TAG, "HOOK %s %s", this->name_, hook); }
} // namespace esphome::reboot_hook_recorder
@@ -1,33 +0,0 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome::reboot_hook_recorder {
/// Logs every shutdown lifecycle hook it receives, so a test can check their order.
class RebootHookRecorder : public Component {
public:
void set_name(const char *name) { this->name_ = name; }
void on_safe_shutdown() override { this->record_("on_safe_shutdown"); }
void on_shutdown() override { this->record_("on_shutdown"); }
// Needs a second call to finish, like a component waiting for a connection to close
bool teardown() override {
if (++this->teardown_calls_ < 2) {
this->record_("teardown_pending");
return false;
}
if (this->teardown_calls_ == 2)
this->record_("teardown_done");
return true;
}
void on_powerdown() override { this->record_("on_powerdown"); }
protected:
void record_(const char *hook);
const char *name_{""};
uint8_t teardown_calls_{0};
};
} // namespace esphome::reboot_hook_recorder
@@ -1,19 +0,0 @@
esphome:
name: reboot-hooks-forced
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -1,19 +0,0 @@
esphome:
name: reboot-hooks-safe
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.safe_reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -1,80 +0,0 @@
"""Test the order of the shutdown lifecycle hooks on a forced and on a safe reboot."""
import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
HOOK_PATTERN = re.compile(r"HOOK (\w+) (\w+)")
# Hooks run in reverse component order, so "first" gets each hook last.
LAST_HOOK = "HOOK first on_powerdown"
async def _record_reboot_hooks(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> list[tuple[str, str]]:
"""Connect, which makes the device reboot, and return its (component, hook) calls in order."""
waiter = LineWaiter()
hooks: list[tuple[str, str]] = []
def on_line(line: str) -> None:
if match := HOOK_PATTERN.search(line):
hooks.append((match[1], match[2]))
waiter.callback(line)
async with (
run_compiled(yaml_config, line_callback=on_line),
api_client_connected(),
):
await wait_or_fail(
waiter.wait_for(LAST_HOOK),
5.0,
"Device did not run the powerdown hooks",
hooks=hooks,
)
return hooks
@pytest.mark.asyncio
async def test_reboot_hook_order_forced(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A forced reboot runs on_shutdown then on_powerdown, with no safe shutdown or teardown."""
hooks = await _record_reboot_hooks(yaml_config, run_compiled, api_client_connected)
assert hooks == [
("second", "on_shutdown"),
("first", "on_shutdown"),
("second", "on_powerdown"),
("first", "on_powerdown"),
]
@pytest.mark.asyncio
async def test_reboot_hook_order_safe(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A safe reboot powers down only after every component has finished its teardown."""
hooks = await _record_reboot_hooks(yaml_config, run_compiled, api_client_connected)
assert hooks == [
("second", "on_safe_shutdown"),
("first", "on_safe_shutdown"),
("second", "on_shutdown"),
("first", "on_shutdown"),
("second", "teardown_pending"),
("first", "teardown_pending"),
("second", "teardown_done"),
("first", "teardown_done"),
("second", "on_powerdown"),
("first", "on_powerdown"),
]