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
16 changed files with 252 additions and 955 deletions

No files matched your search

+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
* });
*/
+51
View File
@@ -337,6 +337,57 @@ size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size
// Parsing & formatting
// A plain decimal converts exactly with one division: the digits accumulate
// into an integer that stays below 2^24 and the fraction's scale is a power of
// ten up to 1e9 (2^9 * 5^9), so both convert to float exactly and IEEE division
// rounds the quotient correctly, the same result strtof produces.
static constexpr uint32_t PARSE_FLOAT_MANTISSA_LIMIT = 1000000; // one more digit stays below 2^24
static constexpr uint32_t PARSE_FLOAT_MAX_FRACTION_DIGITS = 9;
optional<float> parse_float(const char *str) {
const char *p = str;
const bool negative = *p == '-';
if (negative || *p == '+')
p++;
const char *digits = p;
const char *dot = nullptr;
uint32_t mantissa = 0;
for (;; p++) {
const uint32_t d = static_cast<unsigned char>(*p) - '0';
if (d > 9) {
if (*p == '.' && dot == nullptr) {
dot = p;
continue;
}
break;
}
if (mantissa >= PARSE_FLOAT_MANTISSA_LIMIT)
break;
mantissa = mantissa * 10 + d;
}
const uint32_t fraction_digits = dot != nullptr ? p - dot - 1 : 0;
const bool any_digit = p - digits > (dot != nullptr ? 1 : 0);
if (*p == '\0' && any_digit && fraction_digits <= PARSE_FLOAT_MAX_FRACTION_DIGITS) {
// The mantissa is exact in a float, so the sign goes on before the one rounding step
float value = static_cast<float>(mantissa);
if (negative)
value = -value;
if (fraction_digits != 0) {
uint32_t scale = 10;
for (uint32_t i = 1; i < fraction_digits; i++)
scale *= 10;
value /= static_cast<float>(scale);
}
return value;
}
// Exponents, hex, inf, nan, leading whitespace and longer inputs take the libc path.
char *end = nullptr;
const float value = ::strtof(str, &end);
if (end == str || *end != '\0' || value == HUGE_VALF)
return {};
return value;
}
size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) {
size_t chars = std::min(length, 2 * count);
for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) {
+2 -21
View File
@@ -25,7 +25,6 @@
#include <strings.h>
#include "esphome/core/optional.h"
#include "esphome/core/parse_float.h"
#include "esphome/core/time_conversion.h"
#ifdef USE_ESP8266
@@ -1034,26 +1033,6 @@ inline float pow10_int(int8_t exp) {
return result;
}
/// 10^n, exact while it fits in T.
template<typename T> constexpr T pow10_exact(uint32_t n) {
T result = 1;
T base = 10;
for (;;) {
if ((n & 1) != 0)
result *= base;
n >>= 1;
if (n == 0)
return result;
base *= base;
}
}
/// Whether c is '0' to '9'.
constexpr bool is_digit(char c) { return static_cast<uint8_t>(c - '0') <= 9; }
/// Whether c is C-locale white space, without isspace's ctype table.
constexpr bool is_space_c(char c) { return c == ' ' || (c >= '\t' && c <= '\r'); }
/// Remap \p value from the range (\p min, \p max) to (\p min_out, \p max_out).
template<typename T, typename U> T remap(U value, U min, U max, T min_out, T max_out) {
return (value - min) * (max_out - min_out) / (max - min) + min_out;
@@ -1594,6 +1573,8 @@ template<typename T, enable_if_t<(std::is_integral<T>::value && std::is_signed<T
optional<T> parse_number(const std::string &str) {
return parse_number<T>(str.c_str());
}
/// Parse a decimal floating-point number; plain decimals skip the libc parser.
optional<float> parse_float(const char *str);
/// Parse a decimal floating-point number from a null-terminated string.
template<typename T, enable_if_t<(std::is_same<T, float>::value), int> = 0> optional<T> parse_number(const char *str) {
return parse_float(str);
-372
View File
@@ -1,372 +0,0 @@
// picolibc 1.8.10's strtof, bit for bit, without libc's strtod: decimals round to 9 digits, then to the nearest float.
// Float bits are built from integers, so chips that flush denormals give the same result.
#include "esphome/core/parse_float.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <bit>
namespace esphome {
namespace {
constexpr uint32_t FLOAT_SIGN_BIT = 0x80000000, FLOAT_INF_BITS = 0x7F800000;
constexpr uint32_t FLOAT_NAN_BITS = 0x7FC00000; // nanf(""), which strtof returns for every NaN
constexpr uint32_t DECIMAL_DIGITS = 9, HEX_DIGITS = 8; // digits kept before the rest only round
constexpr int32_t EXPONENT_CAP = 224; // picolibc stops adding exponent digits once the exponent reaches this
// 0.<digits> * 10^exp10 is zero at or below MIN_EXP10 and infinity at or above MAX_EXP10, as in picolibc
constexpr int32_t MIN_EXP10 = -46, MAX_EXP10 = 40;
// Past this, one more digit could pass 2^24
constexpr uint32_t PLAIN_MANTISSA_LIMIT = 1677720;
// Bits of the float nearest to (m + sticky fraction) * 2^e, ties to even; m > 0
uint32_t round_to_float_bits(uint64_t m, int32_t e, bool sticky) {
const int32_t msb = e + static_cast<int32_t>(std::bit_width(m)) - 1; // the value is in [2^msb, 2^(msb + 1))
if (msb >= 128)
return FLOAT_INF_BITS;
if (msb < -150) // below half the smallest float
return 0;
// The unit of the float's last bit: 2^(msb - 23), or 2^-149 for a denormal
const int32_t shift = std::max<int32_t>(msb - 23, -149) - e; // at most 64
uint64_t q;
if (shift <= 0) {
q = m << -shift;
} else {
q = shift < 64 ? m >> shift : 0;
const uint64_t rem = shift < 64 ? m & ((uint64_t{1} << shift) - 1) : m;
const uint64_t half = uint64_t{1} << (shift - 1);
if (rem > half || (rem == half && (sticky || (q & 1) != 0)))
q++;
}
if (msb < -126)
return static_cast<uint32_t>(q); // 2^23 here is the smallest normal float, as it should be
// Rounding up past the largest float gives exactly FLOAT_INF_BITS
return (static_cast<uint32_t>(msb + 127) << 23) + static_cast<uint32_t>(q - (uint64_t{1} << 23));
}
// The decimal exponents that reach decimal_to_float_bits: 1 to 9 digits and MIN_EXP10 < digits + e10 < MAX_EXP10
constexpr int32_t MIN_E10 = MIN_EXP10 + 1 - static_cast<int32_t>(DECIMAL_DIGITS), MAX_E10 = MAX_EXP10 - 2;
// Top 64 bits of 10^(8k), rounded down: 10^(8k) = entry * 2^(floor_log2_pow10(8k) - 63), exact for k = 0..3
constexpr int32_t POW10_BY_8_FIRST = MIN_E10 >> 3, POW10_BY_8_LAST = MAX_E10 >> 3;
constexpr uint64_t POW10_BY_8[] PROGMEM = {
0xFB158592BE068D2E, 0xBB127C53B17EC159, 0x8B61313BBABCE2C6, 0xCFB11EAD453994BA,
0x9ABE14CD44753B52, 0xE69594BEC44DE15B, 0xABCC77118461CEFC, 0x8000000000000000,
0xBEBC200000000000, 0x8E1BC9BF04000000, 0xD3C21BCECCEDA100, 0x9DC5ADA82B70B59D,
};
static_assert(POW10_BY_8_FIRST == -7 && POW10_BY_8_LAST == 4 &&
sizeof(POW10_BY_8) / sizeof(POW10_BY_8[0]) == POW10_BY_8_LAST - POW10_BY_8_FIRST + 1,
"POW10_BY_8 must cover MIN_E10..MAX_E10");
// floor(q * log2(10)), for |q| < 100
constexpr int32_t floor_log2_pow10(int32_t q) { return (q * 217706) >> 16; }
// The top 64 bits of a * b, rounded down
uint64_t mul_high(uint64_t a, uint64_t b) {
const uint64_t a_lo = static_cast<uint32_t>(a), a_hi = a >> 32;
const uint64_t b_lo = static_cast<uint32_t>(b), b_hi = b >> 32;
const uint64_t hi_lo = a_hi * b_lo, lo_hi = a_lo * b_hi;
const uint64_t middle = ((a_lo * b_lo) >> 32) + static_cast<uint32_t>(hi_lo) + static_cast<uint32_t>(lo_hi);
return a_hi * b_hi + (hi_lo >> 32) + (lo_hi >> 32) + (middle >> 32);
}
// Cold: a decimal within 16 units of a midpoint is an exact tie when m10 * 5^e10 is an integer
__attribute__((noinline, cold)) uint32_t tie_float_bits(uint32_t m10, int32_t e10, uint32_t bits) {
if (e10 >= 0) {
if (e10 <= 14) // m10 * 5^14 stays below 2^64
return round_to_float_bits(m10 * (pow10_exact<uint64_t>(e10) >> e10), e10, false);
} else if (e10 >= -13) {
const auto pow5 = static_cast<uint32_t>(pow10_exact<uint64_t>(-e10) >> -e10);
if (m10 % pow5 == 0)
return round_to_float_bits(m10 / pow5, e10, false);
}
return bits;
}
// The float nearest to m10 * 10^e10 (0 < m10 <= 10^9). The product is under 4 units low; checked for all 85 billion
// (m10, e10), only exact ties and one non-tie (6 units off, keeps its float) come within 34 units of a midpoint.
uint32_t decimal_to_float_bits(uint32_t m10, int32_t e10) {
// Both operands are exact floats, so the one float operation gives the nearest float
const uint32_t power = e10 < 0 ? -e10 : e10;
if (m10 < (1u << 24) && power <= 10) {
const auto value = static_cast<float>(m10);
// 10^10 overflows uint32_t
const float factor = power == 10 ? 1e10f : static_cast<float>(pow10_exact<uint32_t>(power));
return bit_cast<uint32_t>(e10 < 0 ? value / factor : value * factor);
}
const int32_t k8 = e10 >> 3; // rounds down
const uint64_t x = uint64_t{m10} * pow10_exact<uint32_t>(e10 & 7);
const auto zeros = static_cast<int32_t>(std::countl_zero(x));
uint64_t m = x << zeros;
int32_t e = -zeros;
if (k8 == 0)
return round_to_float_bits(m, e, false); // exact
m = mul_high(m, POW10_BY_8[k8 - POW10_BY_8_FIRST]);
e += floor_log2_pow10(8 * k8) + 1;
if ((m >> 63) == 0) {
m <<= 1;
e--;
}
const uint32_t bits = round_to_float_bits(m, e, false);
// The bits below the float's last one, and the midpoint among them
const int32_t shift = std::max<int32_t>(40, -149 - e);
if (shift <= 64) {
const uint64_t low = shift < 64 ? m & ((uint64_t{1} << shift) - 1) : m;
const uint64_t half = uint64_t{1} << (shift - 1);
if (low - half + 16 > 32) // |low - half| > 16
return bits;
}
return tie_float_bits(m10, e10, bits);
}
uint32_t no_number(const char *str, const char **end, bool *negative) {
*end = str;
*negative = false;
return 0;
}
// The rest of an "nan" or "inf" after its first letter at p; returns the end, or nullptr for no number
const char *read_inf_nan(const char *p) {
const auto lower = [](char c) { return static_cast<char>(c | 0x20); };
if (lower(*p) == 'n') {
if (lower(p[1]) != 'a' || lower(p[2]) != 'n')
return nullptr;
p += 3;
if (*p != '(')
return p;
// "nan(" must be closed after letters, digits and '_'
do {
p++;
} while (static_cast<uint8_t>(lower(*p) - 'a') < 26 || is_digit(*p) || *p == '_');
return *p == ')' ? p + 1 : nullptr;
}
if (lower(p[1]) != 'n' || lower(p[2]) != 'f')
return nullptr;
p += 3;
if (lower(*p) != 'i')
return p;
// Past "infi", only "infinity" is a number
return lower(p[1]) == 'n' && lower(p[2]) == 'i' && lower(p[3]) == 't' && lower(p[4]) == 'y' ? p + 5 : nullptr;
}
// Reads a binary exponent "p" or decimal exponent "e" at p into *exp; returns the end of the number
const char *read_exponent(const char *p, char letter, int32_t *exp) {
if ((*p | 0x20) != letter)
return p;
const char *q = p + 1;
const bool negative = *q == '-';
if (negative || *q == '+')
q++;
if (!is_digit(*q))
return p;
int32_t value = 0;
for (; is_digit(*q); q++) {
if (value < EXPONENT_CAP)
value = value * 10 + (*q - '0');
}
*exp += negative ? -value : value;
return q;
}
// Hex digits after the "x" at p, with the point and decimal exponent read so far (no non-zero digit yet)
__attribute__((noinline, cold)) uint32_t read_hex(const char *p, bool dot, int32_t exp, const char **end) {
uint32_t m = 0, kept = 0; // kept: digits in m from its first non-zero one
bool sticky = false;
for (;; p++) {
const char lower = static_cast<char>(*p | 0x20);
uint32_t digit = static_cast<uint8_t>(*p - '0');
if (static_cast<uint8_t>(lower - 'a') <= 5) {
digit = static_cast<uint8_t>(lower - 'a') + 10;
} else if (digit > 9) {
digit = 16; // picolibc takes ':' to '?' as 10 to 15; we stop there like newlib
}
if (digit < 16) {
if (kept < HEX_DIGITS) {
if (dot)
exp--;
m = m * 16 + digit;
kept += m != 0 ? 1 : 0;
} else {
if (!dot)
exp++;
sticky |= digit != 0;
}
} else if (*p == '.' && !dot) {
dot = true;
} else if (lower != 'x' || m != 0) { // picolibc takes another "x" while the value is still zero
break;
}
}
exp *= 4;
*end = read_exponent(p, 'p', &exp);
return m == 0 ? 0 : round_to_float_bits(m, exp, sticky);
}
// picolibc's conv_flt for a float from the first digit or point at p: returns the bits; *end is str for no number
__attribute__((noinline)) uint32_t read_decimal(const char *str, const char *p, const char **end) {
uint32_t m = 0, kept = 0; // the first (up to 9) significant digits, and how many from the first non-zero one
int32_t exp = 0; // the value is m * 10^exp
bool dot = false, any = false;
uint32_t overflow = 0; // digits past the kept ones: 0 none, 1 below half, 2 half, 3 above half
bool full = false; // a digit past the kept ones was read
for (;; p++) {
const uint32_t digit = static_cast<uint8_t>(*p - '0');
if (digit <= 9) {
any = true;
if (kept < DECIMAL_DIGITS) {
if (dot)
exp--;
m = m * 10 + digit;
kept += m != 0 ? 1 : 0;
} else {
if (!dot)
exp++;
if (!full) {
full = true;
overflow = digit > 5 ? 3 : digit == 5 ? 2 : digit != 0 ? 1 : 0; // the first one decides the half
} else {
overflow |= digit != 0 ? 1 : 0;
}
}
} else if (*p == '.' && !dot) {
dot = true;
} else {
break;
}
}
if ((*p | 0x20) == 'x' && any && m == 0)
return read_hex(p + 1, dot, exp, end);
if (!any) {
*end = str;
return 0;
}
*end = read_exponent(p, 'e', &exp);
if (m == 0)
return 0;
// Ties to even on the decimal digits
overflow |= m & 1;
m += (overflow + 1) >> 2;
const int32_t exp10 = static_cast<int32_t>(kept) + exp;
if (exp10 <= MIN_EXP10)
return 0;
if (exp10 >= MAX_EXP10)
return FLOAT_INF_BITS;
return decimal_to_float_bits(m, exp);
}
// picolibc's strtof from the start: returns the bits without the sign, which goes in *negative
__attribute__((noinline)) uint32_t parse_float_full(const char *str, const char **end, bool *negative) {
// No number starts with a letter but i (inf) or n (nan): "unavailable", "on" and "unknown" stop here
const char first = static_cast<char>(*str | 0x20);
if (first >= 'a' && first <= 'z' && first != 'i' && first != 'n')
return no_number(str, end, negative);
const char *p = str;
while (is_space_c(*p))
p++;
*negative = *p == '-';
if (*negative || *p == '+')
p++;
const char lower = static_cast<char>(*p | 0x20);
if (lower == 'i' || lower == 'n') {
const char *stop = read_inf_nan(p);
if (stop == nullptr)
return no_number(str, end, negative);
*end = stop;
return lower == 'i' ? FLOAT_INF_BITS : FLOAT_NAN_BITS;
}
const uint32_t bits = read_decimal(str, p, end);
if (*end == str)
*negative = false;
return bits;
}
// A plain decimal below 2^24 with up to 9 digits after the point, in one float operation; nullptr otherwise
ESPHOME_ALWAYS_INLINE inline const char *parse_plain_decimal(const char *str, uint32_t *bits, bool *negative) {
const char *p = str;
*negative = *p == '-';
if (*negative || *p == '+')
p++;
const char *digits = p;
const char *point = nullptr;
uint32_t mantissa = 0;
for (;; p++) {
const uint32_t d = static_cast<uint8_t>(*p) - '0';
if (d > 9) {
if (*p == '.' && point == nullptr) {
point = p;
continue;
}
break;
}
if (mantissa > PLAIN_MANTISSA_LIMIT)
return nullptr;
mantissa = mantissa * 10 + d;
}
const uint32_t fraction = point != nullptr ? p - point - 1 : 0;
const char c = static_cast<char>(*p | 0x20);
if (p - digits == (point != nullptr ? 1 : 0) || fraction > 9 || c == 'e' || c == 'x')
return nullptr;
float value = static_cast<float>(mantissa);
if (fraction != 0)
value /= static_cast<float>(pow10_exact<uint32_t>(fraction)); // 10^9 is an exact float
*bits = bit_cast<uint32_t>(value);
return p;
}
} // namespace
optional<float> parse_float(const char *str) {
// Own copy of the plain-decimal loop: sharing parse_plain_decimal costs 12 to 18% more instructions
const char *p = str;
const bool negative = *p == '-';
if (negative || *p == '+')
p++;
const char *digits = p;
const char *point = nullptr;
uint32_t mantissa = 0;
for (;; p++) {
const uint32_t d = static_cast<uint8_t>(*p) - '0';
if (d > 9) {
if (*p == '.' && point == nullptr) {
point = p;
continue;
}
break;
}
if (mantissa > PLAIN_MANTISSA_LIMIT)
break;
mantissa = mantissa * 10 + d;
}
const uint32_t fraction = point != nullptr ? p - point - 1 : 0;
if (*p == '\0' && p - digits > (point != nullptr ? 1 : 0) && fraction <= 9) {
float value = negative ? -static_cast<float>(mantissa) : static_cast<float>(mantissa);
if (fraction != 0)
value /= static_cast<float>(pow10_exact<uint32_t>(fraction));
return value;
}
const char *end;
bool full_negative;
const uint32_t bits = parse_float_full(str, &end, &full_negative);
// HUGE_VALF, positive overflow or "inf", is rejected like before
if (end == str || *end != '\0' || (!full_negative && bits == FLOAT_INF_BITS))
return {};
return bit_cast<float>(full_negative ? bits | FLOAT_SIGN_BIT : bits);
}
float str_to_float(const char *str, char **end) {
const char *stop;
bool negative;
uint32_t bits;
if (const char *plain_end = parse_plain_decimal(str, &bits, &negative)) {
stop = plain_end;
} else {
bits = parse_float_full(str, &stop, &negative);
}
if (end != nullptr)
*end = const_cast<char *>(stop); // strtof's signature
return bit_cast<float>(negative ? bits | FLOAT_SIGN_BIT : bits);
}
} // namespace esphome
-17
View File
@@ -1,17 +0,0 @@
#pragma once
#include <cstdint>
#include "esphome/core/optional.h"
namespace esphome {
/// The whole string as str_to_float's syntax (leading space, hex, inf, nan); trailing characters, "inf" and positive
/// overflow are rejected, while -inf, negative overflow and NaN are accepted.
optional<float> parse_float(const char *str);
/// strtof with picolibc's results on every platform, but it never sets errno (overflow gives +-HUGE_VALF, underflow 0);
/// use it instead of strtof, or strtod/atof for a float result.
float str_to_float(const char *str, char **end);
} // namespace esphome
+2 -3
View File
@@ -7,7 +7,6 @@
#include <memory>
#include <string>
#include "esphome/core/defines.h"
#include "esphome/core/parse_float.h"
#ifdef USE_JSON
#include "esphome/components/json/json_util.h"
@@ -294,7 +293,7 @@ inline bool operator!=(const std::string &lhs, const ProgmemStringRef &rhs) { re
#endif
// String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef)
// Must be in esphome namespace for ADL to find them. Uses strtol, strtod and str_to_float to avoid heap allocation.
// Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation.
namespace internal {
// NOLINTBEGIN(google-runtime-int)
template<typename R, typename F> inline R parse_number(const StringRef &str, size_t *pos, F conv) {
@@ -335,7 +334,7 @@ inline long stol(const StringRef &str, size_t *pos = nullptr, int base = 10) {
return internal::parse_number<long>(str, pos, base, std::strtol);
}
inline float stof(const StringRef &str, size_t *pos = nullptr) {
return internal::parse_number<float>(str, pos, str_to_float);
return internal::parse_number<float>(str, pos, std::strtof);
}
inline double stod(const StringRef &str, size_t *pos = nullptr) {
return internal::parse_number<double>(str, pos, std::strtod);
@@ -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),
});
@@ -1,40 +0,0 @@
#include <benchmark/benchmark.h>
#include "esphome/core/helpers.h"
namespace esphome::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
static constexpr int kInnerIterations = 2000;
static const char *const PLAIN[] = {"0.5", "255", "-12.75", "21.3"};
static const char *const EXPONENT[] = {"1e3", "2.5e-2", "-6.02E5", "1.5e+1"};
static const char *const PRECISE[] = {"3.14159265358979", "0.000123456789", "123456789.125", "-0.1000000000000000055"};
// Home Assistant states that are not numbers
static const char *const REJECT[] = {"unavailable", "unknown", "on", "off"};
template<size_t N, typename F> static void run(benchmark::State &state, const char *const (&inputs)[N], F parse) {
for (auto _ : state) {
float sum = 0.0f;
for (int i = 0; i < kInnerIterations; i++)
sum += parse(inputs[i % N]);
benchmark::DoNotOptimize(sum);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
static float parse_number_float(const char *str) { return parse_number<float>(str).value_or(0.0f); }
static void ParseNumberFloat_Plain(benchmark::State &state) { run(state, PLAIN, parse_number_float); }
BENCHMARK(ParseNumberFloat_Plain);
static void ParseNumberFloat_Exponent(benchmark::State &state) { run(state, EXPONENT, parse_number_float); }
BENCHMARK(ParseNumberFloat_Exponent);
static void ParseNumberFloat_Precise(benchmark::State &state) { run(state, PRECISE, parse_number_float); }
BENCHMARK(ParseNumberFloat_Precise);
static void ParseNumberFloat_Reject(benchmark::State &state) { run(state, REJECT, parse_number_float); }
BENCHMARK(ParseNumberFloat_Reject);
} // namespace esphome::benchmarks
@@ -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);
+11 -342
View File
@@ -1,74 +1,34 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <random>
#include <string>
#include "esphome/core/helpers.h"
#include "esphome/core/parse_float.h"
#ifdef USE_HOST
namespace esphome::core::testing {
static uint32_t bits(float value) { return bit_cast<uint32_t>(value); }
// picolibc rounds a decimal to 9 significant digits and converts those to the nearest float, so up to 9
// significant digits its result is the correctly rounded float the host's strtof (glibc, macOS) gives. Longer
// input and the syntax where libcs differ are checked against values taken from picolibc 1.8.10's own strtof.
static float reference_strtof(const char *str, char **end) {
const float value = ::strtof(str, end);
if (std::isnan(value))
return std::signbit(value) ? -NAN : NAN;
return value;
static uint32_t bits(float value) {
uint32_t out;
memcpy(&out, &value, sizeof(out));
return out;
}
// Every accepted input must give the reference's bits.
// strtof is the reference: every accepted input must give the same bits.
static void expect_matches_strtof(const char *str) {
const float expected = reference_strtof(str, nullptr);
char *end = nullptr;
const float expected = ::strtof(str, &end);
const auto parsed = parse_number<float>(str);
if (!parsed.has_value()) {
ADD_FAILURE() << str << " was rejected";
return;
}
EXPECT_EQ(bits(parsed.value()), bits(expected)) << str;
const float actual = parsed.value();
EXPECT_EQ(bits(actual), bits(expected)) << str << " parsed " << actual << " expected " << expected;
}
// The same bits and end as the host's strtof; for decimals of up to 9 significant digits
static void expect_str_to_float_matches(const char *str) {
char *expected_end = nullptr;
const float expected = reference_strtof(str, &expected_end);
char *end = nullptr;
const float actual = str_to_float(str, &end);
EXPECT_EQ(bits(actual), bits(expected)) << '"' << str << '"';
EXPECT_EQ(end - str, expected_end - str) << '"' << str << '"';
}
struct PicolibcCase {
const char *str;
uint32_t bits;
int32_t end;
};
// Bits and end from picolibc 1.8.10's strtof
static void expect_picolibc(const PicolibcCase &c) {
char *end = nullptr;
const float value = str_to_float(c.str, &end);
EXPECT_EQ(bits(value), c.bits) << '"' << c.str << '"';
EXPECT_EQ(end - c.str, c.end) << '"' << c.str << '"';
}
static void expect_picolibc(const std::string &str, uint32_t expected) {
char *end = nullptr;
EXPECT_EQ(bits(str_to_float(str.c_str(), &end)), expected) << str.substr(0, 40);
EXPECT_EQ(static_cast<size_t>(end - str.c_str()), str.size()) << str.substr(0, 40);
}
static float float_from(uint32_t value) { return bit_cast<float>(value); }
TEST(ParseFloat, PlainDecimalsMatchStrtof) {
for (const char *str :
{"0", "-0", "+0", "1", "-1", "100.50", "99.75", "0.1", "0.3",
@@ -86,14 +46,6 @@ TEST(ParseFloat, SweepOfTwoDecimalValuesMatchesStrtof) {
}
}
// Eight-digit mantissas below 2^24 stay on the one-division path; the next digit past the limit falls back
TEST(ParseFloat, EightDigitPlainDecimalsMatchStrtof) {
for (const char *str : {"12345678", "-12345678", "1234.5678", "0.12345678", "16777209", "1677720.9", "0.16777209",
"16777210", "16777215", "1677721.5", "99999999", "9999999.9", "16777209."}) {
expect_matches_strtof(str);
}
}
// A fraction longer than the fast path accepts must fall back, however long it is.
TEST(ParseFloat, LongFractionsMatchStrtof) {
std::string str = "0.";
@@ -116,293 +68,10 @@ TEST(ParseFloat, LibcPathInputsMatchStrtof) {
// Positive overflow and a positive literal infinity both come back as HUGE_VALF from strtof, and the parser
// rejects both; the negative forms pass through, as they did before the fast path.
TEST(ParseFloat, RejectsInvalidInput) {
for (const char *str : {"", "-", "+", ".", "abc", "12abc", "1.2.3", "1,5", "12 ", "--1", "1e", "1e400", "inf",
"infinity", "infi", "nan(12 ab)", "nan(12"}) {
for (const char *str :
{"", "-", "+", ".", "abc", "12abc", "1.2.3", "1,5", "12 ", "--1", "1e", "1e400", "inf", "infinity"}) {
EXPECT_FALSE(parse_number<float>(str).has_value()) << str;
}
}
// Above the midpoint 1 + 2^-24 by less than half a double: newlib's strtof rounds to the midpoint and then to
// 1.0f; picolibc's 9 digits, 1.00000006, give the next float.
TEST(ParseFloat, FullPathRoundsLikePicolibc) {
EXPECT_EQ(bits(parse_number<float>("1.0000000596046448").value()), 0x3F800001u);
EXPECT_EQ(bits(parse_number<float>("-1.0000000596046448").value()), 0xBF800001u);
}
// picolibc takes "nan(" with letters, digits and '_' up to a ")", and nothing at all otherwise
TEST(ParseFloat, NanForms) {
EXPECT_TRUE(std::isnan(parse_number<float>("nan(12)").value()));
EXPECT_TRUE(std::isnan(parse_number<float>("nan(xyz_1)").value()));
EXPECT_FALSE(parse_number<float>("nan(x y)").has_value());
}
// The callers' overflow check: a positive overflow is HUGE_VALF and rejected, a negative one is -inf and accepted
TEST(ParseFloat, OverflowIsHugeValf) {
EXPECT_FALSE(parse_number<float>("1e39").has_value());
EXPECT_FALSE(parse_number<float>("3.40282357e38").has_value());
EXPECT_FALSE(parse_number<float>("1e99999").has_value());
EXPECT_EQ(bits(parse_number<float>("-1e39").value()), 0xFF800000u);
EXPECT_EQ(bits(parse_number<float>("3.4028235e38").value()), 0x7F7FFFFFu);
}
// Up to 9 significant digits, with leading and trailing zeros, a point anywhere and an exponent
TEST(StrToFloat, RandomDecimalsMatchStrtof) {
std::mt19937_64 rng(1); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
for (int i = 0; i < 50000; i++) {
std::string str;
if (rng() % 3 == 0)
str += (rng() & 1) ? "-" : "+";
const int zeros = static_cast<int>(rng() % 4);
const int significant = 1 + static_cast<int>(rng() % 9);
const int trailing = static_cast<int>(rng() % 4);
std::string digits(zeros, '0');
digits += static_cast<char>('1' + rng() % 9);
for (int j = 1; j < significant; j++)
digits += static_cast<char>('0' + rng() % 10);
digits.append(trailing, '0');
const int point = static_cast<int>(rng() % (digits.size() + 2)) - 1;
for (int j = 0; j < static_cast<int>(digits.size()); j++) {
if (j == point)
str += '.';
str += digits[j];
}
if (rng() & 1) {
str += (rng() & 1) ? 'e' : 'E';
char exp[8];
snprintf(exp, sizeof(exp), "%d", static_cast<int>(rng() % 110) - 60);
str += exp;
}
expect_str_to_float_matches(str.c_str());
}
}
// Every 16411th float, printed with 6 and 9 digits
TEST(StrToFloat, FloatsMatchStrtof) {
char buf[64];
for (uint32_t u = 0; u < 0x7F800000; u += 16411) {
for (int precision : {6, 9}) {
snprintf(buf, sizeof(buf), "%.*e", precision - 1, static_cast<double>(float_from(u)));
expect_str_to_float_matches(buf);
}
}
}
TEST(StrToFloat, SyntaxMatchesStrtof) {
// Tokens whose combinations the libcs read alike: no "x", "nan(" or "infi"
static const char *const TOKENS[] = {"0", "1", "9", ".", "e", "E", "+", "-", "a", "N", " ", "\t", "y", "00", "#", ""};
for (const char *a : TOKENS) {
for (const char *b : TOKENS) {
for (const char *c : TOKENS) {
for (const char *d : TOKENS) {
const std::string str = std::string(a) + b + c + d;
expect_str_to_float_matches(str.c_str());
}
}
}
}
}
// Where newlib and glibc read the text differently from picolibc
TEST(StrToFloat, SyntaxMatchesPicolibc) {
static const PicolibcCase CASES[] = {
// "x" after any zero digits starts hex, even after a point or a second "0x"
{"000x", 0x00000000, 4},
{"0x", 0x00000000, 2},
{"-0x", 0x80000000, 3},
{"00x1", 0x3F800000, 4},
{"0x0x1", 0x3F800000, 5},
{"0.0x1", 0x3B800000, 5},
{".x1", 0x00000000, 0},
{"0x.p1", 0x00000000, 5},
{"0x.8", 0x3F000000, 4},
{"0X1.8", 0x3FC00000, 5},
{"0x1P-1", 0x3F000000, 6},
{"0x1p+", 0x3F800000, 3},
{"1x5", 0x3F800000, 1},
// "inf" stops before anything but "i"; past "infi" only "infinity" is a number
{"inf", 0x7F800000, 3},
{"-inf", 0xFF800000, 4},
{"INFINITY", 0x7F800000, 8},
{"+infi0", 0x00000000, 0},
{"infi", 0x00000000, 0},
{"infinit", 0x00000000, 0},
// "nan(" takes letters, digits and '_' up to ")", and is no number without the ")"
{"NaN", 0x7FC00000, 3},
{"nan()", 0x7FC00000, 5},
{"nan(xyz)", 0x7FC00000, 8},
{"nan(_a1)", 0x7FC00000, 8},
{"-nan(1)", 0xFFC00000, 7},
{"nan( )", 0x00000000, 0},
{"nan(12 ab)", 0x00000000, 0},
{"nan(12", 0x00000000, 0},
// An exponent needs a digit
{"1e", 0x3F800000, 1},
{"1e+", 0x3F800000, 1},
{"1e-x", 0x3F800000, 1},
{"\t 12", 0x41400000, 4},
{"- 1", 0x00000000, 0},
{"1.2.3", 0x3F99999A, 3},
};
for (const PicolibcCase &c : CASES)
expect_picolibc(c);
}
// The inputs where newlib's strtof (decimal to double to float) and picolibc's were found to differ; every platform
// now gives picolibc's result, which is also the correctly rounded float for these
TEST(StrToFloat, RoundsLikePicolibcWhereNewlibDoubleRounds) {
expect_picolibc({"1.0000000596046448", 0x3F800001, 18});
expect_picolibc({"0x1.100b0d0000000c", 0x3F880587, 18});
expect_picolibc("1.000000059604644775390625" + std::string(800, '0') + "1", 0x3F800001);
}
// picolibc rounds a decimal to 9 significant digits, ties to even, before converting it: past 9 digits its float can
// be one away from the nearest, and an exact tie between two floats can round up. Values from picolibc.
TEST(StrToFloat, NineDigitRoundingLikePicolibc) {
static const PicolibcCase CASES[] = {
{"1.0856183295439e10", 0x5021C50E, 18}, // nearest 0x5021C50D
{"3.97496754550e-4", 0x39D0671D, 16}, // nearest 0x39D0671C
{"9.76178673657108930e23", 0x674EB6C1, 22}, // nearest 0x674EB6C0
{"156.6562236e-26", 0x17F269ED, 15}, // nearest 0x17F269EC
{"1.000000059604644775390625", 0x3F800001, 26}, // the tie 1 + 2^-24: nearest-even 0x3F800000
{"1.000000059", 0x3F800001, 11}, // 1.00000006 after rounding
{"7.0064923216240854e-46", 0x00000000, 22}, // 7.00649232e-46, below half the smallest float
{"21.299999999999997", 0x41AA6666, 18},
{"0.30000000000000004", 0x3E99999A, 19},
{"0.1000000000000000055511151231257827", 0x3DCCCCCD, 36},
};
for (const PicolibcCase &c : CASES)
expect_picolibc(c);
}
// Nine digits converted exactly: ties to even between floats, where the 64-bit product cannot decide alone
TEST(StrToFloat, NineDigitTiesGoToEven) {
static const PicolibcCase CASES[] = {
{"16777217", 0x4B800000, 8}, {"16777219", 0x4B800002, 8}, {"16777217.0", 0x4B800000, 10},
{"8388608.5", 0x4B000000, 9}, {"8388609.5", 0x4B000002, 9}, {"3e10", 0x50DF8476, 4},
{"5e10", 0x513A43B7, 4}, {"1.00000006", 0x3F800001, 10},
};
for (const PicolibcCase &c : CASES)
expect_picolibc(c);
}
// RP2350 flushes float denormals in its double to float conversion; #22677 bench. The parser builds the bits
// with integer operations, so every denormal parses to its own value.
TEST(StrToFloat, DenormalsParseWithoutFlushing) {
char buf[64];
const auto check = [&buf](uint32_t k) {
snprintf(buf, sizeof(buf), "%.8e", std::ldexp(static_cast<double>(k), -149));
EXPECT_EQ(bits(str_to_float(buf, nullptr)), k) << buf;
};
for (uint32_t k = 0; k <= 0x800000; k += 4099)
check(k);
// The largest denormal and FLT_MIN, where rounding carries into the exponent
for (uint32_t k : {1u, 2u, 0x7FFFFEu, 0x7FFFFFu, 0x800000u})
check(k);
}
// Pico W and Pico 2 W bench, #22677: the RP2040 and RP2350 libcs flush float denormals to 0 in their double to
// float conversion, and the RP2040 one turns a NaN into +infinity
TEST(StrToFloat, Rp2040LibcFlushesAndNanToInfinity) {
static const PicolibcCase CASES[] = {
{"1e-40", 0x000116C2, 5}, {"1.4e-45", 0x00000001, 7}, {"1e-38", 0x006CE3EE, 5},
{"1.1754942e-38", 0x007FFFFF, 13}, {"0x1p-149", 0x00000001, 8}, {"1.17549435e-38", 0x00800000, 14},
{"nan(12)", 0x7FC00000, 7}, {"nan", 0x7FC00000, 3},
};
for (const PicolibcCase &c : CASES)
expect_picolibc(c);
}
// RP2350 bench: libc returned 0 and the first parser stalled stepping up from a flushed guess; #22677 bench
TEST(StrToFloat, DenormalsAndFltMinMatchStrtof) {
for (const char *str : {"1.4e-45", "-1.4e-45", "1e-40", "1e-38", "1.17549435e-38", "1.1754942e-38", "1.17549421e-38",
"1.1754945e-38", "7.0064923e-46", "7.0064924e-46"}) {
expect_str_to_float_matches(str);
}
}
// Home Assistant's non-numeric states; CodSpeed HomeassistantSensor_StateResponse_Unavailable on #22677
TEST(StrToFloat, NonNumericStatesAreRejected) {
for (const char *str : {"unavailable", "unknown", "on", "off", "", "-", ".", "none", "inactive"}) {
expect_str_to_float_matches(str);
EXPECT_FALSE(parse_number<float>(str).has_value()) << str;
}
}
// The one float operation path takes up to 2^24 - 1 and 10^10; #22677 review
TEST(StrToFloat, PlainPathBoundaries) {
for (const char *top : {"16777215", "16777216", "16777217"}) {
for (const char *exp : {"e10", "e-10", "e11", "e-11", "e9", "e-9", ".5", ".25"}) {
const std::string str = std::string(top) + exp;
expect_str_to_float_matches(str.c_str());
}
}
}
// The midpoint between FLT_MAX and 2^128 rounds to infinity; review
TEST(StrToFloat, OverflowMidpoint) {
expect_picolibc({"340282356779733661637539395458142568448", 0x7F800000, 39});
expect_picolibc({"3.40282356e38", 0x7F7FFFFF, 13});
expect_picolibc({"3.40282357e38", 0x7F800000, 13});
EXPECT_EQ(bits(str_to_float("0x1.ffffffp127", nullptr)), 0x7F800000u);
}
// picolibc stops adding exponent digits at 224, so the digits of the mantissa can move it no further
TEST(StrToFloat, HugeExponents) {
for (const char *str : {"1e-99999", "1e99999", "-1e99999", "0e99999", "1e9999999999999999999",
"1e-9999999999999999999", "1e-999999999999", "0.000e99999", "1e224", "1e2240", "1e-2240"}) {
expect_str_to_float_matches(str);
}
// 10^-3000 * 10^3000 is 1, but the exponent counts only as 300
expect_picolibc("0." + std::string(2999, '0') + "1e3000", 0x00000000);
expect_picolibc("1" + std::string(300, '0') + "e-300", 0x3F800000);
}
TEST(StrToFloat, LongMantissas) {
expect_picolibc("1" + std::string(799, '0') + "e-799", 0x3F800000);
expect_picolibc("1." + std::string(3000, '0') + "1e-3001", 0x00000000);
expect_picolibc("0." + std::string(400, '0') + "34028235" + std::string(500, '7') + "e440", 0x7F800000);
expect_picolibc(std::string(300, '9') + "e-261", 0x7F800000);
expect_picolibc("3" + std::string(799, '7') + "e-761", 0x7F800000);
}
// picolibc takes ':' to '?' as hex digits 10 to 15 ("0x1:" is 26); these stop at them, as newlib does. #22677 review
TEST(StrToFloat, HexStopsAtPunctuationAfterNine) {
struct Case {
const char *str;
uint32_t bits;
int32_t end;
};
const Case cases[] = {
{"0x1:", 0x3F800000, 3}, {"0x1;", 0x3F800000, 3}, {"0x1<", 0x3F800000, 3}, {"0x1=", 0x3F800000, 3},
{"0x1>", 0x3F800000, 3}, {"0x1?", 0x3F800000, 3}, {"0x1.:", 0x3F800000, 4}, {"0x:", 0x00000000, 2},
{"0x9?", 0x41100000, 3}, {"0xA:", 0x41200000, 3},
};
for (const auto &c : cases) {
char *end = nullptr;
const float value = str_to_float(c.str, &end);
EXPECT_EQ(bits(value), c.bits) << '"' << c.str << '"';
EXPECT_EQ(end - c.str, c.end) << '"' << c.str << '"';
EXPECT_FALSE(parse_number<float>(c.str).has_value()) << c.str;
}
}
TEST(StrToFloat, HexDenormalsAndOverflow) {
struct Case {
const char *str;
uint32_t bits;
};
static const Case CASES[] = {
{"0x1p-149", 0x00000001}, {"0x1p-150", 0x00000000},
{"0x1.8p-149", 0x00000002}, {"0x1.fffffcp-127", 0x007FFFFF},
{"0x1.fffffep-127", 0x00800000}, {"-0x1p-149", 0x80000001},
{"0x1p128", 0x7F800000}, {"0x1.fffffep127", 0x7F7FFFFF},
{"0x1.fffffefp127", 0x7F7FFFFF}, {"0x1.0000000000001p-150", 0x00000001},
{"0x1.000001p0", 0x3F800000}, {"0x1.000003p0", 0x3F800002},
{"0x1p-99999999999", 0x00000000}, {"0x1p99999999999", 0x7F800000},
};
for (const Case &c : CASES)
EXPECT_EQ(bits(str_to_float(c.str, nullptr)), c.bits) << c.str;
}
} // namespace esphome::core::testing
#endif // USE_HOST