Compare commits

...

10 Commits

Author SHA1 Message Date
J. Nick Koston
4267e5dda2 Merge branch 'dev' into esp8266-logger-vsnprintf-p 2026-02-02 22:24:57 +01:00
J. Nick Koston
1119003eb5 [core] Add missing uint32_t ID overloads for defer() and cancel_defer() (#13720) 2026-02-02 22:22:11 +01:00
J. Nick Koston
c089d9aeac [esp32_hosted] Replace sscanf with strtol for version parsing (#13658) 2026-02-02 22:21:52 +01:00
J. Nick Koston
4f0894e970 [analyze-memory] Add top 30 largest symbols to report (#13673) 2026-02-02 22:05:39 +01:00
J. Nick Koston
848c237159 [time] Use lazy callback for time sync to save 8 bytes (#13652) 2026-02-02 22:05:27 +01:00
J. Nick Koston
6892805094 [api] Align water_heater_command with standard entity command pattern (#13655) 2026-02-02 22:00:46 +01:00
J. Nick Koston
bd3b7aa50a naming 2026-02-02 21:52:34 +01:00
J. Nick Koston
bce4a9c9ab force in 2026-02-02 17:45:25 +01:00
Roger Fachini
aa8ccfc32b [ethernet] Add on_connect and on_disconnect triggers (#13677)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-02-02 17:00:11 +01:00
rwrozelle
18991686ab [mqtt] resolve warnings related to use of ip.str() (#13719) 2026-02-02 16:48:08 +01:00
29 changed files with 253 additions and 17 deletions

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
import heapq
from operator import itemgetter
import sys
from typing import TYPE_CHECKING
@@ -29,6 +31,10 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
)
# Lower threshold for RAM symbols (RAM is more constrained)
RAM_SYMBOL_SIZE_THRESHOLD: int = 24
# Number of top symbols to show in the largest symbols report
TOP_SYMBOLS_LIMIT: int = 30
# Width for symbol name display in top symbols report
COL_TOP_SYMBOL_NAME: int = 55
# Column width constants
COL_COMPONENT: int = 29
@@ -147,6 +153,37 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
section_label = f" [{section[1:]}]" # .data -> [data], .bss -> [bss]
return f"{demangled} ({size:,} B){section_label}"
def _add_top_symbols(self, lines: list[str]) -> None:
"""Add a section showing the top largest symbols in the binary."""
# Collect all symbols from all components: (symbol, demangled, size, section, component)
all_symbols = [
(symbol, demangled, size, section, component)
for component, symbols in self._component_symbols.items()
for symbol, demangled, size, section in symbols
]
# Get top N symbols by size using heapq for efficiency
top_symbols = heapq.nlargest(
self.TOP_SYMBOLS_LIMIT, all_symbols, key=itemgetter(2)
)
lines.append("")
lines.append(f"Top {self.TOP_SYMBOLS_LIMIT} Largest Symbols:")
# Calculate truncation limit from column width (leaving room for "...")
truncate_limit = self.COL_TOP_SYMBOL_NAME - 3
for i, (_, demangled, size, section, component) in enumerate(top_symbols):
# Format section label
section_label = f"[{section[1:]}]" if section else ""
# Truncate demangled name if too long
demangled_display = (
f"{demangled[:truncate_limit]}..."
if len(demangled) > self.COL_TOP_SYMBOL_NAME
else demangled
)
lines.append(
f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}"
)
def generate_report(self, detailed: bool = False) -> str:
"""Generate a formatted memory report."""
components = sorted(
@@ -248,6 +285,9 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
"RAM",
)
# Top largest symbols in the binary
self._add_top_symbols(lines)
# Add ESPHome core detailed analysis if there are core symbols
if self._esphome_core_symbols:
self._add_section_header(lines, f"{_COMPONENT_CORE} Detailed Analysis")

View File

@@ -45,6 +45,7 @@ service APIConnection {
rpc time_command (TimeCommandRequest) returns (void) {}
rpc update_command (UpdateCommandRequest) returns (void) {}
rpc valve_command (ValveCommandRequest) returns (void) {}
rpc water_heater_command (WaterHeaterCommandRequest) returns (void) {}
rpc subscribe_bluetooth_le_advertisements(SubscribeBluetoothLEAdvertisementsRequest) returns (void) {}
rpc bluetooth_device_request(BluetoothDeviceRequest) returns (void) {}

View File

@@ -1385,7 +1385,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec
is_single);
}
void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequest &msg) {
void APIConnection::water_heater_command(const WaterHeaterCommandRequest &msg) {
ENTITY_COMMAND_MAKE_CALL(water_heater::WaterHeater, water_heater, water_heater)
if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_MODE)
call.set_mode(static_cast<water_heater::WaterHeaterMode>(msg.mode));

View File

@@ -170,7 +170,7 @@ class APIConnection final : public APIServerConnection {
#ifdef USE_WATER_HEATER
bool send_water_heater_state(water_heater::WaterHeater *water_heater);
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override;
void water_heater_command(const WaterHeaterCommandRequest &msg) override;
#endif
#ifdef USE_IR_RF

View File

@@ -746,6 +746,11 @@ void APIServerConnection::on_update_command_request(const UpdateCommandRequest &
#ifdef USE_VALVE
void APIServerConnection::on_valve_command_request(const ValveCommandRequest &msg) { this->valve_command(msg); }
#endif
#ifdef USE_WATER_HEATER
void APIServerConnection::on_water_heater_command_request(const WaterHeaterCommandRequest &msg) {
this->water_heater_command(msg);
}
#endif
#ifdef USE_BLUETOOTH_PROXY
void APIServerConnection::on_subscribe_bluetooth_le_advertisements_request(
const SubscribeBluetoothLEAdvertisementsRequest &msg) {

View File

@@ -303,6 +303,9 @@ class APIServerConnection : public APIServerConnectionBase {
#ifdef USE_VALVE
virtual void valve_command(const ValveCommandRequest &msg) = 0;
#endif
#ifdef USE_WATER_HEATER
virtual void water_heater_command(const WaterHeaterCommandRequest &msg) = 0;
#endif
#ifdef USE_BLUETOOTH_PROXY
virtual void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) = 0;
#endif
@@ -432,6 +435,9 @@ class APIServerConnection : public APIServerConnectionBase {
#ifdef USE_VALVE
void on_valve_command_request(const ValveCommandRequest &msg) override;
#endif
#ifdef USE_WATER_HEATER
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override;
#endif
#ifdef USE_BLUETOOTH_PROXY
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg) override;
#endif

View File

@@ -34,14 +34,29 @@ static const char *const ESP_HOSTED_VERSION_STR = STRINGIFY(ESP_HOSTED_VERSION_M
ESP_HOSTED_VERSION_MINOR_1) "." STRINGIFY(ESP_HOSTED_VERSION_PATCH_1);
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
// Parse an integer from str, advancing ptr past the number
// Returns false if no digits were parsed
static bool parse_int(const char *&ptr, int &value) {
char *end;
value = static_cast<int>(strtol(ptr, &end, 10));
if (end == ptr)
return false;
ptr = end;
return true;
}
// Parse version string "major.minor.patch" into components
// Returns true if parsing succeeded
// Returns true if at least major.minor was parsed
static bool parse_version(const std::string &version_str, int &major, int &minor, int &patch) {
major = minor = patch = 0;
if (sscanf(version_str.c_str(), "%d.%d.%d", &major, &minor, &patch) >= 2) {
return true;
}
return false;
const char *ptr = version_str.c_str();
if (!parse_int(ptr, major) || *ptr++ != '.' || !parse_int(ptr, minor))
return false;
if (*ptr == '.')
parse_int(++ptr, patch);
return true;
}
// Compare two versions, returns:

View File

@@ -1,6 +1,6 @@
import logging
from esphome import pins
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components.esp32 import (
VARIANT_ESP32,
@@ -35,6 +35,8 @@ from esphome.const import (
CONF_MODE,
CONF_MOSI_PIN,
CONF_NUMBER,
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_PAGE_ID,
CONF_PIN,
CONF_POLLING_INTERVAL,
@@ -237,6 +239,8 @@ BASE_SCHEMA = cv.Schema(
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True),
}
).extend(cv.COMPONENT_SCHEMA)
@@ -430,6 +434,18 @@ async def to_code(config):
if CORE.using_arduino:
cg.add_library("WiFi", None)
if on_connect_config := config.get(CONF_ON_CONNECT):
cg.add_define("USE_ETHERNET_CONNECT_TRIGGER")
await automation.build_automation(
var.get_connect_trigger(), [], on_connect_config
)
if on_disconnect_config := config.get(CONF_ON_DISCONNECT):
cg.add_define("USE_ETHERNET_DISCONNECT_TRIGGER")
await automation.build_automation(
var.get_disconnect_trigger(), [], on_disconnect_config
)
CORE.add_job(final_step)

View File

@@ -309,6 +309,9 @@ void EthernetComponent::loop() {
this->dump_connect_params_();
this->status_clear_warning();
#ifdef USE_ETHERNET_CONNECT_TRIGGER
this->connect_trigger_.trigger();
#endif
} else if (now - this->connect_begin_ > 15000) {
ESP_LOGW(TAG, "Connecting failed; reconnecting");
this->start_connect_();
@@ -318,10 +321,16 @@ void EthernetComponent::loop() {
if (!this->started_) {
ESP_LOGI(TAG, "Stopped connection");
this->state_ = EthernetComponentState::STOPPED;
#ifdef USE_ETHERNET_DISCONNECT_TRIGGER
this->disconnect_trigger_.trigger();
#endif
} else if (!this->connected_) {
ESP_LOGW(TAG, "Connection lost; reconnecting");
this->state_ = EthernetComponentState::CONNECTING;
this->start_connect_();
#ifdef USE_ETHERNET_DISCONNECT_TRIGGER
this->disconnect_trigger_.trigger();
#endif
} else {
this->finish_connect_();
// When connected and stable, disable the loop to save CPU cycles

View File

@@ -4,6 +4,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/automation.h"
#include "esphome/components/network/ip_address.h"
#ifdef USE_ESP32
@@ -119,6 +120,12 @@ class EthernetComponent : public Component {
void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); }
#endif
#ifdef USE_ETHERNET_CONNECT_TRIGGER
Trigger<> *get_connect_trigger() { return &this->connect_trigger_; }
#endif
#ifdef USE_ETHERNET_DISCONNECT_TRIGGER
Trigger<> *get_disconnect_trigger() { return &this->disconnect_trigger_; }
#endif
protected:
static void eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data);
static void got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data);
@@ -190,6 +197,12 @@ class EthernetComponent : public Component {
StaticVector<EthernetIPStateListener *, ESPHOME_ETHERNET_IP_STATE_LISTENERS> ip_state_listeners_;
#endif
#ifdef USE_ETHERNET_CONNECT_TRIGGER
Trigger<> connect_trigger_;
#endif
#ifdef USE_ETHERNET_DISCONNECT_TRIGGER
Trigger<> disconnect_trigger_;
#endif
private:
// Stores a pointer to a string literal (static storage duration).
// ONLY set from Python-generated code with string literals - never dynamic strings.

View File

@@ -601,7 +601,8 @@ class Logger : public Component {
// Updates buffer_at with the formatted length, handling truncation:
// - When vsnprintf truncates (ret >= remaining), it writes (remaining - 1) chars + null terminator
// - When it doesn't truncate (ret < remaining), it writes ret chars + null terminator
static inline void HOT process_vsnprintf_result_(char *buffer, uint16_t *buffer_at, uint16_t remaining, int ret) {
__attribute__((always_inline)) static inline void process_vsnprintf_result(const char *buffer, uint16_t *buffer_at,
uint16_t remaining, int ret) {
if (ret < 0)
return; // Encoding error, do not increment buffer_at
*buffer_at += (ret >= remaining) ? (remaining - 1) : static_cast<uint16_t>(ret);
@@ -616,7 +617,7 @@ class Logger : public Component {
if (*buffer_at >= buffer_size)
return;
const uint16_t remaining = buffer_size - *buffer_at;
process_vsnprintf_result_(buffer, buffer_at, remaining, vsnprintf(buffer + *buffer_at, remaining, format, args));
process_vsnprintf_result(buffer, buffer_at, remaining, vsnprintf(buffer + *buffer_at, remaining, format, args));
}
#ifdef USE_STORE_LOG_STR_IN_FLASH
@@ -626,7 +627,7 @@ class Logger : public Component {
if (*buffer_at >= buffer_size)
return;
const uint16_t remaining = buffer_size - *buffer_at;
process_vsnprintf_result_(buffer, buffer_at, remaining, vsnprintf_P(buffer + *buffer_at, remaining, format, args));
process_vsnprintf_result(buffer, buffer_at, remaining, vsnprintf_P(buffer + *buffer_at, remaining, format, args));
}
#endif

View File

@@ -139,7 +139,8 @@ class MQTTBackendESP32 final : public MQTTBackend {
this->lwt_retain_ = retain;
}
void set_server(network::IPAddress ip, uint16_t port) final {
this->host_ = ip.str();
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
this->host_ = ip.str_to(ip_buf);
this->port_ = port;
}
void set_server(const char *host, uint16_t port) final {

View File

@@ -62,7 +62,7 @@ class RealTimeClock : public PollingComponent {
void apply_timezone_();
#endif
CallbackManager<void()> time_sync_callback_;
LazyCallbackManager<void()> time_sync_callback_;
};
template<typename... Ts> class TimeHasTimeCondition : public Condition<Ts...> {

View File

@@ -359,6 +359,10 @@ void Component::defer(const std::string &name, std::function<void()> &&f) { //
void Component::defer(const char *name, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, name, 0, std::move(f));
}
void Component::defer(uint32_t id, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, id, 0, std::move(f));
}
bool Component::cancel_defer(uint32_t id) { return App.scheduler.cancel_timeout(this, id); }
void Component::set_timeout(uint32_t timeout, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, static_cast<const char *>(nullptr), timeout, std::move(f));
}

View File

@@ -494,11 +494,15 @@ class Component {
/// Defer a callback to the next loop() call.
void defer(std::function<void()> &&f); // NOLINT
/// Defer a callback with a numeric ID (zero heap allocation)
void defer(uint32_t id, std::function<void()> &&f); // NOLINT
/// Cancel a defer callback using the specified name, name must not be empty.
// Remove before 2026.7.0
ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0")
bool cancel_defer(const std::string &name); // NOLINT
bool cancel_defer(const char *name); // NOLINT
bool cancel_defer(uint32_t id); // NOLINT
// Ordered for optimal packing on 32-bit systems
const LogString *component_source_{nullptr};

View File

@@ -240,6 +240,8 @@
#define USE_ETHERNET_KSZ8081
#define USE_ETHERNET_MANUAL_IP
#define USE_ETHERNET_IP_STATE_LISTENERS
#define USE_ETHERNET_CONNECT_TRIGGER
#define USE_ETHERNET_DISCONNECT_TRIGGER
#define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2
#endif

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -12,3 +12,7 @@ ethernet:
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -1,2 +1,6 @@
ethernet:
type: OPENETH
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"

View File

@@ -20,6 +20,9 @@ globals:
- id: retry_counter
type: int
initial_value: '0'
- id: defer_counter
type: int
initial_value: '0'
- id: tests_done
type: bool
initial_value: 'false'
@@ -136,11 +139,49 @@ script:
App.scheduler.cancel_retry(component1, 6002U);
ESP_LOGI("test", "Cancelled numeric retry 6002");
// Test 12: defer with numeric ID (Component method)
class TestDeferComponent : public Component {
public:
void test_defer_methods() {
// Test defer with uint32_t ID - should execute on next loop
this->defer(7001U, []() {
ESP_LOGI("test", "Component numeric defer 7001 fired");
id(defer_counter) += 1;
});
// Test another defer with numeric ID
this->defer(7002U, []() {
ESP_LOGI("test", "Component numeric defer 7002 fired");
id(defer_counter) += 1;
});
}
};
static TestDeferComponent test_defer_component;
test_defer_component.test_defer_methods();
// Test 13: cancel_defer with numeric ID (Component method)
class TestCancelDeferComponent : public Component {
public:
void test_cancel_defer() {
// Set a defer that should be cancelled
this->defer(8001U, []() {
ESP_LOGE("test", "ERROR: Numeric defer 8001 should have been cancelled");
});
// Cancel it immediately
bool cancelled = this->cancel_defer(8001U);
ESP_LOGI("test", "Cancelled numeric defer 8001: %s", cancelled ? "true" : "false");
}
};
static TestCancelDeferComponent test_cancel_defer_component;
test_cancel_defer_component.test_cancel_defer();
- id: report_results
then:
- lambda: |-
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d",
id(timeout_counter), id(interval_counter), id(retry_counter));
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d",
id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter));
sensor:
- platform: template

View File

@@ -19,6 +19,7 @@ async def test_scheduler_numeric_id_test(
timeout_count = 0
interval_count = 0
retry_count = 0
defer_count = 0
# Events for each test completion
numeric_timeout_1001_fired = asyncio.Event()
@@ -33,6 +34,9 @@ async def test_scheduler_numeric_id_test(
max_id_timeout_fired = asyncio.Event()
numeric_retry_done = asyncio.Event()
numeric_retry_cancelled = asyncio.Event()
numeric_defer_7001_fired = asyncio.Event()
numeric_defer_7002_fired = asyncio.Event()
numeric_defer_cancelled = asyncio.Event()
final_results_logged = asyncio.Event()
# Track interval counts
@@ -40,7 +44,7 @@ async def test_scheduler_numeric_id_test(
numeric_retry_count = 0
def on_log_line(line: str) -> None:
nonlocal timeout_count, interval_count, retry_count
nonlocal timeout_count, interval_count, retry_count, defer_count
nonlocal numeric_interval_count, numeric_retry_count
# Strip ANSI color codes
@@ -105,15 +109,27 @@ async def test_scheduler_numeric_id_test(
elif "Cancelled numeric retry 6002" in clean_line:
numeric_retry_cancelled.set()
# Check for numeric defer tests
elif "Component numeric defer 7001 fired" in clean_line:
numeric_defer_7001_fired.set()
elif "Component numeric defer 7002 fired" in clean_line:
numeric_defer_7002_fired.set()
elif "Cancelled numeric defer 8001: true" in clean_line:
numeric_defer_cancelled.set()
# Check for final results
elif "Final results" in clean_line:
match = re.search(
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+)", clean_line
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)",
clean_line,
)
if match:
timeout_count = int(match.group(1))
interval_count = int(match.group(2))
retry_count = int(match.group(3))
defer_count = int(match.group(4))
final_results_logged.set()
async with (
@@ -201,6 +217,23 @@ async def test_scheduler_numeric_id_test(
"Numeric retry 6002 should have been cancelled"
)
# Wait for numeric defer tests
try:
await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7001 did not fire within 0.5 seconds")
try:
await asyncio.wait_for(numeric_defer_7002_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7002 did not fire within 0.5 seconds")
# Verify numeric defer was cancelled
try:
await asyncio.wait_for(numeric_defer_cancelled.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 8001 cancel confirmation not received")
# Wait for final results
try:
await asyncio.wait_for(final_results_logged.wait(), timeout=3.0)
@@ -215,3 +248,4 @@ async def test_scheduler_numeric_id_test(
assert retry_count >= 2, (
f"Expected at least 2 retry attempts, got {retry_count}"
)
assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}"