Merge remote-tracking branch 'upstream/sensor-filters-drop-component' into integration

This commit is contained in:
J. Nick Koston
2026-04-29 10:21:49 -05:00
11 changed files with 322 additions and 72 deletions
@@ -78,7 +78,8 @@ class ActionResponse {
: success_(success), error_message_(error_message) {
if (data == nullptr || data_len == 0)
return;
this->json_document_ = json::parse_json(data, data_len);
JsonDocument tmp = json::parse_json(data, data_len);
swap(this->json_document_, tmp);
}
#endif
+6 -13
View File
@@ -266,7 +266,7 @@ StreamingMovingAverageFilter = sensor_ns.class_("StreamingMovingAverageFilter",
ExponentialMovingAverageFilter = sensor_ns.class_(
"ExponentialMovingAverageFilter", Filter
)
ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Component)
ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter)
LambdaFilter = sensor_ns.class_("LambdaFilter", Filter)
StatelessLambdaFilter = sensor_ns.class_("StatelessLambdaFilter", Filter)
OffsetFilter = sensor_ns.class_("OffsetFilter", Filter)
@@ -283,8 +283,8 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_(
TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase)
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component)
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component)
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter)
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter)
DeltaFilter = sensor_ns.class_("DeltaFilter", Filter)
OrFilter = sensor_ns.class_("OrFilter", Filter)
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
@@ -567,9 +567,7 @@ async def exponential_moving_average_filter_to_code(config, filter_id):
"throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds
)
async def throttle_average_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id, config)
await cg.register_component(var, {})
return var
return cg.new_Pvariable(filter_id, config)
@FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda)
@@ -698,13 +696,10 @@ HEARTBEAT_SCHEMA = cv.Schema(
async def heartbeat_filter_to_code(config, filter_id):
if isinstance(config, dict):
var = cg.new_Pvariable(filter_id, config[CONF_PERIOD])
await cg.register_component(var, {})
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
return var
var = cg.new_Pvariable(filter_id, config)
await cg.register_component(var, {})
return var
return cg.new_Pvariable(filter_id, config)
TIMEOUT_SCHEMA = cv.maybe_simple_value(
@@ -738,9 +733,7 @@ async def timeout_filter_to_code(config, filter_id):
"debounce", DebounceFilter, cv.positive_time_period_milliseconds
)
async def debounce_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id, config)
await cg.register_component(var, {})
return var
return cg.new_Pvariable(filter_id, config)
CONF_DATAPOINTS = "datapoints"
+7 -14
View File
@@ -13,11 +13,6 @@ 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);
@@ -185,8 +180,9 @@ optional<float> ThrottleAverageFilter::new_value(float value) {
}
return {};
}
void ThrottleAverageFilter::setup() {
this->set_interval(FILTER_ID, this->time_period_, [this]() {
void ThrottleAverageFilter::initialize(Sensor *parent, Filter *next) {
Filter::initialize(parent, next);
App.scheduler.set_interval(this, 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_)
@@ -199,7 +195,6 @@ void ThrottleAverageFilter::setup() {
this->have_nan_ = false;
});
}
float ThrottleAverageFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
// LambdaFilter
LambdaFilter::LambdaFilter(lambda_filter_t lambda_filter) : lambda_filter_(std::move(lambda_filter)) {}
@@ -362,13 +357,12 @@ optional<float> TimeoutFilterConfigured::new_value(float value) {
// DebounceFilter
optional<float> DebounceFilter::new_value(float value) {
this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); });
App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); });
return {};
}
DebounceFilter::DebounceFilter(uint32_t time_period) : time_period_(time_period) {}
float DebounceFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
// HeartbeatFilter
HeartbeatFilter::HeartbeatFilter(uint32_t time_period) : time_period_(time_period), last_input_(NAN) {}
@@ -384,8 +378,9 @@ optional<float> HeartbeatFilter::new_value(float value) {
return {};
}
void HeartbeatFilter::setup() {
this->set_interval(FILTER_ID, this->time_period_, [this]() {
void HeartbeatFilter::initialize(Sensor *parent, Filter *next) {
Filter::initialize(parent, next);
App.scheduler.set_interval(this, 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_)
@@ -395,8 +390,6 @@ void HeartbeatFilter::setup() {
});
}
float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
optional<float> calibrate_linear_compute(const std::array<float, 3> *functions, size_t count, float value) {
for (size_t i = 0; i < count; i++) {
if (!std::isfinite(functions[i][2]) || value < functions[i][2])
+5 -10
View File
@@ -254,16 +254,14 @@ class ExponentialMovingAverageFilter : public Filter {
*
* It takes the average of all the values received in a period of time.
*/
class ThrottleAverageFilter : public Filter, public Component {
class ThrottleAverageFilter : public Filter {
public:
explicit ThrottleAverageFilter(uint32_t time_period);
void setup() override;
void initialize(Sensor *parent, Filter *next) override;
optional<float> new_value(float value) override;
float get_setup_priority() const override;
protected:
float sum_{0.0f};
unsigned int n_{0};
@@ -454,25 +452,22 @@ class TimeoutFilterConfigured : public TimeoutFilterBase {
// Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead
};
class DebounceFilter : public Filter, public Component {
class DebounceFilter : public Filter {
public:
explicit DebounceFilter(uint32_t time_period);
optional<float> new_value(float value) override;
float get_setup_priority() const override;
protected:
uint32_t time_period_;
};
class HeartbeatFilter : public Filter, public Component {
class HeartbeatFilter : public Filter {
public:
explicit HeartbeatFilter(uint32_t time_period);
void setup() override;
void initialize(Sensor *parent, Filter *next) override;
optional<float> new_value(float value) override;
float get_setup_priority() const override;
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
+7 -6
View File
@@ -31,13 +31,14 @@ void CronTrigger::check_time_() {
return;
if (this->last_check_.has_value()) {
if (*this->last_check_ > time && this->last_check_->timestamp - time.timestamp > MAX_TIMESTAMP_DRIFT) {
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 (*this->last_check_ >= time) {
} else if (last_check >= time) {
// already handled this one
return;
} else if (time > *this->last_check_ && time.timestamp - this->last_check_->timestamp > MAX_TIMESTAMP_DRIFT) {
} 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;
@@ -45,11 +46,11 @@ void CronTrigger::check_time_() {
}
while (true) {
this->last_check_->increment_second();
if (*this->last_check_ >= time)
last_check.increment_second();
if (last_check >= time)
break;
if (this->matches(*this->last_check_))
if (this->matches(last_check))
this->trigger();
}
}
@@ -282,12 +282,13 @@ optional<GateStatus> Tormatic::read_gate_status_() {
}
}
auto hdr = this->pending_hdr_.value();
// Wait for all payload bytes to arrive before processing.
if (this->available() < this->pending_hdr_->payload_size()) {
if (this->available() < hdr.payload_size()) {
return {};
}
auto hdr = *this->pending_hdr_;
this->pending_hdr_.reset();
switch (hdr.type) {
+30 -2
View File
@@ -35,7 +35,9 @@ static constexpr uint32_t MAX_INTERVAL_DELAY = 5000;
// Uses a stack buffer to avoid heap allocation
// Uses ESPHOME_snprintf_P/ESPHOME_PSTR for ESP8266 to keep format strings in flash
struct SchedulerNameLog {
char buffer[20]; // Enough for "id:4294967295" or "hash:0xFFFFFFFF" or "(null)"
// Sized for the widest formatted output: "self:0x" + 16 hex digits (64-bit pointer) + nul.
// Also covers "id:4294967295", "hash:0xFFFFFFFF", "iid:4294967295", "(null)".
char buffer[28];
// Format a scheduler item name for logging
// Returns pointer to formatted string (either static_name or internal buffer)
@@ -53,9 +55,14 @@ 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).
// Cast to const void* — %p requires a void* argument.
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("self:%p"), static_cast<const void *>(static_name));
return buffer;
}
}
};
@@ -293,6 +300,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(const 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(const 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(const void *self) {
return this->cancel_item_(nullptr, NameType::SELF_POINTER, static_cast<const char *>(self), 0,
SchedulerItem::TIMEOUT);
}
bool HOT Scheduler::cancel_interval(const 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
+54 -23
View File
@@ -148,20 +148,40 @@ 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(const void *self, uint32_t timeout, std::function<void()> &&func);
/// Self-keyed interval. See set_timeout(const void *, ...) for semantics.
void set_interval(const void *self, uint32_t interval, std::function<void()> &&func);
bool cancel_timeout(const void *self);
bool cancel_interval(const void *self);
protected:
struct SchedulerItem {
// Ordered by size to minimize padding
Component *component;
// Optimized name storage using tagged union - zero heap allocation
union {
const char *static_name; // For STATIC_STRING (string literals, no allocation)
uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID
const char *static_name; // For STATIC_STRING (string literals) and SELF_POINTER (caller's `this`)
uint32_t hash_or_id; // For HASHED_STRING, NUMERIC_ID, and NUMERIC_ID_INTERNAL
} name_;
uint32_t interval;
// Split time to handle millis() rollover. The scheduler combines the 32-bit millis()
@@ -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 (03, 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 (03, see NameType enum)
NameType name_type_ : 3; // Discriminator for name_ union (04, 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 (04, see NameType enum)
bool is_retry : 1; // True if this is a retry timeout
// 2 bits padding
#endif
// Constructor
@@ -228,19 +248,26 @@ class Scheduler {
SchedulerItem(SchedulerItem &&) = delete;
SchedulerItem &operator=(SchedulerItem &&) = delete;
// 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 pointer-slot value (valid for STATIC_STRING and SELF_POINTER types).
// Both share the same union member, so callers (e.g. log formatters) can read either uniformly.
const char *get_name() const {
return (name_type_ == NameType::STATIC_STRING || name_type_ == NameType::SELF_POINTER) ? 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 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 +394,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;
}
@@ -112,7 +112,7 @@ TEST(ProtoMacVarint, AllOnes) { verify_mac(0xFFFFFFFFFFFFULL, 7); } // F
// 100 deterministic-random 48-bit MACs to catch regressions across the space.
TEST(ProtoMacVarint, RandomSample) {
// NOLINTNEXTLINE(cert-msc32-c,cert-msc51-cpp) -- intentional fixed seed for reproducibility.
// NOLINTNEXTLINE(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) -- fixed seed for reproducibility
std::mt19937_64 rng(0xC0FFEE);
for (int i = 0; i < 100; i++) {
uint64_t mac = rng() & 0xFFFFFFFFFFFFULL;
@@ -0,0 +1,112 @@
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 keys backed by addresses of static markers — they
// must not collide even though both are self-keyed and share no
// Component pointer. Static storage gives them stable, unique
// addresses for the lifetime of the program.
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"
)