Compare commits

..
Author SHA1 Message Date
J. Nick Koston 0bb86f8094 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:37:40 -06:00
J. Nick Koston bf9e2b7a54 Merge branch 'dev' into binary-sensor-click-callback 2026-11-01 13:03:51 -10:00
J. Nick Koston fd000ead9f Merge dev 2026-11-01 11:52:49 -10:00
J. Nick Koston 42956e0173 [binary_sensor] Read the click configs once and share the test callback
Pair each click key with its detector class in one table, add the define
and create the detectors in one pass, register every test callback
through one helper, and point the skill at the in-tree example instead
of copying it.
2026-10-31 08:33:28 -10:00
J. Nick Koston 7edd1a8ea7 Merge branch 'binary-sensor-click-callback-tests' into binary-sensor-click-callback
# Conflicts:
#	tests/components/binary_sensor/click_trigger_test.cpp
2026-10-31 08:15:50 -10:00
J. Nick Koston 35e03009e6 [binary_sensor] Test that a double click resets its times
A short second press after a double click would otherwise pair with the
first release and the next gap.
2026-10-31 08:14:30 -10:00
J. Nick Koston df69028380 [binary_sensor] Test a press published by double click actions
Pin that the double click times reset before the actions run, so a
press an action publishes on the same sensor is kept.
2026-10-31 08:13:31 -10:00
J. Nick Koston 46c0cd859f Merge branch 'binary-sensor-click-callback-tests' into binary-sensor-click-callback
# Conflicts:
#	tests/components/binary_sensor/click_trigger_test.cpp
2026-10-31 07:54:21 -10:00
J. Nick Koston 419660bb67 [binary_sensor] Fix clang-tidy in the click test and hold the press after a double click 2026-10-31 07:54:11 -10:00
J. Nick Koston 22ba443107 [binary_sensor] Fix clang-tidy in the click test and hold the press after a double click 2026-10-31 07:54:08 -10:00
pre-commit-ci-lite[bot] 4015cea895 apply automatic formatting fixes 2026-10-31 17:51:49 +00:00
J. Nick Koston d4a8b149a9 [binary_sensor] Name the click detectors and build them as callback entries
Rename the state objects to ClickDetector and DoubleClickDetector with
check(), write the detector id as ::id through ApplyCall so a trigger id
named like the callback parameter cannot hide it, build both automations
from _CALLBACK_AUTOMATIONS, share one schema helper, and document the
stateful filter pattern in the automations skill.
2026-10-31 07:46:59 -10:00
J. Nick Koston 97732c9a25 [binary_sensor] Build on_click and on_double_click as callback automations
The click timing moves into small ClickState and DoubleClickState objects
that a generated state callback asks, so an automation whose actions are
all inline runs as one function with no Trigger or Automation object.
ClickTrigger and DoubleClickTrigger are removed; only codegen used them.
2026-10-31 07:40:40 -10:00
J. Nick Koston e3bb14be43 [binary_sensor] Add tests for the click and double click triggers
Pin the click and double click timing with a C++ unit test, and the
generated trigger code with a codegen test.
2026-10-31 07:38:50 -10:00
14 changed files with 209 additions and 906 deletions

No files matched your search

