mirror of
https://github.com/esphome/esphome.git
synced 2026-03-24 06:53:07 +08:00
[scheduler] Make core timer ID collisions impossible with type-safe internal IDs (#13882)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,14 @@ namespace esphome::binary_sensor {
|
||||
|
||||
static const char *const TAG = "binary_sensor.automation";
|
||||
|
||||
// MultiClickTrigger timeout IDs.
|
||||
// MultiClickTrigger is its own Component instance, so the scheduler scopes
|
||||
// IDs by component pointer — no risk of collisions between instances.
|
||||
constexpr uint32_t MULTICLICK_TRIGGER_ID = 0;
|
||||
constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1;
|
||||
constexpr uint32_t MULTICLICK_IS_VALID_ID = 2;
|
||||
constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3;
|
||||
|
||||
void MultiClickTrigger::on_state_(bool state) {
|
||||
// Handle duplicate events
|
||||
if (state == this->last_state_) {
|
||||
@@ -27,7 +35,7 @@ void MultiClickTrigger::on_state_(bool state) {
|
||||
evt.min_length, evt.max_length);
|
||||
this->at_index_ = 1;
|
||||
if (this->timing_.size() == 1 && evt.max_length == 4294967294UL) {
|
||||
this->set_timeout("trigger", evt.min_length, [this]() { this->trigger_(); });
|
||||
this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); });
|
||||
} else {
|
||||
this->schedule_is_valid_(evt.min_length);
|
||||
this->schedule_is_not_valid_(evt.max_length);
|
||||
@@ -57,13 +65,13 @@ void MultiClickTrigger::on_state_(bool state) {
|
||||
this->schedule_is_not_valid_(evt.max_length);
|
||||
} else if (*this->at_index_ + 1 != this->timing_.size()) {
|
||||
ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT
|
||||
this->cancel_timeout("is_not_valid");
|
||||
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
|
||||
this->schedule_is_valid_(evt.min_length);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT
|
||||
this->is_valid_ = false;
|
||||
this->cancel_timeout("is_not_valid");
|
||||
this->set_timeout("trigger", evt.min_length, [this]() { this->trigger_(); });
|
||||
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
|
||||
this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); });
|
||||
}
|
||||
|
||||
*this->at_index_ = *this->at_index_ + 1;
|
||||
@@ -71,14 +79,14 @@ void MultiClickTrigger::on_state_(bool state) {
|
||||
void MultiClickTrigger::schedule_cooldown_() {
|
||||
ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_);
|
||||
this->is_in_cooldown_ = true;
|
||||
this->set_timeout("cooldown", this->invalid_cooldown_, [this]() {
|
||||
this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() {
|
||||
ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again.");
|
||||
this->is_in_cooldown_ = false;
|
||||
});
|
||||
this->at_index_.reset();
|
||||
this->cancel_timeout("trigger");
|
||||
this->cancel_timeout("is_valid");
|
||||
this->cancel_timeout("is_not_valid");
|
||||
this->cancel_timeout(MULTICLICK_TRIGGER_ID);
|
||||
this->cancel_timeout(MULTICLICK_IS_VALID_ID);
|
||||
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
|
||||
}
|
||||
void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) {
|
||||
if (min_length == 0) {
|
||||
@@ -86,13 +94,13 @@ void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) {
|
||||
return;
|
||||
}
|
||||
this->is_valid_ = false;
|
||||
this->set_timeout("is_valid", min_length, [this]() {
|
||||
this->set_timeout(MULTICLICK_IS_VALID_ID, min_length, [this]() {
|
||||
ESP_LOGV(TAG, "Multi Click: You can now %s the button.", this->parent_->state ? "RELEASE" : "PRESS");
|
||||
this->is_valid_ = true;
|
||||
});
|
||||
}
|
||||
void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) {
|
||||
this->set_timeout("is_not_valid", max_length, [this]() {
|
||||
this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() {
|
||||
ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS");
|
||||
this->is_valid_ = false;
|
||||
this->schedule_cooldown_();
|
||||
@@ -106,9 +114,9 @@ void MultiClickTrigger::cancel() {
|
||||
void MultiClickTrigger::trigger_() {
|
||||
ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!");
|
||||
this->at_index_.reset();
|
||||
this->cancel_timeout("trigger");
|
||||
this->cancel_timeout("is_valid");
|
||||
this->cancel_timeout("is_not_valid");
|
||||
this->cancel_timeout(MULTICLICK_TRIGGER_ID);
|
||||
this->cancel_timeout(MULTICLICK_IS_VALID_ID);
|
||||
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
|
||||
this->trigger();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@ namespace esphome::binary_sensor {
|
||||
|
||||
static const char *const TAG = "sensor.filter";
|
||||
|
||||
// Timeout IDs for filter classes.
|
||||
// Each filter is its own Component instance, so the scheduler scopes
|
||||
// IDs by component pointer — no risk of collisions between instances.
|
||||
constexpr uint32_t FILTER_TIMEOUT_ID = 0;
|
||||
// AutorepeatFilter needs two distinct IDs (both timeouts on the same component)
|
||||
constexpr uint32_t AUTOREPEAT_TIMING_ID = 0;
|
||||
constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1;
|
||||
|
||||
void Filter::output(bool value) {
|
||||
if (this->next_ == nullptr) {
|
||||
this->parent_->send_state_internal(value);
|
||||
@@ -23,16 +31,16 @@ void Filter::input(bool value) {
|
||||
}
|
||||
|
||||
void TimeoutFilter::input(bool value) {
|
||||
this->set_timeout("timeout", this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
|
||||
// we do not de-dup here otherwise changes from invalid to valid state will not be output
|
||||
this->output(value);
|
||||
}
|
||||
|
||||
optional<bool> DelayedOnOffFilter::new_value(bool value) {
|
||||
if (value) {
|
||||
this->set_timeout("ON_OFF", this->on_delay_.value(), [this]() { this->output(true); });
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); });
|
||||
} else {
|
||||
this->set_timeout("ON_OFF", this->off_delay_.value(), [this]() { this->output(false); });
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); });
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -41,10 +49,10 @@ float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HA
|
||||
|
||||
optional<bool> DelayedOnFilter::new_value(bool value) {
|
||||
if (value) {
|
||||
this->set_timeout("ON", this->delay_.value(), [this]() { this->output(true); });
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); });
|
||||
return {};
|
||||
} else {
|
||||
this->cancel_timeout("ON");
|
||||
this->cancel_timeout(FILTER_TIMEOUT_ID);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -53,10 +61,10 @@ float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDW
|
||||
|
||||
optional<bool> DelayedOffFilter::new_value(bool value) {
|
||||
if (!value) {
|
||||
this->set_timeout("OFF", this->delay_.value(), [this]() { this->output(false); });
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); });
|
||||
return {};
|
||||
} else {
|
||||
this->cancel_timeout("OFF");
|
||||
this->cancel_timeout(FILTER_TIMEOUT_ID);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -76,8 +84,8 @@ optional<bool> AutorepeatFilter::new_value(bool value) {
|
||||
this->next_timing_();
|
||||
return true;
|
||||
} else {
|
||||
this->cancel_timeout("TIMING");
|
||||
this->cancel_timeout("ON_OFF");
|
||||
this->cancel_timeout(AUTOREPEAT_TIMING_ID);
|
||||
this->cancel_timeout(AUTOREPEAT_ON_OFF_ID);
|
||||
this->active_timing_ = 0;
|
||||
return false;
|
||||
}
|
||||
@@ -88,8 +96,10 @@ void AutorepeatFilter::next_timing_() {
|
||||
// 1st time: starts waiting the first delay
|
||||
// 2nd time: starts waiting the second delay and starts toggling with the first time_off / _on
|
||||
// last time: no delay to start but have to bump the index to reflect the last
|
||||
if (this->active_timing_ < this->timings_.size())
|
||||
this->set_timeout("TIMING", this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); });
|
||||
if (this->active_timing_ < this->timings_.size()) {
|
||||
this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay,
|
||||
[this]() { this->next_timing_(); });
|
||||
}
|
||||
|
||||
if (this->active_timing_ <= this->timings_.size()) {
|
||||
this->active_timing_++;
|
||||
@@ -104,7 +114,8 @@ void AutorepeatFilter::next_timing_() {
|
||||
void AutorepeatFilter::next_value_(bool val) {
|
||||
const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2];
|
||||
this->output(val); // This is at least the second one so not initial
|
||||
this->set_timeout("ON_OFF", val ? timing.time_on : timing.time_off, [this, val]() { this->next_value_(!val); });
|
||||
this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off,
|
||||
[this, val]() { this->next_value_(!val); });
|
||||
}
|
||||
|
||||
float AutorepeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
@@ -115,7 +126,7 @@ optional<bool> LambdaFilter::new_value(bool value) { return this->f_(value); }
|
||||
|
||||
optional<bool> SettleFilter::new_value(bool value) {
|
||||
if (!this->steady_) {
|
||||
this->set_timeout("SETTLE", this->delay_.value(), [this, value]() {
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() {
|
||||
this->steady_ = true;
|
||||
this->output(value);
|
||||
});
|
||||
@@ -123,7 +134,7 @@ optional<bool> SettleFilter::new_value(bool value) {
|
||||
} else {
|
||||
this->steady_ = false;
|
||||
this->output(value);
|
||||
this->set_timeout("SETTLE", this->delay_.value(), [this]() { this->steady_ = true; });
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ namespace esphome::sensor {
|
||||
|
||||
static const char *const TAG = "sensor.filter";
|
||||
|
||||
// Filter scheduler IDs.
|
||||
// Each filter is its own Component instance, so the scheduler scopes
|
||||
// IDs by component pointer — no risk of collisions between instances.
|
||||
constexpr uint32_t FILTER_ID = 0;
|
||||
|
||||
// Filter
|
||||
void Filter::input(float value) {
|
||||
ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value);
|
||||
@@ -191,7 +196,7 @@ optional<float> ThrottleAverageFilter::new_value(float value) {
|
||||
return {};
|
||||
}
|
||||
void ThrottleAverageFilter::setup() {
|
||||
this->set_interval("throttle_average", this->time_period_, [this]() {
|
||||
this->set_interval(FILTER_ID, this->time_period_, [this]() {
|
||||
ESP_LOGVV(TAG, "ThrottleAverageFilter(%p)::interval(sum=%f, n=%i)", this, this->sum_, this->n_);
|
||||
if (this->n_ == 0) {
|
||||
if (this->have_nan_)
|
||||
@@ -383,7 +388,7 @@ optional<float> TimeoutFilterConfigured::new_value(float value) {
|
||||
|
||||
// DebounceFilter
|
||||
optional<float> DebounceFilter::new_value(float value) {
|
||||
this->set_timeout("debounce", this->time_period_, [this, value]() { this->output(value); });
|
||||
this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); });
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -406,7 +411,7 @@ optional<float> HeartbeatFilter::new_value(float value) {
|
||||
}
|
||||
|
||||
void HeartbeatFilter::setup() {
|
||||
this->set_interval("heartbeat", this->time_period_, [this]() {
|
||||
this->set_interval(FILTER_ID, this->time_period_, [this]() {
|
||||
ESP_LOGVV(TAG, "HeartbeatFilter(%p)::interval(has_value=%s, last_input=%f)", this, YESNO(this->has_value_),
|
||||
this->last_input_);
|
||||
if (!this->has_value_)
|
||||
|
||||
@@ -191,15 +191,17 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
|
||||
// instead of std::bind to avoid bind overhead (~16 bytes heap + faster execution)
|
||||
if constexpr (sizeof...(Ts) == 0) {
|
||||
App.scheduler.set_timer_common_(
|
||||
this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::STATIC_STRING, "delay", 0, this->delay_.value(),
|
||||
this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr,
|
||||
static_cast<uint32_t>(InternalSchedulerID::DELAY_ACTION), this->delay_.value(),
|
||||
[this]() { this->play_next_(); },
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
|
||||
} else {
|
||||
// For delays with arguments, use std::bind to preserve argument values
|
||||
// Arguments must be copied because original references may be invalid after delay
|
||||
auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...);
|
||||
App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::STATIC_STRING,
|
||||
"delay", 0, this->delay_.value(x...), std::move(f),
|
||||
App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL,
|
||||
nullptr, static_cast<uint32_t>(InternalSchedulerID::DELAY_ACTION),
|
||||
this->delay_.value(x...), std::move(f),
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
|
||||
}
|
||||
}
|
||||
@@ -208,7 +210,7 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
|
||||
void play(const Ts &...x) override { /* ignore - see play_complex */
|
||||
}
|
||||
|
||||
void stop() override { this->cancel_timeout("delay"); }
|
||||
void stop() override { this->cancel_timeout(InternalSchedulerID::DELAY_ACTION); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class LambdaAction : public Action<Ts...> {
|
||||
|
||||
@@ -201,12 +201,24 @@ void Component::set_timeout(uint32_t id, uint32_t timeout, std::function<void()>
|
||||
|
||||
bool Component::cancel_timeout(uint32_t id) { return App.scheduler.cancel_timeout(this, id); }
|
||||
|
||||
void Component::set_timeout(InternalSchedulerID id, uint32_t timeout, std::function<void()> &&f) { // NOLINT
|
||||
App.scheduler.set_timeout(this, id, timeout, std::move(f));
|
||||
}
|
||||
|
||||
bool Component::cancel_timeout(InternalSchedulerID id) { return App.scheduler.cancel_timeout(this, id); }
|
||||
|
||||
void Component::set_interval(uint32_t id, uint32_t interval, std::function<void()> &&f) { // NOLINT
|
||||
App.scheduler.set_interval(this, id, interval, std::move(f));
|
||||
}
|
||||
|
||||
bool Component::cancel_interval(uint32_t id) { return App.scheduler.cancel_interval(this, id); }
|
||||
|
||||
void Component::set_interval(InternalSchedulerID id, uint32_t interval, std::function<void()> &&f) { // NOLINT
|
||||
App.scheduler.set_interval(this, id, interval, std::move(f));
|
||||
}
|
||||
|
||||
bool Component::cancel_interval(InternalSchedulerID id) { return App.scheduler.cancel_interval(this, id); }
|
||||
|
||||
void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts,
|
||||
std::function<RetryResult(uint8_t)> &&f, float backoff_increase_factor) { // NOLINT
|
||||
#pragma GCC diagnostic push
|
||||
@@ -533,12 +545,12 @@ void PollingComponent::call_setup() {
|
||||
|
||||
void PollingComponent::start_poller() {
|
||||
// Register interval.
|
||||
this->set_interval("update", this->get_update_interval(), [this]() { this->update(); });
|
||||
this->set_interval(InternalSchedulerID::POLLING_UPDATE, this->get_update_interval(), [this]() { this->update(); });
|
||||
}
|
||||
|
||||
void PollingComponent::stop_poller() {
|
||||
// Clear the interval to suspend component
|
||||
this->cancel_interval("update");
|
||||
this->cancel_interval(InternalSchedulerID::POLLING_UPDATE);
|
||||
}
|
||||
|
||||
uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; }
|
||||
|
||||
@@ -49,6 +49,14 @@ extern const float LATE;
|
||||
|
||||
static const uint32_t SCHEDULER_DONT_RUN = 4294967295UL;
|
||||
|
||||
/// Type-safe scheduler IDs for core base classes.
|
||||
/// Uses a separate NameType (NUMERIC_ID_INTERNAL) so IDs can never collide
|
||||
/// with component-level NUMERIC_ID values, even if the uint32_t values overlap.
|
||||
enum class InternalSchedulerID : uint32_t {
|
||||
POLLING_UPDATE = 0, // PollingComponent interval
|
||||
DELAY_ACTION = 1, // DelayAction timeout
|
||||
};
|
||||
|
||||
// Forward declaration
|
||||
class PollingComponent;
|
||||
|
||||
@@ -335,6 +343,8 @@ class Component {
|
||||
*/
|
||||
void set_interval(uint32_t id, uint32_t interval, std::function<void()> &&f); // NOLINT
|
||||
|
||||
void set_interval(InternalSchedulerID id, uint32_t interval, std::function<void()> &&f); // NOLINT
|
||||
|
||||
void set_interval(uint32_t interval, std::function<void()> &&f); // NOLINT
|
||||
|
||||
/** Cancel an interval function.
|
||||
@@ -347,6 +357,7 @@ class Component {
|
||||
bool cancel_interval(const std::string &name); // NOLINT
|
||||
bool cancel_interval(const char *name); // NOLINT
|
||||
bool cancel_interval(uint32_t id); // NOLINT
|
||||
bool cancel_interval(InternalSchedulerID id); // NOLINT
|
||||
|
||||
/// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0.
|
||||
// Remove before 2026.8.0
|
||||
@@ -425,6 +436,8 @@ class Component {
|
||||
*/
|
||||
void set_timeout(uint32_t id, uint32_t timeout, std::function<void()> &&f); // NOLINT
|
||||
|
||||
void set_timeout(InternalSchedulerID id, uint32_t timeout, std::function<void()> &&f); // NOLINT
|
||||
|
||||
void set_timeout(uint32_t timeout, std::function<void()> &&f); // NOLINT
|
||||
|
||||
/** Cancel a timeout function.
|
||||
@@ -437,6 +450,7 @@ class Component {
|
||||
bool cancel_timeout(const std::string &name); // NOLINT
|
||||
bool cancel_timeout(const char *name); // NOLINT
|
||||
bool cancel_timeout(uint32_t id); // NOLINT
|
||||
bool cancel_timeout(InternalSchedulerID id); // NOLINT
|
||||
|
||||
/** Defer a callback to the next loop() call.
|
||||
*
|
||||
|
||||
@@ -53,9 +53,12 @@ struct SchedulerNameLog {
|
||||
} else if (name_type == NameType::HASHED_STRING) {
|
||||
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("hash:0x%08" PRIX32), hash_or_id);
|
||||
return buffer;
|
||||
} else { // NUMERIC_ID
|
||||
} else if (name_type == NameType::NUMERIC_ID) {
|
||||
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("id:%" PRIu32), hash_or_id);
|
||||
return buffer;
|
||||
} else { // NUMERIC_ID_INTERNAL
|
||||
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("iid:%" PRIu32), hash_or_id);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -137,6 +140,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
|
||||
case NameType::NUMERIC_ID:
|
||||
item->set_numeric_id(hash_or_id);
|
||||
break;
|
||||
case NameType::NUMERIC_ID_INTERNAL:
|
||||
item->set_internal_id(hash_or_id);
|
||||
break;
|
||||
}
|
||||
item->type = type;
|
||||
item->callback = std::move(func);
|
||||
|
||||
@@ -46,11 +46,20 @@ class Scheduler {
|
||||
void set_timeout(Component *component, const char *name, uint32_t timeout, std::function<void()> func);
|
||||
/// Set a timeout with a numeric ID (zero heap allocation)
|
||||
void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function<void()> func);
|
||||
/// Set a timeout with an internal scheduler ID (separate namespace from component NUMERIC_ID)
|
||||
void set_timeout(Component *component, InternalSchedulerID id, uint32_t timeout, std::function<void()> func) {
|
||||
this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr,
|
||||
static_cast<uint32_t>(id), timeout, std::move(func));
|
||||
}
|
||||
|
||||
ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0")
|
||||
bool cancel_timeout(Component *component, const std::string &name);
|
||||
bool cancel_timeout(Component *component, const char *name);
|
||||
bool cancel_timeout(Component *component, uint32_t id);
|
||||
bool cancel_timeout(Component *component, InternalSchedulerID id) {
|
||||
return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast<uint32_t>(id),
|
||||
SchedulerItem::TIMEOUT);
|
||||
}
|
||||
|
||||
ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0")
|
||||
void set_interval(Component *component, const std::string &name, uint32_t interval, std::function<void()> func);
|
||||
@@ -66,11 +75,20 @@ class Scheduler {
|
||||
void set_interval(Component *component, const char *name, uint32_t interval, std::function<void()> func);
|
||||
/// Set an interval with a numeric ID (zero heap allocation)
|
||||
void set_interval(Component *component, uint32_t id, uint32_t interval, std::function<void()> func);
|
||||
/// Set an interval with an internal scheduler ID (separate namespace from component NUMERIC_ID)
|
||||
void set_interval(Component *component, InternalSchedulerID id, uint32_t interval, std::function<void()> func) {
|
||||
this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr,
|
||||
static_cast<uint32_t>(id), interval, std::move(func));
|
||||
}
|
||||
|
||||
ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0")
|
||||
bool cancel_interval(Component *component, const std::string &name);
|
||||
bool cancel_interval(Component *component, const char *name);
|
||||
bool cancel_interval(Component *component, uint32_t id);
|
||||
bool cancel_interval(Component *component, InternalSchedulerID id) {
|
||||
return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast<uint32_t>(id),
|
||||
SchedulerItem::INTERVAL);
|
||||
}
|
||||
|
||||
// Remove before 2026.8.0
|
||||
ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.",
|
||||
@@ -112,11 +130,12 @@ class Scheduler {
|
||||
void process_to_add();
|
||||
|
||||
// Name storage type discriminator for SchedulerItem
|
||||
// Used to distinguish between static strings, hashed strings, and numeric IDs
|
||||
// Used to distinguish between static strings, hashed strings, numeric IDs, and internal numeric IDs
|
||||
enum class NameType : uint8_t {
|
||||
STATIC_STRING = 0, // const char* pointer to static/flash storage
|
||||
HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string
|
||||
NUMERIC_ID = 2 // uint32_t numeric identifier
|
||||
STATIC_STRING = 0, // const char* pointer to static/flash storage
|
||||
HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string
|
||||
NUMERIC_ID = 2, // uint32_t numeric identifier (component-level)
|
||||
NUMERIC_ID_INTERNAL = 3 // uint32_t numeric identifier (core/internal, separate namespace)
|
||||
};
|
||||
|
||||
protected:
|
||||
@@ -147,7 +166,7 @@ class Scheduler {
|
||||
|
||||
// Bit-packed fields (4 bits used, 4 bits padding in 1 byte)
|
||||
enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1;
|
||||
NameType name_type_ : 2; // Discriminator for name_ union (STATIC_STRING, HASHED_STRING, NUMERIC_ID)
|
||||
NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum)
|
||||
bool is_retry : 1; // True if this is a retry timeout
|
||||
// 4 bits padding
|
||||
#else
|
||||
@@ -155,7 +174,7 @@ class Scheduler {
|
||||
// Bit-packed fields (5 bits used, 3 bits padding in 1 byte)
|
||||
enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1;
|
||||
bool remove : 1;
|
||||
NameType name_type_ : 2; // Discriminator for name_ union (STATIC_STRING, HASHED_STRING, NUMERIC_ID)
|
||||
NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum)
|
||||
bool is_retry : 1; // True if this is a retry timeout
|
||||
// 3 bits padding
|
||||
#endif
|
||||
@@ -218,6 +237,12 @@ class Scheduler {
|
||||
name_type_ = NameType::NUMERIC_ID;
|
||||
}
|
||||
|
||||
// Helper to set an internal numeric ID (separate namespace from NUMERIC_ID)
|
||||
void set_internal_id(uint32_t id) {
|
||||
name_.hash_or_id = id;
|
||||
name_type_ = NameType::NUMERIC_ID_INTERNAL;
|
||||
}
|
||||
|
||||
static bool cmp(const std::unique_ptr<SchedulerItem> &a, const std::unique_ptr<SchedulerItem> &b);
|
||||
|
||||
// Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
esphome:
|
||||
name: scheduler-internal-id-test
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- logger.log: "Starting scheduler internal ID collision tests"
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
globals:
|
||||
- id: tests_done
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
- id: test_internal_id_no_collision
|
||||
then:
|
||||
- logger.log: "Testing NUMERIC_ID_INTERNAL vs NUMERIC_ID isolation"
|
||||
- lambda: |-
|
||||
// All tests use the same component and the same uint32_t value (0).
|
||||
// NUMERIC_ID_INTERNAL and NUMERIC_ID are separate NameType values,
|
||||
// so the scheduler must treat them as independent timers.
|
||||
auto *comp = id(test_sensor);
|
||||
|
||||
// ---- Test 1: Both timeout types fire independently ----
|
||||
// Set an internal timeout with ID 0
|
||||
App.scheduler.set_timeout(comp, InternalSchedulerID{0}, 50, []() {
|
||||
ESP_LOGI("test", "Internal timeout 0 fired");
|
||||
});
|
||||
// Set a component numeric timeout with the same ID 0
|
||||
App.scheduler.set_timeout(comp, 0U, 50, []() {
|
||||
ESP_LOGI("test", "Numeric timeout 0 fired");
|
||||
});
|
||||
|
||||
// ---- Test 2: Cancelling numeric ID does NOT cancel internal ID ----
|
||||
// Set an internal timeout with ID 1
|
||||
App.scheduler.set_timeout(comp, InternalSchedulerID{1}, 100, []() {
|
||||
ESP_LOGI("test", "Internal timeout 1 survived cancel");
|
||||
});
|
||||
// Set a numeric timeout with the same ID 1
|
||||
App.scheduler.set_timeout(comp, 1U, 100, []() {
|
||||
ESP_LOGE("test", "ERROR: Numeric timeout 1 should have been cancelled");
|
||||
});
|
||||
// Cancel only the numeric one
|
||||
App.scheduler.cancel_timeout(comp, 1U);
|
||||
|
||||
// ---- Test 3: Cancelling internal ID does NOT cancel numeric ID ----
|
||||
// Set a numeric timeout with ID 2
|
||||
App.scheduler.set_timeout(comp, 2U, 150, []() {
|
||||
ESP_LOGI("test", "Numeric timeout 2 survived cancel");
|
||||
});
|
||||
// Set an internal timeout with the same ID 2
|
||||
App.scheduler.set_timeout(comp, InternalSchedulerID{2}, 150, []() {
|
||||
ESP_LOGE("test", "ERROR: Internal timeout 2 should have been cancelled");
|
||||
});
|
||||
// Cancel only the internal one
|
||||
App.scheduler.cancel_timeout(comp, InternalSchedulerID{2});
|
||||
|
||||
// ---- Test 4: Both interval types fire independently ----
|
||||
static int internal_interval_count = 0;
|
||||
static int numeric_interval_count = 0;
|
||||
App.scheduler.set_interval(comp, InternalSchedulerID{3}, 100, []() {
|
||||
internal_interval_count++;
|
||||
if (internal_interval_count == 2) {
|
||||
ESP_LOGI("test", "Internal interval 3 fired twice");
|
||||
App.scheduler.cancel_interval(id(test_sensor), InternalSchedulerID{3});
|
||||
}
|
||||
});
|
||||
App.scheduler.set_interval(comp, 3U, 100, []() {
|
||||
numeric_interval_count++;
|
||||
if (numeric_interval_count == 2) {
|
||||
ESP_LOGI("test", "Numeric interval 3 fired twice");
|
||||
App.scheduler.cancel_interval(id(test_sensor), 3U);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Test 5: String name does NOT collide with internal ID ----
|
||||
// Use string name and internal ID 10 on same component
|
||||
App.scheduler.set_timeout(comp, "collision_test", 200, []() {
|
||||
ESP_LOGI("test", "String timeout collision_test fired");
|
||||
});
|
||||
App.scheduler.set_timeout(comp, InternalSchedulerID{10}, 200, []() {
|
||||
ESP_LOGI("test", "Internal timeout 10 fired");
|
||||
});
|
||||
|
||||
// Log completion after all timers should have fired
|
||||
App.scheduler.set_timeout(comp, 9999U, 1500, []() {
|
||||
ESP_LOGI("test", "All collision tests complete");
|
||||
});
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: Test Sensor
|
||||
id: test_sensor
|
||||
lambda: return 1.0;
|
||||
update_interval: never
|
||||
|
||||
interval:
|
||||
- interval: 0.1s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(tests_done) == false;'
|
||||
then:
|
||||
- lambda: 'id(tests_done) = true;'
|
||||
- script.execute: test_internal_id_no_collision
|
||||
124
tests/integration/test_scheduler_internal_id_no_collision.py
Normal file
124
tests/integration/test_scheduler_internal_id_no_collision.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Test that NUMERIC_ID_INTERNAL and NUMERIC_ID cannot collide.
|
||||
|
||||
Verifies that InternalSchedulerID (used by core base classes like
|
||||
PollingComponent and DelayAction) and uint32_t numeric IDs (used by
|
||||
components) are in completely separate matching namespaces, even when
|
||||
the underlying uint32_t values are identical and on the same component.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_internal_id_no_collision(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that internal and numeric IDs with same value don't collide."""
|
||||
# Test 1: Both types fire independently with same ID
|
||||
internal_timeout_0_fired = asyncio.Event()
|
||||
numeric_timeout_0_fired = asyncio.Event()
|
||||
|
||||
# Test 2: Cancelling numeric doesn't cancel internal
|
||||
internal_timeout_1_survived = asyncio.Event()
|
||||
numeric_timeout_1_error = asyncio.Event()
|
||||
|
||||
# Test 3: Cancelling internal doesn't cancel numeric
|
||||
numeric_timeout_2_survived = asyncio.Event()
|
||||
internal_timeout_2_error = asyncio.Event()
|
||||
|
||||
# Test 4: Both interval types fire independently
|
||||
internal_interval_3_done = asyncio.Event()
|
||||
numeric_interval_3_done = asyncio.Event()
|
||||
|
||||
# Test 5: String name doesn't collide with internal ID
|
||||
string_timeout_fired = asyncio.Event()
|
||||
internal_timeout_10_fired = asyncio.Event()
|
||||
|
||||
# Completion
|
||||
all_tests_complete = asyncio.Event()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
|
||||
if "Internal timeout 0 fired" in clean_line:
|
||||
internal_timeout_0_fired.set()
|
||||
elif "Numeric timeout 0 fired" in clean_line:
|
||||
numeric_timeout_0_fired.set()
|
||||
elif "Internal timeout 1 survived cancel" in clean_line:
|
||||
internal_timeout_1_survived.set()
|
||||
elif "ERROR: Numeric timeout 1 should have been cancelled" in clean_line:
|
||||
numeric_timeout_1_error.set()
|
||||
elif "Numeric timeout 2 survived cancel" in clean_line:
|
||||
numeric_timeout_2_survived.set()
|
||||
elif "ERROR: Internal timeout 2 should have been cancelled" in clean_line:
|
||||
internal_timeout_2_error.set()
|
||||
elif "Internal interval 3 fired twice" in clean_line:
|
||||
internal_interval_3_done.set()
|
||||
elif "Numeric interval 3 fired twice" in clean_line:
|
||||
numeric_interval_3_done.set()
|
||||
elif "String timeout collision_test fired" in clean_line:
|
||||
string_timeout_fired.set()
|
||||
elif "Internal timeout 10 fired" in clean_line:
|
||||
internal_timeout_10_fired.set()
|
||||
elif "All collision tests complete" in clean_line:
|
||||
all_tests_complete.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "scheduler-internal-id-test"
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Not all collision tests completed within 5 seconds")
|
||||
|
||||
# Test 1: Both timeout types with same ID 0 must fire
|
||||
assert internal_timeout_0_fired.is_set(), (
|
||||
"Internal timeout with ID 0 should have fired"
|
||||
)
|
||||
assert numeric_timeout_0_fired.is_set(), (
|
||||
"Numeric timeout with ID 0 should have fired"
|
||||
)
|
||||
|
||||
# Test 2: Cancelling numeric ID must NOT cancel internal ID
|
||||
assert internal_timeout_1_survived.is_set(), (
|
||||
"Internal timeout 1 should survive cancellation of numeric timeout 1"
|
||||
)
|
||||
assert not numeric_timeout_1_error.is_set(), (
|
||||
"Numeric timeout 1 should have been cancelled"
|
||||
)
|
||||
|
||||
# Test 3: Cancelling internal ID must NOT cancel numeric ID
|
||||
assert numeric_timeout_2_survived.is_set(), (
|
||||
"Numeric timeout 2 should survive cancellation of internal timeout 2"
|
||||
)
|
||||
assert not internal_timeout_2_error.is_set(), (
|
||||
"Internal timeout 2 should have been cancelled"
|
||||
)
|
||||
|
||||
# Test 4: Both interval types with same ID must fire independently
|
||||
assert internal_interval_3_done.is_set(), (
|
||||
"Internal interval 3 should have fired at least twice"
|
||||
)
|
||||
assert numeric_interval_3_done.is_set(), (
|
||||
"Numeric interval 3 should have fired at least twice"
|
||||
)
|
||||
|
||||
# Test 5: String name and internal ID don't collide
|
||||
assert string_timeout_fired.is_set(), (
|
||||
"String timeout 'collision_test' should have fired"
|
||||
)
|
||||
assert internal_timeout_10_fired.is_set(), (
|
||||
"Internal timeout 10 should have fired alongside string timeout"
|
||||
)
|
||||
Reference in New Issue
Block a user