mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 18:16:03 +00:00
[scheduler] Add self-keyed timer API for callers without a Component
This commit is contained in:
@@ -53,9 +53,13 @@ struct SchedulerNameLog {
|
||||
} 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
|
||||
} else if (name_type == NameType::NUMERIC_ID_INTERNAL) {
|
||||
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("iid:%" PRIu32), hash_or_id);
|
||||
return buffer;
|
||||
} else { // SELF_POINTER
|
||||
// static_name carries the void* key for SELF_POINTER (pointer-width union slot).
|
||||
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("self:%p"), static_name);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -293,6 +297,27 @@ bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) {
|
||||
return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL);
|
||||
}
|
||||
|
||||
// Self-keyed scheduler API. The cancellation key is `self` (typically the caller's `this`),
|
||||
// passed through the existing static_name pointer slot. Matching is by raw pointer equality
|
||||
// (see matches_item_locked_'s SELF_POINTER branch). No Component pointer is stored, so
|
||||
// is_failed() skip and component-based log attribution don't apply.
|
||||
void HOT Scheduler::set_timeout(void *self, uint32_t timeout, std::function<void()> &&func) {
|
||||
this->set_timer_common_(nullptr, SchedulerItem::TIMEOUT, NameType::SELF_POINTER, static_cast<const char *>(self), 0,
|
||||
timeout, std::move(func));
|
||||
}
|
||||
void HOT Scheduler::set_interval(void *self, uint32_t interval, std::function<void()> &&func) {
|
||||
this->set_timer_common_(nullptr, SchedulerItem::INTERVAL, NameType::SELF_POINTER, static_cast<const char *>(self), 0,
|
||||
interval, std::move(func));
|
||||
}
|
||||
bool HOT Scheduler::cancel_timeout(void *self) {
|
||||
return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast<const char *>(self), 0,
|
||||
SchedulerItem::TIMEOUT);
|
||||
}
|
||||
bool HOT Scheduler::cancel_interval(void *self) {
|
||||
return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast<const char *>(self), 0,
|
||||
SchedulerItem::INTERVAL);
|
||||
}
|
||||
|
||||
// Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation.
|
||||
// Remove before 2026.8.0 along with all retry code.
|
||||
#pragma GCC diagnostic push
|
||||
|
||||
+52
-19
@@ -148,12 +148,32 @@ class Scheduler {
|
||||
// Name storage type discriminator for SchedulerItem
|
||||
// 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 (component-level)
|
||||
NUMERIC_ID_INTERNAL = 3 // uint32_t numeric identifier (core/internal, separate namespace)
|
||||
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)
|
||||
SELF_POINTER = 4 // void* caller-supplied key (typically `this`); pointer equality
|
||||
};
|
||||
|
||||
/** Self-keyed timeout. The cancellation key is `self` (typically the caller's `this`).
|
||||
*
|
||||
* Use this when the caller schedules at most one timer of a single purpose at a time and
|
||||
* does not need a `Component` for `is_failed()` skip or log source attribution. Lets
|
||||
* small classes drop `Component` inheritance entirely when their only Component dependency
|
||||
* was the per-instance scheduler key.
|
||||
*
|
||||
* NOT applied for self-keyed items:
|
||||
* - `is_failed()` skip — callbacks always fire (no Component to consult).
|
||||
* - Log source attribution — logs use a generic "self:0x…" label.
|
||||
*
|
||||
* If you need either of those, use the existing `(Component *, id)` overloads.
|
||||
*/
|
||||
void set_timeout(void *self, uint32_t timeout, std::function<void()> &&func);
|
||||
/// Self-keyed interval. See set_timeout(void *, ...) for semantics.
|
||||
void set_interval(void *self, uint32_t interval, std::function<void()> &&func);
|
||||
bool cancel_timeout(void *self);
|
||||
bool cancel_interval(void *self);
|
||||
|
||||
protected:
|
||||
struct SchedulerItem {
|
||||
// Ordered by size to minimize padding
|
||||
@@ -182,19 +202,19 @@ class Scheduler {
|
||||
// std::atomic<uint8_t> inlines correctly on all platforms.
|
||||
std::atomic<uint8_t> remove{0};
|
||||
|
||||
// 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 (0–3, see NameType enum)
|
||||
bool is_retry : 1; // True if this is a retry timeout
|
||||
// 4 bits padding
|
||||
#else
|
||||
// Single-threaded or multi-threaded without atomics: can pack all fields together
|
||||
// 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 (0–3, see NameType enum)
|
||||
NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum)
|
||||
bool is_retry : 1; // True if this is a retry timeout
|
||||
// 3 bits padding
|
||||
#else
|
||||
// Single-threaded or multi-threaded without atomics: can pack all fields together
|
||||
// Bit-packed fields (6 bits used, 2 bits padding in 1 byte)
|
||||
enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1;
|
||||
bool remove : 1;
|
||||
NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum)
|
||||
bool is_retry : 1; // True if this is a retry timeout
|
||||
// 2 bits padding
|
||||
#endif
|
||||
|
||||
// Constructor
|
||||
@@ -231,16 +251,25 @@ class Scheduler {
|
||||
// Helper to get the static name (only valid for STATIC_STRING type)
|
||||
const char *get_name() const { return (name_type_ == NameType::STATIC_STRING) ? name_.static_name : nullptr; }
|
||||
|
||||
// Helper to get the hash or numeric ID (only valid for HASHED_STRING or NUMERIC_ID types)
|
||||
uint32_t get_name_hash_or_id() const { return (name_type_ != NameType::STATIC_STRING) ? name_.hash_or_id : 0; }
|
||||
// Helper to get the hash or numeric ID (only valid for HASHED_STRING / NUMERIC_ID / NUMERIC_ID_INTERNAL types)
|
||||
uint32_t get_name_hash_or_id() const {
|
||||
return (name_type_ != NameType::STATIC_STRING && name_type_ != NameType::SELF_POINTER) ? name_.hash_or_id : 0;
|
||||
}
|
||||
|
||||
// Helper to get the self pointer (only valid for SELF_POINTER type).
|
||||
// The pointer is stored in the same union slot as `static_name` since both are pointer-width.
|
||||
const void *get_self() const {
|
||||
return (name_type_ == NameType::SELF_POINTER) ? static_cast<const void *>(name_.static_name) : nullptr;
|
||||
}
|
||||
|
||||
// Helper to get the name type
|
||||
NameType get_name_type() const { return name_type_; }
|
||||
|
||||
// Set name storage: for STATIC_STRING stores the pointer, for all other types stores hash_or_id.
|
||||
// Both union members occupy the same offset, so only one store is needed.
|
||||
// Set name storage. STATIC_STRING/SELF_POINTER use the static_name pointer slot
|
||||
// (both are pointer-width); other types use hash_or_id. Both union members occupy
|
||||
// the same offset, so only one store is needed.
|
||||
void set_name(NameType type, const char *static_name, uint32_t hash_or_id) {
|
||||
if (type == NameType::STATIC_STRING) {
|
||||
if (type == NameType::STATIC_STRING || type == NameType::SELF_POINTER) {
|
||||
name_.static_name = static_name;
|
||||
} else {
|
||||
name_.hash_or_id = hash_or_id;
|
||||
@@ -367,10 +396,14 @@ class Scheduler {
|
||||
// Name type must match
|
||||
if (item->get_name_type() != name_type)
|
||||
return false;
|
||||
// For static strings, compare the string content; for hash/ID, compare the value
|
||||
// STATIC_STRING: compare string content. SELF_POINTER: raw pointer equality (no strcmp).
|
||||
// Other types: compare hash/ID value.
|
||||
if (name_type == NameType::STATIC_STRING) {
|
||||
return this->names_match_static_(item->get_name(), static_name);
|
||||
}
|
||||
if (name_type == NameType::SELF_POINTER) {
|
||||
return item->name_.static_name == static_name;
|
||||
}
|
||||
return item->get_name_hash_or_id() == hash_or_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
esphome:
|
||||
debug_scheduler: true # Enable scheduler leak detection
|
||||
name: scheduler-self-keyed-test
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- logger.log: "Starting scheduler self-keyed tests"
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
globals:
|
||||
- id: tests_done
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
- id: test_self_keyed
|
||||
then:
|
||||
- logger.log: "Testing self-keyed scheduler API"
|
||||
- lambda: |-
|
||||
// Two distinct heap-allocated keys - they must not collide
|
||||
// even though both are self-keyed and share no Component pointer.
|
||||
static int key_a_marker = 0;
|
||||
static int key_b_marker = 0;
|
||||
void *key_a = &key_a_marker;
|
||||
void *key_b = &key_b_marker;
|
||||
|
||||
// ---- Test 1: Self-keyed timeout fires ----
|
||||
App.scheduler.set_timeout(key_a, 50, []() {
|
||||
ESP_LOGI("test", "Self timeout A fired");
|
||||
});
|
||||
|
||||
// ---- Test 2: Self-keyed cancel cancels only that key ----
|
||||
App.scheduler.set_timeout(key_b, 100, []() {
|
||||
ESP_LOGE("test", "ERROR: Self timeout B should have been cancelled");
|
||||
});
|
||||
App.scheduler.cancel_timeout(key_b);
|
||||
|
||||
// ---- Test 3: Two independent self keys don't collide ----
|
||||
// Using fresh static markers so neither matches key_a / key_b.
|
||||
static int key_c_marker = 0;
|
||||
static int key_d_marker = 0;
|
||||
void *key_c = &key_c_marker;
|
||||
void *key_d = &key_d_marker;
|
||||
App.scheduler.set_timeout(key_c, 150, []() {
|
||||
ESP_LOGI("test", "Self timeout C fired");
|
||||
});
|
||||
App.scheduler.set_timeout(key_d, 150, []() {
|
||||
ESP_LOGI("test", "Self timeout D fired");
|
||||
});
|
||||
|
||||
// ---- Test 4: Self-keyed and component-keyed don't collide ----
|
||||
// Use a self pointer that happens to look like a Component-attached id.
|
||||
// The scheduler must treat them as separate namespaces.
|
||||
static int shared_marker = 0;
|
||||
void *self_shared = &shared_marker;
|
||||
App.scheduler.set_timeout(self_shared, 200, []() {
|
||||
ESP_LOGI("test", "Self timeout shared fired");
|
||||
});
|
||||
App.scheduler.set_timeout(id(test_sensor), 7777U, 200, []() {
|
||||
ESP_LOGI("test", "Component timeout 7777 fired");
|
||||
});
|
||||
|
||||
// ---- Test 5: Self-keyed interval fires multiple times then cancels ----
|
||||
static int interval_count = 0;
|
||||
static int key_e_marker = 0;
|
||||
void *key_e = &key_e_marker;
|
||||
App.scheduler.set_interval(key_e, 80, [key_e]() {
|
||||
interval_count++;
|
||||
if (interval_count == 2) {
|
||||
ESP_LOGI("test", "Self interval E fired twice");
|
||||
App.scheduler.cancel_interval(key_e);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Test 6: Re-registering same self-key replaces the timer ----
|
||||
// The old timer must NOT fire; only the new one does.
|
||||
static int key_f_marker = 0;
|
||||
void *key_f = &key_f_marker;
|
||||
App.scheduler.set_timeout(key_f, 250, []() {
|
||||
ESP_LOGE("test", "ERROR: Self timeout F first registration should have been replaced");
|
||||
});
|
||||
App.scheduler.set_timeout(key_f, 300, []() {
|
||||
ESP_LOGI("test", "Self timeout F replacement fired");
|
||||
});
|
||||
|
||||
// Log completion after all timers should have fired
|
||||
App.scheduler.set_timeout(id(test_sensor), 9999U, 1500, []() {
|
||||
ESP_LOGI("test", "All self-keyed 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_self_keyed
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Test the self-keyed scheduler API.
|
||||
|
||||
Verifies that `Scheduler::set_timeout(void *, ...)` / `set_interval(void *, ...)` and the
|
||||
matching `cancel_*(void *)` overloads behave correctly: callbacks fire, distinct keys
|
||||
don't collide, self-keyed and component-keyed namespaces are independent, and
|
||||
re-registering the same key replaces the existing timer.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_self_keyed(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test self-keyed scheduler API."""
|
||||
self_a_fired = asyncio.Event()
|
||||
self_b_error = asyncio.Event()
|
||||
self_c_fired = asyncio.Event()
|
||||
self_d_fired = asyncio.Event()
|
||||
self_shared_fired = asyncio.Event()
|
||||
component_7777_fired = asyncio.Event()
|
||||
self_interval_done = asyncio.Event()
|
||||
self_f_first_error = asyncio.Event()
|
||||
self_f_replacement_fired = asyncio.Event()
|
||||
all_tests_complete = asyncio.Event()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
|
||||
if "Self timeout A fired" in clean_line:
|
||||
self_a_fired.set()
|
||||
elif "ERROR: Self timeout B" in clean_line:
|
||||
self_b_error.set()
|
||||
elif "Self timeout C fired" in clean_line:
|
||||
self_c_fired.set()
|
||||
elif "Self timeout D fired" in clean_line:
|
||||
self_d_fired.set()
|
||||
elif "Self timeout shared fired" in clean_line:
|
||||
self_shared_fired.set()
|
||||
elif "Component timeout 7777 fired" in clean_line:
|
||||
component_7777_fired.set()
|
||||
elif "Self interval E fired twice" in clean_line:
|
||||
self_interval_done.set()
|
||||
elif "ERROR: Self timeout F first registration" in clean_line:
|
||||
self_f_first_error.set()
|
||||
elif "Self timeout F replacement fired" in clean_line:
|
||||
self_f_replacement_fired.set()
|
||||
elif "All self-keyed 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-self-keyed-test"
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Not all self-keyed tests completed within 5 seconds")
|
||||
|
||||
# Test 1: self-keyed timeout fires
|
||||
assert self_a_fired.is_set(), "Self timeout A should have fired"
|
||||
|
||||
# Test 2: cancel_timeout(self) actually cancels
|
||||
assert not self_b_error.is_set(), "Self timeout B should have been cancelled"
|
||||
|
||||
# Test 3: distinct self keys don't collide
|
||||
assert self_c_fired.is_set(), "Self timeout C should have fired"
|
||||
assert self_d_fired.is_set(), "Self timeout D should have fired"
|
||||
|
||||
# Test 4: self-keyed and component-keyed namespaces are independent
|
||||
assert self_shared_fired.is_set(), "Self timeout shared should have fired"
|
||||
assert component_7777_fired.is_set(), "Component timeout 7777 should have fired"
|
||||
|
||||
# Test 5: self-keyed interval fires repeatedly and cancels cleanly
|
||||
assert self_interval_done.is_set(), "Self interval E should have fired twice"
|
||||
|
||||
# Test 6: re-registering same self-key replaces the previous timer
|
||||
assert not self_f_first_error.is_set(), (
|
||||
"Self timeout F first registration should have been replaced"
|
||||
)
|
||||
assert self_f_replacement_fired.is_set(), (
|
||||
"Self timeout F replacement should have fired"
|
||||
)
|
||||
Reference in New Issue
Block a user