mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 09:36:04 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b94440ef46 | ||
|
|
5fe744a30e | ||
|
|
3a5a7ba6d3 | ||
|
|
bc4cbe6c51 | ||
|
|
7123d83a30 | ||
|
|
84fbf1a933 | ||
|
|
29e98feabe | ||
|
|
7d972192fc | ||
|
|
dc238ba271 | ||
|
|
1681f7790d | ||
|
|
359e439b18 | ||
|
|
19ee9db1cf | ||
|
|
57294a4499 | ||
|
|
4ce89e21cc | ||
|
|
aeb099d8a1 | ||
|
|
e3563c3799 | ||
|
|
c0f0c61c54 | ||
|
|
aebf8cf78f | ||
|
|
9d6a805343 | ||
|
|
9497ea0345 | ||
|
|
13457001c3 | ||
|
|
06bdedd3ff | ||
|
|
38b86769b3 | ||
|
|
ce4baa22c0 | ||
|
|
db8c32fe00 | ||
|
|
783cb8e449 | ||
|
|
23b82c7c22 | ||
|
|
330ac831fc | ||
|
|
98ba538a29 | ||
|
|
69621497b1 | ||
|
|
93b7ca3bdd | ||
|
|
a708742c5e |
No files matched your search
@@ -1,7 +1,9 @@
|
||||
from collections.abc import Callable
|
||||
import errno
|
||||
import functools
|
||||
from importlib import resources
|
||||
import logging
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import tzlocal
|
||||
|
||||
@@ -25,7 +27,6 @@ from esphome.const import (
|
||||
CONF_SECOND,
|
||||
CONF_SECONDS,
|
||||
CONF_TIMEZONE,
|
||||
CONF_TRIGGER_ID,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
PLATFORM_BK72XX,
|
||||
@@ -38,6 +39,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
|
||||
from esphome.helpers import cpp_string_escape
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,7 +49,7 @@ DOMAIN = "time"
|
||||
|
||||
time_ns = cg.esphome_ns.namespace("time")
|
||||
RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent)
|
||||
CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Component)
|
||||
_request_on_time_slot = cg.slot_counter("TIME_ON_TIME_COUNT")
|
||||
|
||||
# C++ types for pre-parsed timezone struct generation
|
||||
DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True)
|
||||
@@ -221,7 +223,8 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None):
|
||||
raise cv.Invalid(
|
||||
f"{name} {v} is out of range (min={min_value} max={max_value})."
|
||||
)
|
||||
return sorted(value)
|
||||
# YAML true/false pass as 1/0
|
||||
return sorted({int(v) for v in value})
|
||||
value = cv.string(value)
|
||||
values = set()
|
||||
for part in value.split(","):
|
||||
@@ -231,12 +234,28 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None):
|
||||
return validator
|
||||
|
||||
|
||||
validate_cron_seconds = cron_expression_validator("seconds", 0, 60)
|
||||
validate_cron_minutes = cron_expression_validator("minutes", 0, 59)
|
||||
validate_cron_hours = cron_expression_validator("hours", 0, 23)
|
||||
validate_cron_days_of_month = cron_expression_validator("days of month", 1, 31)
|
||||
validate_cron_months = cron_expression_validator(
|
||||
"months",
|
||||
class CronField(NamedTuple):
|
||||
"""An on_time schedule key, the ESPTime member it checks, its value range and value names."""
|
||||
|
||||
key: str
|
||||
member: str
|
||||
low: int
|
||||
high: int
|
||||
names: dict[str, int] | None = None
|
||||
|
||||
def validator(self) -> Callable[[Any], list[int]]:
|
||||
return cron_expression_validator(
|
||||
self.key.replace("_", " "), self.low, self.high, self.names
|
||||
)
|
||||
|
||||
|
||||
SECONDS_FIELD = CronField(CONF_SECONDS, "second", 0, 60)
|
||||
MINUTES_FIELD = CronField(CONF_MINUTES, "minute", 0, 59)
|
||||
HOURS_FIELD = CronField(CONF_HOURS, "hour", 0, 23)
|
||||
DAYS_OF_MONTH_FIELD = CronField(CONF_DAYS_OF_MONTH, "day_of_month", 1, 31)
|
||||
MONTHS_FIELD = CronField(
|
||||
CONF_MONTHS,
|
||||
"month",
|
||||
1,
|
||||
12,
|
||||
{
|
||||
@@ -254,20 +273,29 @@ validate_cron_months = cron_expression_validator(
|
||||
"DEC": 12,
|
||||
},
|
||||
)
|
||||
validate_cron_days_of_week = cron_expression_validator(
|
||||
"days of week",
|
||||
DAYS_OF_WEEK_FIELD = CronField(
|
||||
CONF_DAYS_OF_WEEK,
|
||||
"day_of_week",
|
||||
1,
|
||||
7,
|
||||
{"SUN": 1, "MON": 2, "TUE": 3, "WED": 4, "THU": 5, "FRI": 6, "SAT": 7},
|
||||
)
|
||||
CRON_KEYS = [
|
||||
CONF_SECONDS,
|
||||
CONF_MINUTES,
|
||||
CONF_HOURS,
|
||||
CONF_DAYS_OF_MONTH,
|
||||
CONF_MONTHS,
|
||||
CONF_DAYS_OF_WEEK,
|
||||
]
|
||||
CRON_FIELDS = (
|
||||
SECONDS_FIELD,
|
||||
MINUTES_FIELD,
|
||||
HOURS_FIELD,
|
||||
DAYS_OF_MONTH_FIELD,
|
||||
MONTHS_FIELD,
|
||||
DAYS_OF_WEEK_FIELD,
|
||||
)
|
||||
CRON_KEYS = [field.key for field in CRON_FIELDS]
|
||||
|
||||
validate_cron_seconds = SECONDS_FIELD.validator()
|
||||
validate_cron_minutes = MINUTES_FIELD.validator()
|
||||
validate_cron_hours = HOURS_FIELD.validator()
|
||||
validate_cron_days_of_month = DAYS_OF_MONTH_FIELD.validator()
|
||||
validate_cron_months = MONTHS_FIELD.validator()
|
||||
validate_cron_days_of_week = DAYS_OF_WEEK_FIELD.validator()
|
||||
|
||||
|
||||
def validate_cron_raw(value):
|
||||
@@ -291,9 +319,9 @@ def validate_cron_raw(value):
|
||||
def validate_time_at(value):
|
||||
value = cv.time_of_day(value)
|
||||
return {
|
||||
CONF_HOURS: [value[CONF_HOUR]],
|
||||
CONF_MINUTES: [value[CONF_MINUTE]],
|
||||
CONF_SECONDS: [value[CONF_SECOND]],
|
||||
CONF_HOURS: validate_cron_hours([value[CONF_HOUR]]),
|
||||
CONF_MINUTES: validate_cron_minutes([value[CONF_MINUTE]]),
|
||||
CONF_SECONDS: validate_cron_seconds([value[CONF_SECOND]]),
|
||||
CONF_DAYS_OF_MONTH: validate_cron_days_of_month("*"),
|
||||
CONF_MONTHS: validate_cron_months("*"),
|
||||
CONF_DAYS_OF_WEEK: validate_cron_days_of_week("*"),
|
||||
@@ -322,6 +350,33 @@ def validate_cron_keys(value):
|
||||
return cv.has_at_least_one_key(*CRON_KEYS)(value)
|
||||
|
||||
|
||||
def cron_field_check(field: CronField, values: list[int]) -> str | None:
|
||||
"""C++ check that valid ``x.<member>`` is one of the validated ``values``; None for the full range.
|
||||
|
||||
An empty list never matches, one value is compared, more use a mask as wide as the field's range.
|
||||
"""
|
||||
if values == list(range(field.low, field.high + 1)):
|
||||
return None
|
||||
if not values:
|
||||
return "false"
|
||||
member = f"x.{field.member}"
|
||||
if len(values) == 1:
|
||||
return f"{member} == {values[0]}"
|
||||
mask = sum(1 << value for value in values)
|
||||
suffix = "ULL" if field.high >= 32 else "U"
|
||||
return f"(({mask:#x}{suffix} >> {member}) & 1)"
|
||||
|
||||
|
||||
def on_time_check(conf: ConfigType) -> str | None:
|
||||
checks = []
|
||||
for field in CRON_FIELDS:
|
||||
if (values := conf.get(field.key)) is None:
|
||||
continue
|
||||
if (check := cron_field_check(field, values)) is not None:
|
||||
checks.append(check)
|
||||
return " && ".join(checks) or None
|
||||
|
||||
|
||||
def validate_tz(value: str) -> str:
|
||||
value = cv.string_strict(value)
|
||||
|
||||
@@ -359,7 +414,6 @@ TIME_SCHEMA = cv.Schema(
|
||||
),
|
||||
cv.Optional(CONF_ON_TIME): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CronTrigger),
|
||||
cv.Optional(CONF_SECONDS): validate_cron_seconds,
|
||||
cv.Optional(CONF_MINUTES): validate_cron_minutes,
|
||||
cv.Optional(CONF_HOURS): validate_cron_hours,
|
||||
@@ -438,23 +492,18 @@ async def setup_time_core_(time_var, config):
|
||||
cg.add_define("USE_TIME_TRIGGERS")
|
||||
|
||||
for conf in on_time:
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
|
||||
|
||||
seconds = conf.get(CONF_SECONDS, list(range(61)))
|
||||
cg.add(trigger.add_seconds(seconds))
|
||||
minutes = conf.get(CONF_MINUTES, list(range(60)))
|
||||
cg.add(trigger.add_minutes(minutes))
|
||||
hours = conf.get(CONF_HOURS, list(range(24)))
|
||||
cg.add(trigger.add_hours(hours))
|
||||
days_of_month = conf.get(CONF_DAYS_OF_MONTH, list(range(1, 32)))
|
||||
cg.add(trigger.add_days_of_month(days_of_month))
|
||||
months = conf.get(CONF_MONTHS, list(range(1, 13)))
|
||||
cg.add(trigger.add_months(months))
|
||||
days_of_week = conf.get(CONF_DAYS_OF_WEEK, list(range(1, 8)))
|
||||
cg.add(trigger.add_days_of_week(days_of_week))
|
||||
|
||||
await cg.register_component(trigger, conf)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
_request_on_time_slot(str(time_var))
|
||||
await automation.build_callback_automation(
|
||||
time_var,
|
||||
"add_on_time_callback",
|
||||
[],
|
||||
conf,
|
||||
params=[(cg.ESPTime, "x")],
|
||||
forward=[],
|
||||
when=on_time_check(conf),
|
||||
)
|
||||
if on_time:
|
||||
cg.add(time_var.start_on_time_checks())
|
||||
|
||||
for conf in config.get(CONF_ON_TIME_SYNC, []):
|
||||
await automation.build_callback_automation(
|
||||
@@ -492,11 +541,9 @@ automation.register_apply_condition(
|
||||
|
||||
|
||||
# posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a
|
||||
# timezone is configured or detected; automation.cpp holds the on_time
|
||||
# trigger and is #ifdef'd on USE_TIME_TRIGGERS.
|
||||
# timezone is configured or detected.
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{
|
||||
"posix_tz.cpp": "USE_TIME_TIMEZONE",
|
||||
"automation.cpp": "USE_TIME_TRIGGERS",
|
||||
}
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
#include "automation.h"
|
||||
#ifdef USE_TIME_TRIGGERS
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome::time {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "automation");
|
||||
static const int MAX_TIMESTAMP_DRIFT = 900; // how far can the clock drift before we consider
|
||||
// there has been a drastic time synchronization
|
||||
|
||||
void CronTrigger::add_second(uint8_t second) { this->seconds_[second] = true; }
|
||||
void CronTrigger::add_minute(uint8_t minute) { this->minutes_[minute] = true; }
|
||||
void CronTrigger::add_hour(uint8_t hour) { this->hours_[hour] = true; }
|
||||
void CronTrigger::add_day_of_month(uint8_t day_of_month) { this->days_of_month_[day_of_month] = true; }
|
||||
void CronTrigger::add_month(uint8_t month) { this->months_[month] = true; }
|
||||
void CronTrigger::add_day_of_week(uint8_t day_of_week) { this->days_of_week_[day_of_week] = true; }
|
||||
bool CronTrigger::matches(const ESPTime &time) {
|
||||
return time.is_valid() && this->seconds_[time.second] && this->minutes_[time.minute] && this->hours_[time.hour] &&
|
||||
this->days_of_month_[time.day_of_month] && this->months_[time.month] && this->days_of_week_[time.day_of_week];
|
||||
}
|
||||
void CronTrigger::setup() {
|
||||
// Cron resolution is 1 second — check once per second instead of every loop iteration
|
||||
this->set_interval(1000, [this]() { this->check_time_(); });
|
||||
}
|
||||
|
||||
void CronTrigger::check_time_() {
|
||||
ESPTime time = this->rtc_->now();
|
||||
if (!time.is_valid())
|
||||
return;
|
||||
|
||||
if (this->last_check_.has_value()) {
|
||||
auto &last_check = *this->last_check_;
|
||||
if (last_check > time && last_check.timestamp - time.timestamp > MAX_TIMESTAMP_DRIFT) {
|
||||
// We went back in time (a lot), probably caused by time synchronization
|
||||
ESP_LOGW(TAG, "Time has jumped back!");
|
||||
} else if (last_check >= time) {
|
||||
// already handled this one
|
||||
return;
|
||||
} else if (time > last_check && time.timestamp - last_check.timestamp > MAX_TIMESTAMP_DRIFT) {
|
||||
// We went ahead in time (a lot), probably caused by time synchronization
|
||||
ESP_LOGW(TAG, "Time has jumped ahead!");
|
||||
this->last_check_ = time;
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
last_check.increment_second();
|
||||
if (last_check >= time)
|
||||
break;
|
||||
|
||||
if (this->matches(last_check))
|
||||
this->trigger();
|
||||
}
|
||||
}
|
||||
|
||||
this->last_check_ = time;
|
||||
if (!time.fields_in_range()) {
|
||||
ESP_LOGW(TAG, "Time is out of range!");
|
||||
ESP_LOGD(TAG, "Second=%02u Minute=%02u Hour=%02u DayOfWeek=%u DayOfMonth=%u DayOfYear=%u Month=%u time=%" PRId64,
|
||||
time.second, time.minute, time.hour, time.day_of_week, time.day_of_month, time.day_of_year, time.month,
|
||||
(int64_t) time.timestamp);
|
||||
}
|
||||
|
||||
if (this->matches(time))
|
||||
this->trigger();
|
||||
}
|
||||
CronTrigger::CronTrigger(RealTimeClock *rtc) : rtc_(rtc) {}
|
||||
void CronTrigger::add_seconds(const std::vector<uint8_t> &seconds) {
|
||||
for (uint8_t it : seconds)
|
||||
this->add_second(it);
|
||||
}
|
||||
void CronTrigger::add_minutes(const std::vector<uint8_t> &minutes) {
|
||||
for (uint8_t it : minutes)
|
||||
this->add_minute(it);
|
||||
}
|
||||
void CronTrigger::add_hours(const std::vector<uint8_t> &hours) {
|
||||
for (uint8_t it : hours)
|
||||
this->add_hour(it);
|
||||
}
|
||||
void CronTrigger::add_days_of_month(const std::vector<uint8_t> &days_of_month) {
|
||||
for (uint8_t it : days_of_month)
|
||||
this->add_day_of_month(it);
|
||||
}
|
||||
void CronTrigger::add_months(const std::vector<uint8_t> &months) {
|
||||
for (uint8_t it : months)
|
||||
this->add_month(it);
|
||||
}
|
||||
void CronTrigger::add_days_of_week(const std::vector<uint8_t> &days_of_week) {
|
||||
for (uint8_t it : days_of_week)
|
||||
this->add_day_of_week(it);
|
||||
}
|
||||
float CronTrigger::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
} // namespace esphome::time
|
||||
|
||||
#endif // USE_TIME_TRIGGERS
|
||||
@@ -1,48 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_TIME_TRIGGERS
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/time.h"
|
||||
|
||||
#include "real_time_clock.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::time {
|
||||
|
||||
class CronTrigger final : public Trigger<>, public Component {
|
||||
public:
|
||||
explicit CronTrigger(RealTimeClock *rtc);
|
||||
void add_second(uint8_t second);
|
||||
void add_seconds(const std::vector<uint8_t> &seconds);
|
||||
void add_minute(uint8_t minute);
|
||||
void add_minutes(const std::vector<uint8_t> &minutes);
|
||||
void add_hour(uint8_t hour);
|
||||
void add_hours(const std::vector<uint8_t> &hours);
|
||||
void add_day_of_month(uint8_t day_of_month);
|
||||
void add_days_of_month(const std::vector<uint8_t> &days_of_month);
|
||||
void add_month(uint8_t month);
|
||||
void add_months(const std::vector<uint8_t> &months);
|
||||
void add_day_of_week(uint8_t day_of_week);
|
||||
void add_days_of_week(const std::vector<uint8_t> &days_of_week);
|
||||
bool matches(const ESPTime &time);
|
||||
void setup() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
protected:
|
||||
void check_time_();
|
||||
std::bitset<61> seconds_;
|
||||
std::bitset<60> minutes_;
|
||||
std::bitset<24> hours_;
|
||||
std::bitset<32> days_of_month_;
|
||||
std::bitset<13> months_;
|
||||
std::bitset<8> days_of_week_;
|
||||
RealTimeClock *rtc_;
|
||||
optional<ESPTime> last_check_;
|
||||
};
|
||||
} // namespace esphome::time
|
||||
|
||||
#endif // USE_TIME_TRIGGERS
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "real_time_clock.h"
|
||||
#include "esphome/core/log.h"
|
||||
#ifdef USE_TIME_TRIGGERS
|
||||
#include "esphome/core/application.h"
|
||||
#endif
|
||||
#ifdef USE_HOST
|
||||
#include <sys/time.h>
|
||||
#elif defined(USE_ZEPHYR)
|
||||
@@ -23,19 +26,7 @@ ESPHOME_LOG_TAG(TAG, "time");
|
||||
|
||||
RealTimeClock::RealTimeClock() = default;
|
||||
|
||||
ESPTime __attribute__((noinline)) RealTimeClock::now() {
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
time_t epoch = this->timestamp_now();
|
||||
struct tm local_tm;
|
||||
if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) {
|
||||
return ESPTime::from_c_tm(&local_tm, epoch);
|
||||
}
|
||||
// Fallback to UTC if parsing failed
|
||||
return ESPTime::from_epoch_utc(epoch);
|
||||
#else
|
||||
return ESPTime::from_epoch_local(this->timestamp_now());
|
||||
#endif
|
||||
}
|
||||
ESPTime __attribute__((noinline)) RealTimeClock::now() { return ESPTime::from_epoch_local(this->timestamp_now()); }
|
||||
|
||||
void RealTimeClock::dump_config() {
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
@@ -108,4 +99,41 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
|
||||
this->time_sync_callback_.call();
|
||||
}
|
||||
|
||||
#ifdef USE_TIME_TRIGGERS
|
||||
// How far the clock may move between checks before it counts as a time sync instead of skipped seconds
|
||||
static constexpr uint32_t MAX_TIMESTAMP_DRIFT = 900;
|
||||
|
||||
void RealTimeClock::start_on_time_checks() {
|
||||
App.scheduler.set_interval(this, 1000, [this]() { this->check_time_(); });
|
||||
}
|
||||
|
||||
void RealTimeClock::check_time_() {
|
||||
ESPTime time = this->now();
|
||||
if (!time.is_valid())
|
||||
return;
|
||||
|
||||
const uint32_t epoch = static_cast<uint32_t>(time.timestamp);
|
||||
const uint32_t last = this->last_check_;
|
||||
if (last != 0) {
|
||||
if (last > epoch && last - epoch > MAX_TIMESTAMP_DRIFT) {
|
||||
ESP_LOGW(TAG, "Time has jumped back!");
|
||||
} else if (last >= epoch) {
|
||||
return;
|
||||
} else if (epoch - last > MAX_TIMESTAMP_DRIFT) {
|
||||
ESP_LOGW(TAG, "Time has jumped ahead!");
|
||||
this->last_check_ = epoch;
|
||||
return;
|
||||
} else if (epoch - last > 1) {
|
||||
for (ESPTime skipped = ESPTime::from_epoch_local(last + 1); skipped < time; skipped.increment_second()) {
|
||||
if (skipped.is_valid())
|
||||
this->time_callback_.call(skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this->last_check_ = epoch;
|
||||
this->time_callback_.call(time);
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::time
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <bitset>
|
||||
#include <cstdlib>
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -35,6 +34,14 @@ class RealTimeClock : public PollingComponent {
|
||||
this->time_sync_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
#ifdef USE_TIME_TRIGGERS
|
||||
/// Called once per second with the local time, and once for each second skipped since the last call
|
||||
/// (up to 15 minutes; a larger jump is treated as a time sync). Only called with a valid time.
|
||||
template<typename F> void add_on_time_callback(F &&callback) { this->time_callback_.add(std::forward<F>(callback)); }
|
||||
/// Start the once-a-second check; it keeps running even if this platform fails setup.
|
||||
void start_on_time_checks();
|
||||
#endif
|
||||
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
@@ -42,6 +49,12 @@ class RealTimeClock : public PollingComponent {
|
||||
void synchronize_epoch_(uint32_t epoch);
|
||||
|
||||
LazyCallbackManager<void()> time_sync_callback_;
|
||||
#ifdef USE_TIME_TRIGGERS
|
||||
void check_time_();
|
||||
|
||||
StaticCallbackManager<TIME_ON_TIME_COUNT, void(const ESPTime &)> time_callback_;
|
||||
uint32_t last_check_{0}; // epoch, 0 until the first valid time
|
||||
#endif
|
||||
};
|
||||
|
||||
/// How the weekday byte of a BCD calendar names the day, Sunday first.
|
||||
|
||||
@@ -369,6 +369,7 @@
|
||||
#define USE_TEXT_SENSOR_FILTER_RESET
|
||||
#define USE_TIME
|
||||
#define USE_TIME_TRIGGERS
|
||||
#define TIME_ON_TIME_COUNT 2
|
||||
#define USE_TM1638_KEYS_PUBLISHER
|
||||
#define USE_TMP102_BINARY_SENSOR
|
||||
#define USE_TMP102_CONFIGURE
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,34 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
time:
|
||||
- platform: sntp
|
||||
id: sntp_time
|
||||
timezone: UTC0
|
||||
on_time:
|
||||
- seconds: 0
|
||||
then:
|
||||
- logger.log: sntp_first
|
||||
- seconds: 30
|
||||
then:
|
||||
- logger.log: sntp_second
|
||||
- platform: homeassistant
|
||||
id: ha_time
|
||||
timezone: UTC0
|
||||
on_time:
|
||||
- seconds: 15
|
||||
then:
|
||||
- logger.log: ha_only
|
||||
@@ -6,17 +6,32 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.time import CRON_KEYS, TIME_SCHEMA
|
||||
from esphome.components.time import (
|
||||
CRON_KEYS,
|
||||
DAYS_OF_MONTH_FIELD,
|
||||
DAYS_OF_WEEK_FIELD,
|
||||
HOURS_FIELD,
|
||||
MINUTES_FIELD,
|
||||
MONTHS_FIELD,
|
||||
SECONDS_FIELD,
|
||||
TIME_SCHEMA,
|
||||
CronField,
|
||||
cron_field_check,
|
||||
on_time_check,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AT,
|
||||
CONF_CRON,
|
||||
CONF_DAYS_OF_MONTH,
|
||||
CONF_DAYS_OF_WEEK,
|
||||
CONF_HOUR,
|
||||
CONF_HOURS,
|
||||
CONF_MINUTE,
|
||||
CONF_MINUTES,
|
||||
CONF_MONTHS,
|
||||
CONF_ON_TIME,
|
||||
CONF_SECOND,
|
||||
CONF_SECONDS,
|
||||
CONF_THEN,
|
||||
CONF_TIMEZONE,
|
||||
@@ -138,63 +153,48 @@ def test_on_time_schedule_rejected(
|
||||
TIME_SCHEMA({CONF_TIMEZONE: "UTC0", CONF_ON_TIME: [{**entry, CONF_THEN: []}]})
|
||||
|
||||
|
||||
ALL_MINUTES = ", ".join(map(str, range(60)))
|
||||
ALL_HOURS = ", ".join(map(str, range(24)))
|
||||
ALL_DOM = ", ".join(map(str, range(1, 32)))
|
||||
ALL_MONTHS_CPP = ", ".join(map(str, range(1, 13)))
|
||||
|
||||
|
||||
def _cron_lines(
|
||||
var: str,
|
||||
seconds: str = "0",
|
||||
minutes: str = ALL_MINUTES,
|
||||
hours: str = ALL_HOURS,
|
||||
days_of_month: str = ALL_DOM,
|
||||
months: str = ALL_MONTHS_CPP,
|
||||
days_of_week: str = "1, 2, 3, 4, 5, 6, 7",
|
||||
) -> str:
|
||||
def _on_time_lines(action: str, check: str) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
f"new({var}) time::CronTrigger(sntp_time);",
|
||||
f"{var}->add_seconds({{{seconds}}});",
|
||||
f"{var}->add_minutes({{{minutes}}});",
|
||||
f"{var}->add_hours({{{hours}}});",
|
||||
f"{var}->add_days_of_month({{{days_of_month}}});",
|
||||
f"{var}->add_months({{{months}}});",
|
||||
f"{var}->add_days_of_week({{{days_of_week}}});",
|
||||
"sntp_time->add_on_time_callback([](const std::remove_cvref_t<ESPTime> & x) -> void {",
|
||||
f" if (!({check}))",
|
||||
" return;",
|
||||
f" esphome__{action}__fn();",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# One entry per on_time automation in config/on_time_fields.yaml, in order.
|
||||
EXPECTED_ON_TIME_CODE = [
|
||||
_cron_lines(
|
||||
"time_crontrigger_id", minutes="0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55"
|
||||
_on_time_lines(
|
||||
"lambdaaction_id", "x.second == 0 && ((0x84210842108421ULL >> x.minute) & 1)"
|
||||
),
|
||||
_cron_lines(
|
||||
"time_crontrigger_id_2", minutes="30", hours="7", days_of_week="2, 3, 4, 5, 6"
|
||||
_on_time_lines(
|
||||
"lambdaaction_id_2",
|
||||
"x.second == 0 && x.minute == 30 && x.hour == 7"
|
||||
" && ((0x7cU >> x.day_of_week) & 1)",
|
||||
),
|
||||
_cron_lines("time_crontrigger_id_3", minutes="30", hours="7"),
|
||||
_cron_lines(
|
||||
"time_crontrigger_id_4",
|
||||
minutes="0",
|
||||
hours="8, 9, 10, 11, 12, 13, 14, 15, 16, 17",
|
||||
_on_time_lines(
|
||||
"lambdaaction_id_3", "x.second == 0 && x.minute == 30 && x.hour == 7"
|
||||
),
|
||||
_cron_lines(
|
||||
"time_crontrigger_id_5",
|
||||
minutes="0",
|
||||
hours="12",
|
||||
days_of_month="1, 15",
|
||||
months="1, 7",
|
||||
_on_time_lines(
|
||||
"lambdaaction_id_4",
|
||||
"x.second == 0 && x.minute == 0 && ((0x3ff00U >> x.hour) & 1)",
|
||||
),
|
||||
_cron_lines(
|
||||
"time_crontrigger_id_6",
|
||||
minutes="0",
|
||||
hours="0",
|
||||
days_of_month="1, 8, 15, 22, 29",
|
||||
_on_time_lines(
|
||||
"lambdaaction_id_5",
|
||||
"x.second == 0 && x.minute == 0 && x.hour == 12"
|
||||
" && ((0x8002U >> x.day_of_month) & 1) && ((0x82U >> x.month) & 1)",
|
||||
),
|
||||
_on_time_lines(
|
||||
"lambdaaction_id_6",
|
||||
"x.second == 0 && x.minute == 0 && x.hour == 0"
|
||||
" && ((0x20408102U >> x.day_of_month) & 1)",
|
||||
),
|
||||
_on_time_lines("lambdaaction_id_7", "((0x1000200040008001ULL >> x.second) & 1)"),
|
||||
_on_time_lines(
|
||||
"lambdaaction_id_8", "x.second == 0 && ((0x82U >> x.day_of_week) & 1)"
|
||||
),
|
||||
_cron_lines("time_crontrigger_id_7", seconds="0, 15, 30, 45, 60"),
|
||||
_cron_lines("time_crontrigger_id_8", days_of_week="1, 7"),
|
||||
]
|
||||
|
||||
|
||||
@@ -209,3 +209,87 @@ def test_on_time_generated_code(
|
||||
found = main_cpp.find(expected, position)
|
||||
assert found != -1, f"missing or out of order:\n{expected}"
|
||||
position = found + len(expected)
|
||||
assert main_cpp.count("sntp_time->start_on_time_checks();") == 1
|
||||
assert main_cpp.find("sntp_time->start_on_time_checks();", position) != -1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "values", "check"),
|
||||
[
|
||||
(SECONDS_FIELD, list(range(61)), None),
|
||||
(DAYS_OF_WEEK_FIELD, list(range(1, 8)), None),
|
||||
(SECONDS_FIELD, [], "false"),
|
||||
(MINUTES_FIELD, [30], "x.minute == 30"),
|
||||
(DAYS_OF_MONTH_FIELD, [31], "x.day_of_month == 31"),
|
||||
# a 0-60 field takes a 64-bit mask even when every listed value is below 32
|
||||
(SECONDS_FIELD, [0, 1], "((0x3ULL >> x.second) & 1)"),
|
||||
(MINUTES_FIELD, [0, 59], "((0x800000000000001ULL >> x.minute) & 1)"),
|
||||
(HOURS_FIELD, [8, 17], "((0x20100U >> x.hour) & 1)"),
|
||||
(DAYS_OF_MONTH_FIELD, [1, 31], "((0x80000002U >> x.day_of_month) & 1)"),
|
||||
(MONTHS_FIELD, [1, 7], "((0x82U >> x.month) & 1)"),
|
||||
(DAYS_OF_WEEK_FIELD, [2, 3, 4, 5, 6], "((0x7cU >> x.day_of_week) & 1)"),
|
||||
],
|
||||
)
|
||||
def test_cron_field_check(
|
||||
field: CronField, values: list[int], check: str | None
|
||||
) -> None:
|
||||
assert cron_field_check(field, values) == check
|
||||
|
||||
|
||||
def test_empty_field_validates_and_generates(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
config = TIME_SCHEMA(
|
||||
{CONF_TIMEZONE: "UTC0", CONF_ON_TIME: [{CONF_SECONDS: [], CONF_THEN: []}]}
|
||||
)
|
||||
assert on_time_check(config[CONF_ON_TIME][0]) == "false"
|
||||
|
||||
|
||||
def test_list_values_are_deduplicated_ints(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""``true``/``false`` in a list have always meant 1/0; the check must be valid C++."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
config = TIME_SCHEMA(
|
||||
{
|
||||
CONF_TIMEZONE: "UTC0",
|
||||
CONF_ON_TIME: [{CONF_SECONDS: [True, False, True], CONF_THEN: []}],
|
||||
}
|
||||
)
|
||||
seconds = config[CONF_ON_TIME][0][CONF_SECONDS]
|
||||
assert seconds == [0, 1]
|
||||
assert all(type(value) is int for value in seconds)
|
||||
|
||||
|
||||
def test_at_dict_values_are_ints(set_core_config: SetCoreConfigCallable) -> None:
|
||||
"""The dict form of ``at`` takes the same path, so ``hour: true`` is checked as 1."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
config = TIME_SCHEMA(
|
||||
{
|
||||
CONF_TIMEZONE: "UTC0",
|
||||
CONF_ON_TIME: [
|
||||
{
|
||||
CONF_AT: {CONF_HOUR: True, CONF_MINUTE: 0, CONF_SECOND: 0},
|
||||
CONF_THEN: [],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert on_time_check(config[CONF_ON_TIME][0]) == (
|
||||
"x.second == 0 && x.minute == 0 && x.hour == 1"
|
||||
)
|
||||
|
||||
|
||||
def test_every_second_schedule_has_no_check(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A schedule covering every field's full range fires every second, with no check."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
config = TIME_SCHEMA(
|
||||
{
|
||||
CONF_TIMEZONE: "UTC0",
|
||||
CONF_ON_TIME: [{CONF_CRON: "* * * * * *", CONF_THEN: []}],
|
||||
}
|
||||
)
|
||||
assert on_time_check(config[CONF_ON_TIME][0]) is None
|
||||
@@ -1,5 +1,5 @@
|
||||
"""automation.cpp (CronTrigger) is only compiled when an on_time automation
|
||||
exists, so the define must follow it; on_time_sync needs no class."""
|
||||
"""The clock's once-a-second on_time check is only compiled when an on_time automation
|
||||
exists, so the define must follow it; on_time_sync needs no check."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
@@ -8,21 +8,29 @@ import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
from ..helpers import get_define_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture", "emits"),
|
||||
("fixture", "emits", "slots"),
|
||||
[
|
||||
("no_triggers.yaml", False),
|
||||
("on_time.yaml", True),
|
||||
("on_time_sync.yaml", False),
|
||||
("no_triggers.yaml", False, None),
|
||||
("on_time.yaml", True, "1"),
|
||||
("on_time_sync.yaml", False, None),
|
||||
("on_time_fields.yaml", True, "8"),
|
||||
# the callback list is per clock: its size is the largest count on one clock, not the total
|
||||
("on_time_two_clocks.yaml", True, "2"),
|
||||
],
|
||||
)
|
||||
def test_triggers_define_follows_automations(
|
||||
fixture: str,
|
||||
emits: bool,
|
||||
slots: str | None,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path(fixture))
|
||||
main_cpp = generate_main(component_config_path(fixture))
|
||||
defines = {define.name for define in CORE.defines}
|
||||
assert ("USE_TIME_TRIGGERS" in defines) is emits
|
||||
assert ("start_on_time_checks()" in main_cpp) is emits
|
||||
assert get_define_value("TIME_ON_TIME_COUNT") == slots
|
||||
@@ -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
|
||||
@@ -6,5 +6,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code(config):
|
||||
cg.add_define("USE_TIME_TIMEZONE")
|
||||
cg.add_define("USE_TIME_TRIGGERS")
|
||||
cg.add_define("TIME_ON_TIME_COUNT", 1)
|
||||
|
||||
manifest.to_code = to_code
|
||||
@@ -3,19 +3,7 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <bitset>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/time.h"
|
||||
|
||||
// CronTrigger is final; drop that here so the test can reach check_time_() without the scheduler. Everything else
|
||||
// is included above, so the macro only reaches this one header.
|
||||
#define final
|
||||
#include "esphome/components/time/automation.h"
|
||||
#undef final
|
||||
|
||||
namespace esphome::time::testing {
|
||||
|
||||
@@ -24,81 +12,48 @@ static constexpr time_t MONDAY_235958 = 1767657598;
|
||||
|
||||
class FakeClock : public RealTimeClock {
|
||||
public:
|
||||
using RealTimeClock::check_time_;
|
||||
time_t timestamp_now() override { return this->epoch; }
|
||||
void update() override {}
|
||||
time_t epoch{0};
|
||||
};
|
||||
|
||||
class CountAction : public Action<> {
|
||||
public:
|
||||
explicit CountAction(int *count) : count_(count) {}
|
||||
|
||||
protected:
|
||||
void play() override { (*this->count_)++; }
|
||||
int *count_;
|
||||
};
|
||||
|
||||
class TestCronTrigger : public CronTrigger {
|
||||
public:
|
||||
using CronTrigger::check_time_;
|
||||
using CronTrigger::CronTrigger;
|
||||
};
|
||||
|
||||
class OnTimeTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// Other tests leave their zone set; these times are UTC.
|
||||
this->saved_tz_ = get_global_tz();
|
||||
set_global_tz(ParsedTimezone{});
|
||||
this->automation_.add_action(&this->count_);
|
||||
}
|
||||
void TearDown() override { set_global_tz(this->saved_tz_); }
|
||||
|
||||
void match_all_fields_except_seconds_() {
|
||||
for (uint8_t i = 0; i < 60; i++)
|
||||
this->trigger_.add_minute(i);
|
||||
for (uint8_t i = 0; i < 24; i++)
|
||||
this->trigger_.add_hour(i);
|
||||
for (uint8_t i = 1; i <= 31; i++)
|
||||
this->trigger_.add_day_of_month(i);
|
||||
for (uint8_t i = 1; i <= 12; i++)
|
||||
this->trigger_.add_month(i);
|
||||
for (uint8_t i = 1; i <= 7; i++)
|
||||
this->trigger_.add_day_of_week(i);
|
||||
}
|
||||
// Each stands in for the check code generation writes for an on_time automation.
|
||||
void match_every_second_() {
|
||||
this->match_all_fields_except_seconds_();
|
||||
for (uint8_t i = 0; i <= 60; i++)
|
||||
this->trigger_.add_second(i);
|
||||
this->clock_.add_on_time_callback([this](const ESPTime &) { this->fires_++; });
|
||||
}
|
||||
void match_second_(uint8_t second) {
|
||||
this->match_all_fields_except_seconds_();
|
||||
this->trigger_.add_second(second);
|
||||
this->clock_.add_on_time_callback([this, second](const ESPTime &time) {
|
||||
if (time.second == second)
|
||||
this->fires_++;
|
||||
});
|
||||
}
|
||||
void match_midnight_on_(uint8_t day_of_week) {
|
||||
this->trigger_.add_second(0);
|
||||
this->trigger_.add_minute(0);
|
||||
this->trigger_.add_hour(0);
|
||||
for (uint8_t i = 1; i <= 31; i++)
|
||||
this->trigger_.add_day_of_month(i);
|
||||
for (uint8_t i = 1; i <= 12; i++)
|
||||
this->trigger_.add_month(i);
|
||||
this->trigger_.add_day_of_week(day_of_week);
|
||||
this->clock_.add_on_time_callback([this, day_of_week](const ESPTime &time) {
|
||||
if (time.second == 0 && time.minute == 0 && time.hour == 0 && time.day_of_week == day_of_week)
|
||||
this->fires_++;
|
||||
});
|
||||
}
|
||||
// One pass of the 1 s check with the clock at `epoch`; returns how many times on_time fired.
|
||||
int tick_(time_t epoch) {
|
||||
this->clock_.epoch = epoch;
|
||||
this->fires_ = 0;
|
||||
this->trigger_.check_time_();
|
||||
this->clock_.check_time_();
|
||||
return this->fires_;
|
||||
}
|
||||
|
||||
ParsedTimezone saved_tz_{};
|
||||
FakeClock clock_;
|
||||
TestCronTrigger trigger_{&this->clock_};
|
||||
Automation<> automation_{&this->trigger_};
|
||||
int fires_{0};
|
||||
CountAction count_{&this->fires_};
|
||||
};
|
||||
|
||||
TEST_F(OnTimeTest, FiresOnlyOnMatchingSecond) {
|
||||
|
||||
Reference in new issue
Block a user