From 241d7797e36a2ec2de37d7f7fe5ed2857a0d98aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 09:18:09 -0500 Subject: [PATCH] [scheduler] Add self-keyed timer API for callers without a Component --- esphome/core/scheduler.cpp | 27 ++++- esphome/core/scheduler.h | 71 ++++++++--- .../fixtures/scheduler_self_keyed.yaml | 110 ++++++++++++++++++ .../integration/test_scheduler_self_keyed.py | 95 +++++++++++++++ 4 files changed, 283 insertions(+), 20 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_self_keyed.yaml create mode 100644 tests/integration/test_scheduler_self_keyed.py diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 11884ce4ba..4feb0811c9 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -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 &&func) { + this->set_timer_common_(nullptr, SchedulerItem::TIMEOUT, NameType::SELF_POINTER, static_cast(self), 0, + timeout, std::move(func)); +} +void HOT Scheduler::set_interval(void *self, uint32_t interval, std::function &&func) { + this->set_timer_common_(nullptr, SchedulerItem::INTERVAL, NameType::SELF_POINTER, static_cast(self), 0, + interval, std::move(func)); +} +bool HOT Scheduler::cancel_timeout(void *self) { + return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast(self), 0, + SchedulerItem::TIMEOUT); +} +bool HOT Scheduler::cancel_interval(void *self) { + return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast(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 diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 46b19855c3..cb75dce38a 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -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 &&func); + /// Self-keyed interval. See set_timeout(void *, ...) for semantics. + void set_interval(void *self, uint32_t interval, std::function &&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 inlines correctly on all platforms. std::atomic 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(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; } diff --git a/tests/integration/fixtures/scheduler_self_keyed.yaml b/tests/integration/fixtures/scheduler_self_keyed.yaml new file mode 100644 index 0000000000..bee8a488b1 --- /dev/null +++ b/tests/integration/fixtures/scheduler_self_keyed.yaml @@ -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 diff --git a/tests/integration/test_scheduler_self_keyed.py b/tests/integration/test_scheduler_self_keyed.py new file mode 100644 index 0000000000..48c9d98ad6 --- /dev/null +++ b/tests/integration/test_scheduler_self_keyed.py @@ -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" + )