+9 -2
View File
@@ -123,10 +123,17 @@ Both live in `esphome/core/helpers.h` and accept `std::function`, lambdas and fo
their templatized `add()` method. `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first
callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
A filter that keeps state between calls (timing, edges) still needs no Trigger class: give each automation a
small object with constant initializers, created from its `CONF_TRIGGER_ID` before the automations are built,
and let `when` ask it, e.g. `ApplyCall("{}->check(state, {}, {})", ((CONF_TRIGGER_ID, ...), ...))`. An `ID`
value in an `ApplyCall` is written as `::id`, so a callback parameter cannot hide it. `on_click` in
`esphome/components/binary_sensor/__init__.py` is the in-tree example; once a second component needs this,
move the object creation into `CallbackAutomation`.
## Triggers: Trigger class method
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state
beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the state must be set up from the
parent (in the constructor or in `setup()`), or the trigger needs its own timers.
**Python:**
```python
+49 -47
View File
@@ -134,10 +134,8 @@ BinarySensorInitiallyOff = binary_sensor_ns.class_(
BinarySensorPtr = BinarySensor.operator("ptr")
# Triggers
ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.template())
DoubleClickTrigger = binary_sensor_ns.class_(
"DoubleClickTrigger", automation.Trigger.template()
)
ClickDetector = binary_sensor_ns.class_("ClickDetector")
DoubleClickDetector = binary_sensor_ns.class_("DoubleClickDetector")
MultiClickTrigger = binary_sensor_ns.class_(
"MultiClickTrigger", automation.Trigger.template(), cg.Component
)
@@ -450,6 +448,23 @@ def validate_click_timing(value):
return value
def _click_automation_schema(detector: MockObjClass) -> cv.All:
return cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(detector),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
)
def validate_publish_initial_state(value):
value = cv.boolean(value)
_LOGGER.warning(
@@ -482,35 +497,9 @@ _BINARY_SENSOR_SCHEMA = (
): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
cv.Optional(CONF_ON_CLICK): cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ClickTrigger),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
),
cv.Optional(CONF_ON_DOUBLE_CLICK): cv.All(
automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DoubleClickTrigger
),
cv.Optional(
CONF_MIN_LENGTH, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_LENGTH, default="350ms"
): cv.positive_time_period_milliseconds,
}
),
validate_click_timing,
cv.Optional(CONF_ON_CLICK): _click_automation_schema(ClickDetector),
cv.Optional(CONF_ON_DOUBLE_CLICK): _click_automation_schema(
DoubleClickDetector
),
cv.Optional(CONF_ON_MULTI_CLICK): automation.validate_automation(
{
@@ -564,6 +553,11 @@ def binary_sensor_schema(
return _BINARY_SENSOR_SCHEMA.extend(schema)
_CLICK_DETECTORS = (
(CONF_ON_CLICK, ClickDetector),
(CONF_ON_DOUBLE_CLICK, DoubleClickDetector),
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_PRESS,
@@ -586,32 +580,40 @@ _CALLBACK_AUTOMATIONS = (
"add_full_state_callback",
[(cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x")],
),
*(
automation.CallbackAutomation(
conf_key,
"add_on_state_callback",
params=[(bool, "state")],
forward=[],
when=automation.ApplyCall(
"{}->check(state, {}, {})",
(
(CONF_TRIGGER_ID, detector.operator("ptr")),
(CONF_MIN_LENGTH, cg.uint32),
(CONF_MAX_LENGTH, cg.uint32),
),
),
)
for conf_key, detector in _CLICK_DETECTORS
),
)
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _build_binary_sensor_automations(var, config):
# The detectors must exist before the automations that name them are built
if clicks := [c for key, _ in _CLICK_DETECTORS for c in config.get(key, ())]:
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
for conf in clicks:
cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK):
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
if config.get(CONF_ON_MULTI_CLICK):
cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER")
if config.get(CONF_ON_RAW_VALUE):
cg.add_define("USE_BINARY_SENSOR_RAW_CALLBACK")
for conf in config.get(CONF_ON_CLICK, []):
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_DOUBLE_CLICK, []):
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_MULTI_CLICK, []):
timings = [
cg.StructInitializer(
@@ -136,6 +136,28 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
return length >= min_length && length <= max_length;
}
}
bool ClickDetector::check(bool state, uint32_t min_length, uint32_t max_length) {
if (state) {
this->start_time_ = millis();
return false;
}
return match_interval(min_length, max_length, millis() - this->start_time_);
}
bool DoubleClickDetector::check(bool state, uint32_t min_length, uint32_t max_length) {
const uint32_t now = millis();
if (state && this->start_time_ != 0 && this->end_time_ != 0 &&
match_interval(min_length, max_length, this->end_time_ - this->start_time_) &&
match_interval(min_length, max_length, now - this->end_time_)) {
this->start_time_ = 0;
this->end_time_ = 0;
return true;
}
this->start_time_ = this->end_time_;
this->end_time_ = now;
return false;
}
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER
} // namespace esphome::binary_sensor
+11 -40
View File
@@ -24,54 +24,25 @@ static_assert(std::is_same_v<decltype(MultiClickTriggerEvent::state), uint32_t>
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length);
class ClickTrigger final : public Trigger<> {
/// Times the presses of one on_click automation.
class ClickDetector {
public:
explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length)
: min_length_(min_length), max_length_(max_length) {
parent->add_on_state_callback([this](bool state) {
if (state) {
this->start_time_ = millis();
} else {
const uint32_t length = millis() - this->start_time_;
if (match_interval(this->min_length_, this->max_length_, length))
this->trigger();
}
});
}
/// Whether `state` ends a press of min_length to max_length ms; a max_length of 0 means no maximum.
bool check(bool state, uint32_t min_length, uint32_t max_length);
protected:
uint32_t start_time_{0}; /// The millis() time when the click started.
uint32_t min_length_; /// Minimum length of click. 0 means no minimum.
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
uint32_t start_time_{0}; ///< The millis() time when the press started.
};
class DoubleClickTrigger final : public Trigger<> {
/// Times the presses of one on_double_click automation.
class DoubleClickDetector {
public:
explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length)
: min_length_(min_length), max_length_(max_length) {
parent->add_on_state_callback([this](bool state) {
const uint32_t now = millis();
if (state && this->start_time_ != 0 && this->end_time_ != 0) {
if (match_interval(this->min_length_, this->max_length_, this->end_time_ - this->start_time_) &&
match_interval(this->min_length_, this->max_length_, now - this->end_time_)) {
this->trigger();
this->start_time_ = 0;
this->end_time_ = 0;
return;
}
}
this->start_time_ = this->end_time_;
this->end_time_ = now;
});
}
/// Whether `state` is the second press, with the first press and the gap both in the window.
bool check(bool state, uint32_t min_length, uint32_t max_length);
protected:
uint32_t start_time_{0};
uint32_t end_time_{0};
uint32_t min_length_; /// Minimum length of click. 0 means no minimum.
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
uint32_t start_time_{0}; ///< The millis() time of the edge before the last one.
uint32_t end_time_{0}; ///< The millis() time of the last edge.
};
class MultiClickTrigger final : public Trigger<>, public Component {
+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);
@@ -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
@@ -8,18 +8,24 @@ from esphome.core import CORE
def test_click_triggers(generate_main: Callable[[str | Path], str]) -> None:
"""Each trigger gets its timing in milliseconds, defaults 50 and 350, and its own automation."""
"""Each trigger gets a detector and a callback with its timing in milliseconds, defaults 50 and 350."""
main_cpp = generate_main("tests/component_tests/binary_sensor/test_click.yaml")
triggers = re.findall(
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Trigger\(button_a, (\d+), (\d+)\);",
main_cpp,
kinds = dict(
re.findall(
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Detector\(\);", main_cpp
)
)
assert [trigger[1:] for trigger in triggers] == [
calls = re.findall(r"if \(!\(::(\w+)->check\(state, (\d+), (\d+)\)\)\)", main_cpp)
assert [(kinds[detector_id], *timing) for detector_id, *timing in calls] == [
("Click", "50", "350"),
("Click", "1000", "3000"),
("DoubleClick", "20", "400"),
]
for trigger_id, *_ in triggers:
assert f"Automation<>({trigger_id});" in main_cpp
assert main_cpp.count("button_a->add_on_state_callback([](") == 3
# Only the automation with a delay keeps an Automation object
assert re.findall(r"new\((\w+)\) Automation<>\(\);", main_cpp) == [
"automation_id_2"
]
assert "::automation_id_2->trigger();" in main_cpp
assert "USE_BINARY_SENSOR_CLICK_TRIGGER" in {define.name for define in CORE.defines}
@@ -21,7 +21,9 @@ binary_sensor:
- delay: 1s
- logger.log: a long click done
on_double_click:
- min_length: 20ms
# Named like the callback parameter, which must not hide it
- trigger_id: state
min_length: 20ms
max_length: 400ms
then:
- logger.log: a double click
@@ -1,16 +1,15 @@
#include <gtest/gtest.h>
#include <memory>
#include <functional>
#include <utility>
#include "esphome/components/binary_sensor/automation.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/binary_sensor/filter.h"
#include "esphome/core/automation.h"
#include "esphome/core/base_automation.h"
#include "esphome/core/hal.h"
namespace esphome::binary_sensor::testing {
// The triggers read the real millis(), so every wait is at least 80 ms away from a window edge
// The click detectors read the real millis(), so every wait is at least 80 ms away from a window edge
static constexpr uint32_t MIN_MS = 100;
static constexpr uint32_t MAX_MS = 300;
static constexpr uint32_t SHORT_MS = 20;
@@ -24,18 +23,24 @@ class ClickTriggerTest : public ::testing::Test {
void SetUp() override {
fired = 0;
this->sensor_.publish_state(false);
this->automation_.add_action(&this->action_);
}
// As codegen builds on_click and on_double_click
void add_click_(uint32_t min_length = MIN_MS, uint32_t max_length = MAX_MS, BinarySensor *sensor = nullptr) {
this->click_ = std::make_unique<ClickTrigger>(sensor != nullptr ? sensor : &this->sensor_, min_length, max_length);
this->click_->set_automation_parent(&this->automation_);
this->watch_(this->click_, min_length, max_length, sensor != nullptr ? *sensor : this->sensor_);
}
void add_double_click_() {
this->double_click_ = std::make_unique<DoubleClickTrigger>(&this->sensor_, MIN_MS, MAX_MS);
this->double_click_->set_automation_parent(&this->automation_);
void add_double_click_(std::function<void()> on_fire = count_fire) {
this->watch_(this->double_click_, MIN_MS, MAX_MS, this->sensor_, std::move(on_fire));
}
template<typename D>
void watch_(D &detector, uint32_t min_length, uint32_t max_length, BinarySensor &sensor,
std::function<void()> on_fire = count_fire) {
sensor.add_on_state_callback([&detector, min_length, max_length, on_fire = std::move(on_fire)](bool state) {
if (detector.check(state, min_length, max_length))
on_fire();
});
}
static void count_fire() { fired++; }
void press_for_(uint32_t length) {
this->sensor_.publish_state(true);
@@ -44,10 +49,8 @@ class ClickTriggerTest : public ::testing::Test {
}
BinarySensor sensor_;
StatelessLambdaAction<> action_{[]() { fired++; }};
Automation<> automation_;
std::unique_ptr<ClickTrigger> click_;
std::unique_ptr<DoubleClickTrigger> double_click_;
ClickDetector click_;
DoubleClickDetector double_click_;
};
TEST(ClickMatchInterval, BoundsAreInclusive) {
@@ -198,4 +201,23 @@ TEST_F(ClickTriggerTest, DoubleClickDoesNotFireTwiceWithoutReset) {
EXPECT_EQ(fired, 1);
}
TEST_F(ClickTriggerTest, DoubleClickKeepsPressPublishedByItsActions) {
// The times reset before the actions run, so a press the first action publishes is kept
this->add_double_click_([this]() {
if (++fired == 1) {
this->sensor_.publish_state(false);
this->sensor_.publish_state(true);
}
});
this->press_for_(INSIDE_MS);
delay(INSIDE_MS);
this->sensor_.publish_state(true);
EXPECT_EQ(fired, 1);
delay(INSIDE_MS);
this->sensor_.publish_state(false);
delay(INSIDE_MS);
this->sensor_.publish_state(true);
EXPECT_EQ(fired, 2);
}
} // namespace esphome::binary_sensor::testing
+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