From 28b9487b25043dcbecb12068d617bc1909acb22f Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 8 Feb 2026 18:52:05 +0100 Subject: [PATCH 01/19] [nrf52,logger] fix printk (#13874) --- esphome/components/logger/logger_zephyr.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index ef1702c5c1..1fc0acd573 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -68,7 +68,7 @@ void HOT Logger::write_msg_(const char *msg, uint16_t len) { #ifdef CONFIG_PRINTK // Requires the debug component and an active SWD connection. // It is used for pyocd rtt -t nrf52840 - k_str_out(const_cast(msg), len); + printk("%.*s", static_cast(len), msg); #endif if (this->uart_dev_ == nullptr) { return; From 756f1c6b7e8162752af7ca8191e2eff71a122e3a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:53:43 +1100 Subject: [PATCH 02/19] [lvgl] Fix crash with unconfigured `top_layer` (#13846) --- esphome/components/lvgl/schemas.py | 1 + tests/components/lvgl/test.host.yaml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 45d933c00e..2aeeedbd10 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -436,6 +436,7 @@ def container_schema(widget_type: WidgetType, extras=None): schema = schema.extend(widget_type.schema) def validator(value): + value = value or {} return append_layout_schema(schema, value)(value) return validator diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 00a8cd8c01..f84156c9d8 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -20,6 +20,8 @@ lvgl: - id: lvgl_0 default_font: space16 displays: sdl0 + top_layer: + - id: lvgl_1 displays: sdl1 on_idle: From 140ec0639ca3f0e8cac0c839d5e93f67cbee2621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:24:45 -0600 Subject: [PATCH 03/19] [api] Elide empty message construction in protobuf dispatch (#13871) --- esphome/components/api/api_connection.cpp | 17 ++-- esphome/components/api/api_connection.h | 24 +++-- esphome/components/api/api_pb2_service.cpp | 106 ++++++++------------- esphome/components/api/api_pb2_service.h | 62 ++++++------ script/api_protobuf/api_protobuf.py | 53 ++++++----- 5 files changed, 117 insertions(+), 145 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2aa5956f24..efc3d210b4 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -283,7 +283,7 @@ void APIConnection::loop() { #endif } -bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { +bool APIConnection::send_disconnect_response() { // remote initiated disconnect_client // don't close yet, we still need to send the disconnect response // close will happen on next loop @@ -292,7 +292,7 @@ bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { DisconnectResponse resp; return this->send_message(resp, DisconnectResponse::MESSAGE_TYPE); } -void APIConnection::on_disconnect_response(const DisconnectResponse &value) { +void APIConnection::on_disconnect_response() { this->helper_->close(); this->flags_.remove = true; } @@ -1095,7 +1095,7 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { void APIConnection::subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->subscribe_api_connection(this, msg.flags); } -void APIConnection::unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) { +void APIConnection::unsubscribe_bluetooth_le_advertisements() { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); } void APIConnection::bluetooth_device_request(const BluetoothDeviceRequest &msg) { @@ -1121,8 +1121,7 @@ void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) bluetooth_proxy::global_bluetooth_proxy->bluetooth_gatt_notify(msg); } -bool APIConnection::send_subscribe_bluetooth_connections_free_response( - const SubscribeBluetoothConnectionsFreeRequest &msg) { +bool APIConnection::send_subscribe_bluetooth_connections_free_response() { bluetooth_proxy::global_bluetooth_proxy->send_connections_free(this); return true; } @@ -1491,12 +1490,12 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { return this->send_message(resp, HelloResponse::MESSAGE_TYPE); } -bool APIConnection::send_ping_response(const PingRequest &msg) { +bool APIConnection::send_ping_response() { PingResponse resp; return this->send_message(resp, PingResponse::MESSAGE_TYPE); } -bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { +bool APIConnection::send_device_info_response() { DeviceInfoResponse resp{}; resp.name = StringRef(App.get_name()); resp.friendly_name = StringRef(App.get_friendly_name()); @@ -1746,9 +1745,7 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption } #endif #ifdef USE_API_HOMEASSISTANT_STATES -void APIConnection::subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) { - state_subs_at_ = 0; -} +void APIConnection::subscribe_home_assistant_states() { state_subs_at_ = 0; } #endif bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { if (this->flags_.remove) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 40e4fd61c1..935393b2da 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -127,7 +127,7 @@ class APIConnection final : public APIServerConnection { #endif // USE_API_HOMEASSISTANT_SERVICES #ifdef USE_BLUETOOTH_PROXY void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; - void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override; + void unsubscribe_bluetooth_le_advertisements() override; void bluetooth_device_request(const BluetoothDeviceRequest &msg) override; void bluetooth_gatt_read(const BluetoothGATTReadRequest &msg) override; @@ -136,7 +136,7 @@ class APIConnection final : public APIServerConnection { void bluetooth_gatt_write_descriptor(const BluetoothGATTWriteDescriptorRequest &msg) override; void bluetooth_gatt_get_services(const BluetoothGATTGetServicesRequest &msg) override; void bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) override; - bool send_subscribe_bluetooth_connections_free_response(const SubscribeBluetoothConnectionsFreeRequest &msg) override; + bool send_subscribe_bluetooth_connections_free_response() override; void bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) override; #endif @@ -187,8 +187,8 @@ class APIConnection final : public APIServerConnection { void update_command(const UpdateCommandRequest &msg) override; #endif - void on_disconnect_response(const DisconnectResponse &value) override; - void on_ping_response(const PingResponse &value) override { + void on_disconnect_response() override; + void on_ping_response() override { // we initiated ping this->flags_.sent_ping = false; } @@ -199,11 +199,11 @@ class APIConnection final : public APIServerConnection { void on_get_time_response(const GetTimeResponse &value) override; #endif bool send_hello_response(const HelloRequest &msg) override; - bool send_disconnect_response(const DisconnectRequest &msg) override; - bool send_ping_response(const PingRequest &msg) override; - bool send_device_info_response(const DeviceInfoRequest &msg) override; - void list_entities(const ListEntitiesRequest &msg) override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } - void subscribe_states(const SubscribeStatesRequest &msg) override { + bool send_disconnect_response() override; + bool send_ping_response() override; + bool send_device_info_response() override; + void list_entities() override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } + void subscribe_states() override { this->flags_.state_subscription = true; // Start initial state iterator only if no iterator is active // If list_entities is running, we'll start initial_state when it completes @@ -217,12 +217,10 @@ class APIConnection final : public APIServerConnection { App.schedule_dump_config(); } #ifdef USE_API_HOMEASSISTANT_SERVICES - void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) override { - this->flags_.service_call_subscription = true; - } + void subscribe_homeassistant_services() override { this->flags_.service_call_subscription = true; } #endif #ifdef USE_API_HOMEASSISTANT_STATES - void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; + void subscribe_home_assistant_states() override; #endif #ifdef USE_API_USER_DEFINED_ACTIONS void execute_service(const ExecuteServiceRequest &msg) override; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index af0a2d0ca2..df66b6eb83 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -15,6 +15,9 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name, const DumpBuffer dump_buf; ESP_LOGVV(TAG, "%s: %s", LOG_STR_ARG(name), msg.dump_to(dump_buf)); } +void APIServerConnectionBase::log_receive_message_(const LogString *name) { + ESP_LOGVV(TAG, "%s: {}", LOG_STR_ARG(name)); +} #endif void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { @@ -29,66 +32,52 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } case DisconnectRequest::MESSAGE_TYPE: { - DisconnectRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_disconnect_request"), msg); + this->log_receive_message_(LOG_STR("on_disconnect_request")); #endif - this->on_disconnect_request(msg); + this->on_disconnect_request(); break; } case DisconnectResponse::MESSAGE_TYPE: { - DisconnectResponse msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_disconnect_response"), msg); + this->log_receive_message_(LOG_STR("on_disconnect_response")); #endif - this->on_disconnect_response(msg); + this->on_disconnect_response(); break; } case PingRequest::MESSAGE_TYPE: { - PingRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_ping_request"), msg); + this->log_receive_message_(LOG_STR("on_ping_request")); #endif - this->on_ping_request(msg); + this->on_ping_request(); break; } case PingResponse::MESSAGE_TYPE: { - PingResponse msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_ping_response"), msg); + this->log_receive_message_(LOG_STR("on_ping_response")); #endif - this->on_ping_response(msg); + this->on_ping_response(); break; } case DeviceInfoRequest::MESSAGE_TYPE: { - DeviceInfoRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_device_info_request"), msg); + this->log_receive_message_(LOG_STR("on_device_info_request")); #endif - this->on_device_info_request(msg); + this->on_device_info_request(); break; } case ListEntitiesRequest::MESSAGE_TYPE: { - ListEntitiesRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_list_entities_request"), msg); + this->log_receive_message_(LOG_STR("on_list_entities_request")); #endif - this->on_list_entities_request(msg); + this->on_list_entities_request(); break; } case SubscribeStatesRequest::MESSAGE_TYPE: { - SubscribeStatesRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_subscribe_states_request"), msg); + this->log_receive_message_(LOG_STR("on_subscribe_states_request")); #endif - this->on_subscribe_states_request(msg); + this->on_subscribe_states_request(); break; } case SubscribeLogsRequest::MESSAGE_TYPE: { @@ -146,12 +135,10 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #endif #ifdef USE_API_HOMEASSISTANT_SERVICES case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: { - SubscribeHomeassistantServicesRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_subscribe_homeassistant_services_request"), msg); + this->log_receive_message_(LOG_STR("on_subscribe_homeassistant_services_request")); #endif - this->on_subscribe_homeassistant_services_request(msg); + this->on_subscribe_homeassistant_services_request(); break; } #endif @@ -166,12 +153,10 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } #ifdef USE_API_HOMEASSISTANT_STATES case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: { - SubscribeHomeAssistantStatesRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_subscribe_home_assistant_states_request"), msg); + this->log_receive_message_(LOG_STR("on_subscribe_home_assistant_states_request")); #endif - this->on_subscribe_home_assistant_states_request(msg); + this->on_subscribe_home_assistant_states_request(); break; } #endif @@ -375,23 +360,19 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #endif #ifdef USE_BLUETOOTH_PROXY case SubscribeBluetoothConnectionsFreeRequest::MESSAGE_TYPE: { - SubscribeBluetoothConnectionsFreeRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"), msg); + this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); #endif - this->on_subscribe_bluetooth_connections_free_request(msg); + this->on_subscribe_bluetooth_connections_free_request(); break; } #endif #ifdef USE_BLUETOOTH_PROXY case UnsubscribeBluetoothLEAdvertisementsRequest::MESSAGE_TYPE: { - UnsubscribeBluetoothLEAdvertisementsRequest msg; - // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_unsubscribe_bluetooth_le_advertisements_request"), msg); + this->log_receive_message_(LOG_STR("on_unsubscribe_bluetooth_le_advertisements_request")); #endif - this->on_unsubscribe_bluetooth_le_advertisements_request(msg); + this->on_unsubscribe_bluetooth_le_advertisements_request(); break; } #endif @@ -647,36 +628,29 @@ void APIServerConnection::on_hello_request(const HelloRequest &msg) { this->on_fatal_error(); } } -void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) { - if (!this->send_disconnect_response(msg)) { +void APIServerConnection::on_disconnect_request() { + if (!this->send_disconnect_response()) { this->on_fatal_error(); } } -void APIServerConnection::on_ping_request(const PingRequest &msg) { - if (!this->send_ping_response(msg)) { +void APIServerConnection::on_ping_request() { + if (!this->send_ping_response()) { this->on_fatal_error(); } } -void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) { - if (!this->send_device_info_response(msg)) { +void APIServerConnection::on_device_info_request() { + if (!this->send_device_info_response()) { this->on_fatal_error(); } } -void APIServerConnection::on_list_entities_request(const ListEntitiesRequest &msg) { this->list_entities(msg); } -void APIServerConnection::on_subscribe_states_request(const SubscribeStatesRequest &msg) { - this->subscribe_states(msg); -} +void APIServerConnection::on_list_entities_request() { this->list_entities(); } +void APIServerConnection::on_subscribe_states_request() { this->subscribe_states(); } void APIServerConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { this->subscribe_logs(msg); } #ifdef USE_API_HOMEASSISTANT_SERVICES -void APIServerConnection::on_subscribe_homeassistant_services_request( - const SubscribeHomeassistantServicesRequest &msg) { - this->subscribe_homeassistant_services(msg); -} +void APIServerConnection::on_subscribe_homeassistant_services_request() { this->subscribe_homeassistant_services(); } #endif #ifdef USE_API_HOMEASSISTANT_STATES -void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) { - this->subscribe_home_assistant_states(msg); -} +void APIServerConnection::on_subscribe_home_assistant_states_request() { this->subscribe_home_assistant_states(); } #endif #ifdef USE_API_USER_DEFINED_ACTIONS void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { this->execute_service(msg); } @@ -793,17 +767,15 @@ void APIServerConnection::on_bluetooth_gatt_notify_request(const BluetoothGATTNo } #endif #ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_subscribe_bluetooth_connections_free_request( - const SubscribeBluetoothConnectionsFreeRequest &msg) { - if (!this->send_subscribe_bluetooth_connections_free_response(msg)) { +void APIServerConnection::on_subscribe_bluetooth_connections_free_request() { + if (!this->send_subscribe_bluetooth_connections_free_response()) { this->on_fatal_error(); } } #endif #ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request( - const UnsubscribeBluetoothLEAdvertisementsRequest &msg) { - this->unsubscribe_bluetooth_le_advertisements(msg); +void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request() { + this->unsubscribe_bluetooth_le_advertisements(); } #endif #ifdef USE_BLUETOOTH_PROXY diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index e2bc1609ed..b8c9e4da6f 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -14,6 +14,7 @@ class APIServerConnectionBase : public ProtoService { protected: void log_send_message_(const char *name, const char *dump); void log_receive_message_(const LogString *name, const ProtoMessage &msg); + void log_receive_message_(const LogString *name); public: #endif @@ -28,15 +29,15 @@ class APIServerConnectionBase : public ProtoService { virtual void on_hello_request(const HelloRequest &value){}; - virtual void on_disconnect_request(const DisconnectRequest &value){}; - virtual void on_disconnect_response(const DisconnectResponse &value){}; - virtual void on_ping_request(const PingRequest &value){}; - virtual void on_ping_response(const PingResponse &value){}; - virtual void on_device_info_request(const DeviceInfoRequest &value){}; + virtual void on_disconnect_request(){}; + virtual void on_disconnect_response(){}; + virtual void on_ping_request(){}; + virtual void on_ping_response(){}; + virtual void on_device_info_request(){}; - virtual void on_list_entities_request(const ListEntitiesRequest &value){}; + virtual void on_list_entities_request(){}; - virtual void on_subscribe_states_request(const SubscribeStatesRequest &value){}; + virtual void on_subscribe_states_request(){}; #ifdef USE_COVER virtual void on_cover_command_request(const CoverCommandRequest &value){}; @@ -61,14 +62,14 @@ class APIServerConnectionBase : public ProtoService { #endif #ifdef USE_API_HOMEASSISTANT_SERVICES - virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){}; + virtual void on_subscribe_homeassistant_services_request(){}; #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES virtual void on_homeassistant_action_response(const HomeassistantActionResponse &value){}; #endif #ifdef USE_API_HOMEASSISTANT_STATES - virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){}; + virtual void on_subscribe_home_assistant_states_request(){}; #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -147,12 +148,11 @@ class APIServerConnectionBase : public ProtoService { #endif #ifdef USE_BLUETOOTH_PROXY - virtual void on_subscribe_bluetooth_connections_free_request(const SubscribeBluetoothConnectionsFreeRequest &value){}; + virtual void on_subscribe_bluetooth_connections_free_request(){}; #endif #ifdef USE_BLUETOOTH_PROXY - virtual void on_unsubscribe_bluetooth_le_advertisements_request( - const UnsubscribeBluetoothLEAdvertisementsRequest &value){}; + virtual void on_unsubscribe_bluetooth_le_advertisements_request(){}; #endif #ifdef USE_BLUETOOTH_PROXY @@ -231,17 +231,17 @@ class APIServerConnectionBase : public ProtoService { class APIServerConnection : public APIServerConnectionBase { public: virtual bool send_hello_response(const HelloRequest &msg) = 0; - virtual bool send_disconnect_response(const DisconnectRequest &msg) = 0; - virtual bool send_ping_response(const PingRequest &msg) = 0; - virtual bool send_device_info_response(const DeviceInfoRequest &msg) = 0; - virtual void list_entities(const ListEntitiesRequest &msg) = 0; - virtual void subscribe_states(const SubscribeStatesRequest &msg) = 0; + virtual bool send_disconnect_response() = 0; + virtual bool send_ping_response() = 0; + virtual bool send_device_info_response() = 0; + virtual void list_entities() = 0; + virtual void subscribe_states() = 0; virtual void subscribe_logs(const SubscribeLogsRequest &msg) = 0; #ifdef USE_API_HOMEASSISTANT_SERVICES - virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0; + virtual void subscribe_homeassistant_services() = 0; #endif #ifdef USE_API_HOMEASSISTANT_STATES - virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; + virtual void subscribe_home_assistant_states() = 0; #endif #ifdef USE_API_USER_DEFINED_ACTIONS virtual void execute_service(const ExecuteServiceRequest &msg) = 0; @@ -331,11 +331,10 @@ class APIServerConnection : public APIServerConnectionBase { virtual void bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) = 0; #endif #ifdef USE_BLUETOOTH_PROXY - virtual bool send_subscribe_bluetooth_connections_free_response( - const SubscribeBluetoothConnectionsFreeRequest &msg) = 0; + virtual bool send_subscribe_bluetooth_connections_free_response() = 0; #endif #ifdef USE_BLUETOOTH_PROXY - virtual void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) = 0; + virtual void unsubscribe_bluetooth_le_advertisements() = 0; #endif #ifdef USE_BLUETOOTH_PROXY virtual void bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) = 0; @@ -363,17 +362,17 @@ class APIServerConnection : public APIServerConnectionBase { #endif protected: void on_hello_request(const HelloRequest &msg) override; - void on_disconnect_request(const DisconnectRequest &msg) override; - void on_ping_request(const PingRequest &msg) override; - void on_device_info_request(const DeviceInfoRequest &msg) override; - void on_list_entities_request(const ListEntitiesRequest &msg) override; - void on_subscribe_states_request(const SubscribeStatesRequest &msg) override; + void on_disconnect_request() override; + void on_ping_request() override; + void on_device_info_request() override; + void on_list_entities_request() override; + void on_subscribe_states_request() override; void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override; #ifdef USE_API_HOMEASSISTANT_SERVICES - void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &msg) override; + void on_subscribe_homeassistant_services_request() override; #endif #ifdef USE_API_HOMEASSISTANT_STATES - void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; + void on_subscribe_home_assistant_states_request() override; #endif #ifdef USE_API_USER_DEFINED_ACTIONS void on_execute_service_request(const ExecuteServiceRequest &msg) override; @@ -463,11 +462,10 @@ class APIServerConnection : public APIServerConnectionBase { void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) override; #endif #ifdef USE_BLUETOOTH_PROXY - void on_subscribe_bluetooth_connections_free_request(const SubscribeBluetoothConnectionsFreeRequest &msg) override; + void on_subscribe_bluetooth_connections_free_request() override; #endif #ifdef USE_BLUETOOTH_PROXY - void on_unsubscribe_bluetooth_le_advertisements_request( - const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override; + void on_unsubscribe_bluetooth_le_advertisements_request() override; #endif #ifdef USE_BLUETOOTH_PROXY void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) override; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 4021a062ca..5fbc1137a8 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2270,10 +2270,13 @@ SOURCE_NAMES = { SOURCE_CLIENT: "SOURCE_CLIENT", } -RECEIVE_CASES: dict[int, tuple[str, str | None]] = {} +RECEIVE_CASES: dict[int, tuple[str, str | None, str]] = {} ifdefs: dict[str, str] = {} +# Track messages with no fields (empty messages) for parameter elision +EMPTY_MESSAGES: set[str] = set() + def get_opt( desc: descriptor.DescriptorProto, @@ -2504,26 +2507,26 @@ def build_service_message_type( # Only add ifdef when we're actually generating content if ifdef is not None: hout += f"#ifdef {ifdef}\n" - # Generate receive + # Generate receive handler and switch case func = f"on_{snake}" - hout += f"virtual void {func}(const {mt.name} &value){{}};\n" - case = "" - case += f"{mt.name} msg;\n" - # Check if this message has any fields (excluding deprecated ones) has_fields = any(not field.options.deprecated for field in mt.field) - if has_fields: - # Normal case: decode the message + is_empty = not has_fields + if is_empty: + EMPTY_MESSAGES.add(mt.name) + hout += f"virtual void {func}({'' if is_empty else f'const {mt.name} &value'}){{}};\n" + case = "" + if not is_empty: + case += f"{mt.name} msg;\n" case += "msg.decode(msg_data, msg_size);\n" - else: - # Empty message optimization: skip decode since there are no fields - case += "// Empty message: no decode needed\n" if log: case += "#ifdef HAS_PROTO_MESSAGE_DUMP\n" - case += f'this->log_receive_message_(LOG_STR("{func}"), msg);\n' + if is_empty: + case += f'this->log_receive_message_(LOG_STR("{func}"));\n' + else: + case += f'this->log_receive_message_(LOG_STR("{func}"), msg);\n' case += "#endif\n" - case += f"this->{func}(msg);\n" + case += f"this->{func}({'msg' if not is_empty else ''});\n" case += "break;" - # Store the message name and ifdef with the case for later use RECEIVE_CASES[id_] = (case, ifdef, mt.name) # Only close ifdef if we opened it @@ -2839,6 +2842,7 @@ static const char *const TAG = "api.service"; hpp += ( " void log_receive_message_(const LogString *name, const ProtoMessage &msg);\n" ) + hpp += " void log_receive_message_(const LogString *name);\n" hpp += " public:\n" hpp += "#endif\n\n" @@ -2862,6 +2866,9 @@ static const char *const TAG = "api.service"; cpp += " DumpBuffer dump_buf;\n" cpp += ' ESP_LOGVV(TAG, "%s: %s", LOG_STR_ARG(name), msg.dump_to(dump_buf));\n' cpp += "}\n" + cpp += f"void {class_name}::log_receive_message_(const LogString *name) {{\n" + cpp += ' ESP_LOGVV(TAG, "%s: {}", LOG_STR_ARG(name));\n' + cpp += "}\n" cpp += "#endif\n\n" for mt in file.message_type: @@ -2929,22 +2936,22 @@ static const char *const TAG = "api.service"; hpp_protected += f"#ifdef {ifdef}\n" cpp += f"#ifdef {ifdef}\n" - hpp_protected += f" void {on_func}(const {inp} &msg) override;\n" + is_empty = inp in EMPTY_MESSAGES + param = "" if is_empty else f"const {inp} &msg" + arg = "" if is_empty else "msg" - # For non-void methods, generate a send_ method instead of return-by-value + hpp_protected += f" void {on_func}({param}) override;\n" if is_void: - hpp += f" virtual void {func}(const {inp} &msg) = 0;\n" + hpp += f" virtual void {func}({param}) = 0;\n" else: - hpp += f" virtual bool send_{func}_response(const {inp} &msg) = 0;\n" + hpp += f" virtual bool send_{func}_response({param}) = 0;\n" - cpp += f"void {class_name}::{on_func}(const {inp} &msg) {{\n" - - # No authentication check here - it's done in read_message + cpp += f"void {class_name}::{on_func}({param}) {{\n" body = "" if is_void: - body += f"this->{func}(msg);\n" + body += f"this->{func}({arg});\n" else: - body += f"if (!this->send_{func}_response(msg)) {{\n" + body += f"if (!this->send_{func}_response({arg})) {{\n" body += " this->on_fatal_error();\n" body += "}\n" From eb6a6f8d0d6a408138d0dd2fd2143a8af9b2ee8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:25:05 -0600 Subject: [PATCH 04/19] [web_server_idf] Remove unused host() method (#13869) --- esphome/components/web_server_idf/web_server_idf.cpp | 2 -- esphome/components/web_server_idf/web_server_idf.h | 1 - 2 files changed, 3 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9860810452..39e6b7a790 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -258,8 +258,6 @@ StringRef AsyncWebServerRequest::url_to(std::span buffer) co return StringRef(buffer.data(), decoded_len); } -std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); } - void AsyncWebServerRequest::send(AsyncWebServerResponse *response) { httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 817f47da79..1760544963 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -121,7 +121,6 @@ class AsyncWebServerRequest { char buffer[URL_BUF_SIZE]; return std::string(this->url_to(buffer)); } - std::string host() const; // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } From 6ee185c58aa047dde7e933c4d5a1b47c42f6572a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:25:23 -0600 Subject: [PATCH 05/19] [dashboard] Use resolve/relative_to for download path validation (#13867) --- esphome/dashboard/web_server.py | 17 ++++-- tests/dashboard/test_web_server.py | 93 +++++++++++++++++++++++++----- 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index da50279864..00974bf460 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1054,17 +1054,26 @@ class DownloadBinaryRequestHandler(BaseHandler): # fallback to type=, but prioritize file= file_name = self.get_argument("type", None) file_name = self.get_argument("file", file_name) - if file_name is None: + if file_name is None or not file_name.strip(): self.send_error(400) return - file_name = file_name.replace("..", "").lstrip("/") # get requested download name, or build it based on filename download_name = self.get_argument( "download", f"{storage_json.name}-{file_name}", ) - path = storage_json.firmware_bin_path.parent.joinpath(file_name) + if storage_json.firmware_bin_path is None: + self.send_error(404) + return + + base_dir = storage_json.firmware_bin_path.parent.resolve() + path = base_dir.joinpath(file_name).resolve() + try: + path.relative_to(base_dir) + except ValueError: + self.send_error(403) + return if not path.is_file(): args = ["esphome", "idedata", settings.rel_path(configuration)] @@ -1078,7 +1087,7 @@ class DownloadBinaryRequestHandler(BaseHandler): found = False for image in idedata.extra_flash_images: - if image.path.endswith(file_name): + if image.path.as_posix().endswith(file_name): path = image.path download_name = file_name found = True diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 7642876ee5..9ea7a5164b 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -8,6 +8,7 @@ import gzip import json import os from pathlib import Path +import sys from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -421,7 +422,7 @@ async def test_download_binary_handler_idedata_fallback( # Mock idedata response mock_image = Mock() - mock_image.path = str(bootloader_file) + mock_image.path = bootloader_file mock_idedata_instance = Mock() mock_idedata_instance.extra_flash_images = [mock_image] mock_idedata.return_value = mock_idedata_instance @@ -528,14 +529,22 @@ async def test_download_binary_handler_subdirectory_file_url_encoded( @pytest.mark.asyncio @pytest.mark.usefixtures("mock_ext_storage_path") @pytest.mark.parametrize( - "attack_path", + ("attack_path", "expected_code"), [ - pytest.param("../../../secrets.yaml", id="basic_traversal"), - pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"), - pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"), - pytest.param("/etc/passwd", id="absolute_path"), - pytest.param("//etc/passwd", id="double_slash_absolute"), - pytest.param("....//secrets.yaml", id="multiple_dots"), + pytest.param("../../../secrets.yaml", 403, id="basic_traversal"), + pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"), + pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"), + pytest.param("/etc/passwd", 403, id="absolute_path"), + pytest.param("//etc/passwd", 403, id="double_slash_absolute"), + pytest.param( + "....//secrets.yaml", + # On Windows, Path.resolve() treats "..." and "...." as parent + # traversal (like ".."), so the path escapes base_dir -> 403. + # On Unix, "...." is a literal directory name that stays inside + # base_dir but doesn't exist -> 404. + 403 if sys.platform == "win32" else 404, + id="multiple_dots", + ), ], ) async def test_download_binary_handler_path_traversal_protection( @@ -543,11 +552,14 @@ async def test_download_binary_handler_path_traversal_protection( tmp_path: Path, mock_storage_json: MagicMock, attack_path: str, + expected_code: int, ) -> None: """Test that DownloadBinaryRequestHandler prevents path traversal attacks. - Verifies that attempts to use '..' in file paths are sanitized to prevent - accessing files outside the build directory. Tests multiple attack vectors. + Verifies that attempts to escape the build directory via '..' are rejected + using resolve()/relative_to() validation. Tests multiple attack vectors. + Real traversals that escape the base directory get 403. Paths like '....' + that resolve inside the base directory but don't exist get 404. """ # Create build structure build_dir = get_build_path(tmp_path, "test") @@ -565,14 +577,67 @@ async def test_download_binary_handler_path_traversal_protection( mock_storage.firmware_bin_path = firmware_file mock_storage_json.load.return_value = mock_storage - # Attempt path traversal attack - should be blocked - with pytest.raises(HTTPClientError) as exc_info: + # Mock async_run_system_command so paths that pass validation but don't exist + # return 404 deterministically without spawning a real subprocess. + with ( + patch( + "esphome.dashboard.web_server.async_run_system_command", + new_callable=AsyncMock, + return_value=(2, "", ""), + ), + pytest.raises(HTTPClientError) as exc_info, + ): await dashboard.fetch( f"/download.bin?configuration=test.yaml&file={attack_path}", method="GET", ) - # Should get 404 (file not found after sanitization) or 500 (idedata fails) - assert exc_info.value.code in (404, 500) + assert exc_info.value.code == expected_code + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_no_firmware_bin_path( + dashboard: DashboardTestHelper, + mock_storage_json: MagicMock, +) -> None: + """Test that download returns 404 when firmware_bin_path is None. + + This covers configs created by StorageJSON.from_wizard() where no + firmware has been compiled yet. + """ + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = None + mock_storage_json.load.return_value = mock_storage + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin", + method="GET", + ) + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"]) +async def test_download_binary_handler_empty_file_name( + dashboard: DashboardTestHelper, + mock_storage_json: MagicMock, + file_value: str, +) -> None: + """Test that download returns 400 for empty or whitespace-only file names.""" + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = Path("/fake/firmware.bin") + mock_storage_json.load.return_value = mock_storage + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + f"/download.bin?configuration=test.yaml&file={file_value}", + method="GET", + ) + assert exc_info.value.code == 400 @pytest.mark.asyncio From 5370687001745b2dfd590426eb3008158469a56b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:25:41 -0600 Subject: [PATCH 06/19] [wizard] Use secrets module for fallback AP password generation (#13864) --- esphome/wizard.py | 3 +-- tests/unit_tests/test_wizard.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/esphome/wizard.py b/esphome/wizard.py index 4b74847996..f83342cc6a 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,6 +1,5 @@ import base64 from pathlib import Path -import random import secrets import string from typing import Literal, NotRequired, TypedDict, Unpack @@ -130,7 +129,7 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: if len(ap_name) > 32: ap_name = ap_name_base kwargs["fallback_name"] = ap_name - kwargs["fallback_psk"] = "".join(random.choice(letters) for _ in range(12)) + kwargs["fallback_psk"] = "".join(secrets.choice(letters) for _ in range(12)) base = BASE_CONFIG_FRIENDLY if kwargs.get("friendly_name") else BASE_CONFIG diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index eb44c1c20f..0ce89230d8 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from pytest import MonkeyPatch @@ -632,3 +632,14 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): # rpipico doesn't support WiFi, so no api_encryption_key or ota_password assert "api_encryption_key" not in call_kwargs assert "ota_password" not in call_kwargs + + +def test_fallback_psk_uses_secrets_choice( + default_config: dict[str, Any], +) -> None: + """Test that fallback PSK is generated using secrets.choice.""" + with patch("esphome.wizard.secrets.choice", return_value="X") as mock_choice: + config = wz.wizard_file(**default_config) + + assert 'password: "XXXXXXXXXXXX"' in config + assert mock_choice.call_count == 12 From e24528c842f6cbab9b71a07d6d738006fd5dd509 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:25:59 -0600 Subject: [PATCH 07/19] [analyze-memory] Attribute CSWTCH symbols from SDK archives (#13850) --- esphome/analyze_memory/__init__.py | 156 +++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 42 deletions(-) diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index d8c941e76f..d8abc8bafb 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -397,47 +397,38 @@ class MemoryAnalyzer: return pioenvs_dir return None - def _scan_cswtch_in_objects( - self, obj_dir: Path - ) -> dict[str, list[tuple[str, int]]]: - """Scan object files for CSWTCH symbols using a single nm invocation. + @staticmethod + def _parse_nm_cswtch_output( + output: str, + base_dir: Path | None, + cswtch_map: dict[str, list[tuple[str, int]]], + ) -> None: + """Parse nm output for CSWTCH symbols and add to cswtch_map. - Uses ``nm --print-file-name -S`` on all ``.o`` files at once. - Output format: ``/path/to/file.o:address size type name`` + Handles both ``.o`` files and ``.a`` archives. + + nm output formats:: + + .o files: /path/file.o:hex_addr hex_size type name + .a files: /path/lib.a:member.o:hex_addr hex_size type name + + For ``.o`` files, paths are made relative to *base_dir* when possible. + For ``.a`` archives (detected by ``:`` in the file portion), paths are + formatted as ``archive_stem/member.o`` (e.g. ``liblwip2-536-feat/lwip-esp.o``). Args: - obj_dir: Directory containing object files (.pioenvs//) - - Returns: - Dict mapping "CSWTCH$NNN:size" to list of (source_file, size) tuples. + output: Raw stdout from ``nm --print-file-name -S``. + base_dir: Base directory for computing relative paths of ``.o`` files. + Pass ``None`` when scanning archives outside the build tree. + cswtch_map: Dict to populate, mapping ``"CSWTCH$N:size"`` to source list. """ - cswtch_map: dict[str, list[tuple[str, int]]] = defaultdict(list) - - if not self.nm_path: - return cswtch_map - - # Find all .o files recursively, sorted for deterministic output - obj_files = sorted(obj_dir.rglob("*.o")) - if not obj_files: - return cswtch_map - - _LOGGER.debug("Scanning %d object files for CSWTCH symbols", len(obj_files)) - - # Single nm call with --print-file-name for all object files - result = run_tool( - [self.nm_path, "--print-file-name", "-S"] + [str(f) for f in obj_files], - timeout=30, - ) - if result is None or result.returncode != 0: - return cswtch_map - - for line in result.stdout.splitlines(): + for line in output.splitlines(): if "CSWTCH$" not in line: continue # Split on last ":" that precedes a hex address. - # nm --print-file-name format: filepath:hex_addr hex_size type name - # We split from the right: find the last colon followed by hex digits. + # For .o: "filepath.o" : "hex_addr hex_size type name" + # For .a: "filepath.a:member.o" : "hex_addr hex_size type name" parts_after_colon = line.rsplit(":", 1) if len(parts_after_colon) != 2: continue @@ -457,16 +448,89 @@ class MemoryAnalyzer: except ValueError: continue - # Get relative path from obj_dir for readability - try: - rel_path = str(Path(file_path).relative_to(obj_dir)) - except ValueError: + # Determine readable source path + # Use ".a:" to detect archive format (not bare ":" which matches + # Windows drive letters like "C:\...\file.o"). + if ".a:" in file_path: + # Archive format: "archive.a:member.o" → "archive_stem/member.o" + archive_part, member = file_path.rsplit(":", 1) + archive_name = Path(archive_part).stem + rel_path = f"{archive_name}/{member}" + elif base_dir is not None: + try: + rel_path = str(Path(file_path).relative_to(base_dir)) + except ValueError: + rel_path = file_path + else: rel_path = file_path key = f"{sym_name}:{size}" cswtch_map[key].append((rel_path, size)) - return cswtch_map + def _run_nm_cswtch_scan( + self, + files: list[Path], + base_dir: Path | None, + cswtch_map: dict[str, list[tuple[str, int]]], + ) -> None: + """Run nm on *files* and add any CSWTCH symbols to *cswtch_map*. + + Args: + files: Object (``.o``) or archive (``.a``) files to scan. + base_dir: Base directory for relative path computation (see + :meth:`_parse_nm_cswtch_output`). + cswtch_map: Dict to populate with results. + """ + if not self.nm_path or not files: + return + + _LOGGER.debug("Scanning %d files for CSWTCH symbols", len(files)) + + result = run_tool( + [self.nm_path, "--print-file-name", "-S"] + [str(f) for f in files], + timeout=30, + ) + if result is None or result.returncode != 0: + _LOGGER.debug( + "nm failed or timed out scanning %d files for CSWTCH symbols", + len(files), + ) + return + + self._parse_nm_cswtch_output(result.stdout, base_dir, cswtch_map) + + def _scan_cswtch_in_sdk_archives( + self, cswtch_map: dict[str, list[tuple[str, int]]] + ) -> None: + """Scan SDK library archives (.a) for CSWTCH symbols. + + Prebuilt SDK libraries (e.g. lwip, bearssl) are not compiled from source, + so their CSWTCH symbols only exist inside ``.a`` archives. Results are + merged into *cswtch_map* for keys not already found in ``.o`` files. + + The same source file (e.g. ``lwip-esp.o``) often appears in multiple + library variants (``liblwip2-536.a``, ``liblwip2-1460-feat.a``, etc.), + so results are deduplicated by member name. + """ + sdk_dirs = self._find_sdk_library_dirs() + if not sdk_dirs: + return + + sdk_archives = sorted(a for sdk_dir in sdk_dirs for a in sdk_dir.glob("*.a")) + + sdk_map: dict[str, list[tuple[str, int]]] = defaultdict(list) + self._run_nm_cswtch_scan(sdk_archives, None, sdk_map) + + # Merge SDK results, deduplicating by member name. + for key, sources in sdk_map.items(): + if key in cswtch_map: + continue + seen: dict[str, tuple[str, int]] = {} + for path, sz in sources: + member = Path(path).name + if member not in seen: + seen[member] = (path, sz) + cswtch_map[key] = list(seen.values()) def _source_file_to_component(self, source_file: str) -> str: """Map a source object file path to its component name. @@ -505,17 +569,25 @@ class MemoryAnalyzer: CSWTCH symbols are compiler-generated lookup tables for switch statements. They are local symbols, so the same name can appear in different object files. - This method scans .o files to attribute them to their source components. + This method scans .o files and SDK archives to attribute them to their + source components. """ obj_dir = self._find_object_files_dir() if obj_dir is None: _LOGGER.debug("No object files directory found, skipping CSWTCH analysis") return - # Scan object files for CSWTCH symbols - cswtch_map = self._scan_cswtch_in_objects(obj_dir) + # Scan build-dir object files for CSWTCH symbols + cswtch_map: dict[str, list[tuple[str, int]]] = defaultdict(list) + self._run_nm_cswtch_scan(sorted(obj_dir.rglob("*.o")), obj_dir, cswtch_map) + + # Also scan SDK library archives (.a) for CSWTCH symbols. + # Prebuilt SDK libraries (e.g. lwip, bearssl) are not compiled from source + # so their symbols only exist inside .a archives, not as loose .o files. + self._scan_cswtch_in_sdk_archives(cswtch_map) + if not cswtch_map: - _LOGGER.debug("No CSWTCH symbols found in object files") + _LOGGER.debug("No CSWTCH symbols found in object files or SDK archives") return # Collect CSWTCH symbols from the ELF (already parsed in sections) From 46f8302d8ff4aa8acf9751d0df948f86e4b61aa0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:26:15 -0600 Subject: [PATCH 08/19] [mqtt] Use stack buffer for discovery topic to avoid heap allocation (#13812) --- esphome/components/mqtt/mqtt_component.cpp | 21 +++++++++++---------- esphome/components/mqtt/mqtt_component.h | 9 +++++++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index e4b80de50a..8cf393e2df 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -34,10 +34,7 @@ inline char *append_char(char *p, char c) { // MQTT_COMPONENT_TYPE_MAX_LEN, MQTT_SUFFIX_MAX_LEN, and MQTT_DEFAULT_TOPIC_MAX_LEN are in mqtt_component.h. // ESPHOME_DEVICE_NAME_MAX_LEN and OBJECT_ID_MAX_LEN are defined in entity_base.h. // This ensures the stack buffers below are always large enough. -static constexpr size_t DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) -// Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null -static constexpr size_t DISCOVERY_TOPIC_MAX_LEN = DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + - ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1; +// MQTT_DISCOVERY_PREFIX_MAX_LEN and MQTT_DISCOVERY_TOPIC_MAX_LEN are defined in mqtt_component.h // Function implementation of LOG_MQTT_COMPONENT macro to reduce code size void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) { @@ -54,15 +51,15 @@ void MQTTComponent::set_subscribe_qos(uint8_t qos) { this->subscribe_qos_ = qos; void MQTTComponent::set_retain(bool retain) { this->retain_ = retain; } -std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const { +StringRef MQTTComponent::get_discovery_topic_to_(std::span buf, + const MQTTDiscoveryInfo &discovery_info) const { char sanitized_name[ESPHOME_DEVICE_NAME_MAX_LEN + 1]; str_sanitize_to(sanitized_name, App.get_name().c_str()); const char *comp_type = this->component_type(); char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = this->get_default_object_id_to_(object_id_buf); - char buf[DISCOVERY_TOPIC_MAX_LEN]; - char *p = buf; + char *p = buf.data(); p = append_str(p, discovery_info.prefix.data(), discovery_info.prefix.size()); p = append_char(p, '/'); @@ -72,8 +69,9 @@ std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discove p = append_char(p, '/'); p = append_str(p, object_id.c_str(), object_id.size()); p = append_str(p, "/config", 7); + *p = '\0'; - return std::string(buf, p - buf); + return StringRef(buf.data(), p - buf.data()); } StringRef MQTTComponent::get_default_topic_for_to_(std::span buf, const char *suffix, @@ -182,16 +180,19 @@ bool MQTTComponent::publish_json(const char *topic, const json::json_build_t &f) bool MQTTComponent::send_discovery_() { const MQTTDiscoveryInfo &discovery_info = global_mqtt_client->get_discovery_info(); + char discovery_topic_buf[MQTT_DISCOVERY_TOPIC_MAX_LEN]; + StringRef discovery_topic = this->get_discovery_topic_to_(discovery_topic_buf, discovery_info); + if (discovery_info.clean) { ESP_LOGV(TAG, "'%s': Cleaning discovery", this->friendly_name_().c_str()); - return global_mqtt_client->publish(this->get_discovery_topic_(discovery_info), "", 0, this->qos_, true); + return global_mqtt_client->publish(discovery_topic.c_str(), "", 0, this->qos_, true); } ESP_LOGV(TAG, "'%s': Sending discovery", this->friendly_name_().c_str()); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return global_mqtt_client->publish_json( - this->get_discovery_topic_(discovery_info), + discovery_topic.c_str(), [this](JsonObject root) { SendDiscoveryConfig config; config.state_topic = true; diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 2cec6fda7e..76375fb106 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -32,6 +32,10 @@ static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: // Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN = MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1; +static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) +// Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null +static constexpr size_t MQTT_DISCOVERY_TOPIC_MAX_LEN = MQTT_DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + + 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1; class MQTTComponent; // Forward declaration void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic); @@ -263,8 +267,9 @@ class MQTTComponent : public Component { void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); protected: - /// Helper method to get the discovery topic for this component. - std::string get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const; + /// Helper method to get the discovery topic for this component into a buffer. + StringRef get_discovery_topic_to_(std::span buf, + const MQTTDiscoveryInfo &discovery_info) const; /** Get this components state/command/... topic into a buffer. * From c3c0c40524105ad3165581b2c8241dc1990201cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:26:29 -0600 Subject: [PATCH 09/19] [mqtt] Return friendly_name_() by const reference to avoid string copies (#13810) --- esphome/components/mqtt/mqtt_component.cpp | 6 +++--- esphome/components/mqtt/mqtt_component.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 8cf393e2df..09570106df 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -205,7 +205,7 @@ bool MQTTComponent::send_discovery_() { } // Fields from EntityBase - root[MQTT_NAME] = this->get_entity()->has_own_name() ? this->friendly_name_() : ""; + root[MQTT_NAME] = this->get_entity()->has_own_name() ? this->friendly_name_() : StringRef(); if (this->is_disabled_by_default_()) root[MQTT_ENABLED_BY_DEFAULT] = false; @@ -249,7 +249,7 @@ bool MQTTComponent::send_discovery_() { if (discovery_info.unique_id_generator == MQTT_MAC_ADDRESS_UNIQUE_ID_GENERATOR) { char friendly_name_hash[9]; buf_append_printf(friendly_name_hash, sizeof(friendly_name_hash), 0, "%08" PRIx32, - fnv1_hash(this->friendly_name_())); + fnv1_hash(this->friendly_name_().c_str())); // Format: mac-component_type-hash (e.g. "aabbccddeeff-sensor-12345678") // MAC (12) + "-" (1) + domain (max 20) + "-" (1) + hash (8) + null (1) = 43 char unique_id[MAC_ADDRESS_BUFFER_SIZE + ESPHOME_DOMAIN_MAX_LEN + 11]; @@ -415,7 +415,7 @@ void MQTTComponent::schedule_resend_state() { this->resend_state_ = true; } bool MQTTComponent::is_connected_() const { return global_mqtt_client->is_connected(); } // Pull these properties from EntityBase if not overridden -std::string MQTTComponent::friendly_name_() const { return this->get_entity()->get_name(); } +const StringRef &MQTTComponent::friendly_name_() const { return this->get_entity()->get_name(); } StringRef MQTTComponent::get_default_object_id_to_(std::span buf) const { return this->get_entity()->get_object_id_to(buf); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 76375fb106..eb98158647 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -293,7 +293,7 @@ class MQTTComponent : public Component { virtual const EntityBase *get_entity() const = 0; /// Get the friendly name of this MQTT component. - std::string friendly_name_() const; + const StringRef &friendly_name_() const; /// Get the icon field of this component as StringRef StringRef get_icon_ref_() const; From 422f413680690b257429a4c4f8b51a700bee40e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:26:44 -0600 Subject: [PATCH 10/19] [lps22] Replace set_retry with set_interval to avoid heap allocation (#13841) --- esphome/components/lps22/lps22.cpp | 14 ++++++++++---- esphome/components/lps22/lps22.h | 5 +++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 526286ba72..7fc5774b08 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -38,22 +38,29 @@ void LPS22Component::dump_config() { LOG_UPDATE_INTERVAL(this); } +static constexpr uint32_t INTERVAL_READ = 0; + void LPS22Component::update() { uint8_t value = 0x00; this->read_register(CTRL_REG2, &value, 1); value |= CTRL_REG2_ONE_SHOT_MASK; this->write_register(CTRL_REG2, &value, 1); - this->set_retry(READ_INTERVAL, READ_ATTEMPTS, [this](uint8_t _) { return this->try_read_(); }); + this->read_attempts_remaining_ = READ_ATTEMPTS; + this->set_interval(INTERVAL_READ, READ_INTERVAL, [this]() { this->try_read_(); }); } -RetryResult LPS22Component::try_read_() { +void LPS22Component::try_read_() { uint8_t value = 0x00; this->read_register(STATUS, &value, 1); const uint8_t expected_status_mask = STATUS_T_DA_MASK | STATUS_P_DA_MASK; if ((value & expected_status_mask) != expected_status_mask) { ESP_LOGD(TAG, "STATUS not ready: %x", value); - return RetryResult::RETRY; + if (--this->read_attempts_remaining_ == 0) { + this->cancel_interval(INTERVAL_READ); + } + return; } + this->cancel_interval(INTERVAL_READ); if (this->temperature_sensor_ != nullptr) { uint8_t t_buf[2]{0}; @@ -68,7 +75,6 @@ RetryResult LPS22Component::try_read_() { uint32_t p_lsb = encode_uint24(p_buf[2], p_buf[1], p_buf[0]); this->pressure_sensor_->publish_state(PRESSURE_SCALE * static_cast(p_lsb)); } - return RetryResult::DONE; } } // namespace lps22 diff --git a/esphome/components/lps22/lps22.h b/esphome/components/lps22/lps22.h index 549ea524ea..95ee4ad442 100644 --- a/esphome/components/lps22/lps22.h +++ b/esphome/components/lps22/lps22.h @@ -17,10 +17,11 @@ class LPS22Component : public sensor::Sensor, public PollingComponent, public i2 void dump_config() override; protected: + void try_read_(); + sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; - - RetryResult try_read_(); + uint8_t read_attempts_remaining_{0}; }; } // namespace lps22 From bed01da345c6c652ef464d800aa2c8152c9681a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 03:45:40 -0600 Subject: [PATCH 11/19] [api] Guard varint parsing against overlong encodings (#13870) --- esphome/components/api/proto.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 92978f765f..41ea0043f9 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -112,8 +112,12 @@ class ProtoVarInt { uint64_t result = buffer[0] & 0x7F; uint8_t bitpos = 7; + // A 64-bit varint is at most 10 bytes (ceil(64/7)). Reject overlong encodings + // to avoid undefined behavior from shifting uint64_t by >= 64 bits. + uint32_t max_len = std::min(len, uint32_t(10)); + // Start from the second byte since we've already processed the first - for (uint32_t i = 1; i < len; i++) { + for (uint32_t i = 1; i < max_len; i++) { uint8_t val = buffer[i]; result |= uint64_t(val & 0x7F) << uint64_t(bitpos); bitpos += 7; From fb9328372028c941d7a1c1bc446efbf157c835d0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 9 Feb 2026 01:52:49 -0800 Subject: [PATCH 12/19] [water_heater] Add state masking to distinguish explicit commands from no-change (#13879) --- .../components/water_heater/water_heater.cpp | 18 ++++++++++++------ esphome/components/water_heater/water_heater.h | 4 ++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index e6d1562352..9d7ae0cbc0 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -65,6 +65,7 @@ WaterHeaterCall &WaterHeaterCall::set_away(bool away) { } else { this->state_ &= ~WATER_HEATER_STATE_AWAY; } + this->state_mask_ |= WATER_HEATER_STATE_AWAY; return *this; } @@ -74,6 +75,7 @@ WaterHeaterCall &WaterHeaterCall::set_on(bool on) { } else { this->state_ &= ~WATER_HEATER_STATE_ON; } + this->state_mask_ |= WATER_HEATER_STATE_ON; return *this; } @@ -92,11 +94,11 @@ void WaterHeaterCall::perform() { if (!std::isnan(this->target_temperature_high_)) { ESP_LOGD(TAG, " Target Temperature High: %.2f", this->target_temperature_high_); } - if (this->state_ & WATER_HEATER_STATE_AWAY) { - ESP_LOGD(TAG, " Away: YES"); + if (this->state_mask_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGD(TAG, " Away: %s", (this->state_ & WATER_HEATER_STATE_AWAY) ? "YES" : "NO"); } - if (this->state_ & WATER_HEATER_STATE_ON) { - ESP_LOGD(TAG, " On: YES"); + if (this->state_mask_ & WATER_HEATER_STATE_ON) { + ESP_LOGD(TAG, " On: %s", (this->state_ & WATER_HEATER_STATE_ON) ? "YES" : "NO"); } this->parent_->control(*this); } @@ -137,13 +139,17 @@ void WaterHeaterCall::validate_() { this->target_temperature_high_ = NAN; } } - if ((this->state_ & WATER_HEATER_STATE_AWAY) && !traits.get_supports_away_mode()) { - ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + if (!traits.get_supports_away_mode()) { + if (this->state_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + } this->state_ &= ~WATER_HEATER_STATE_AWAY; + this->state_mask_ &= ~WATER_HEATER_STATE_AWAY; } // If ON/OFF not supported, device is always on - clear the flag silently if (!traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { this->state_ &= ~WATER_HEATER_STATE_ON; + this->state_mask_ &= ~WATER_HEATER_STATE_ON; } } diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 7bd05ba7f5..93fcf5f401 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -91,6 +91,8 @@ class WaterHeaterCall { float get_target_temperature_high() const { return this->target_temperature_high_; } /// Get state flags value uint32_t get_state() const { return this->state_; } + /// Get mask of state flags that are being changed + uint32_t get_state_mask() const { return this->state_mask_; } protected: void validate_(); @@ -100,6 +102,7 @@ class WaterHeaterCall { float target_temperature_low_{NAN}; float target_temperature_high_{NAN}; uint32_t state_{0}; + uint32_t state_mask_{0}; }; struct WaterHeaterCallInternal : public WaterHeaterCall { @@ -111,6 +114,7 @@ struct WaterHeaterCallInternal : public WaterHeaterCall { this->target_temperature_low_ = restore.target_temperature_low_; this->target_temperature_high_ = restore.target_temperature_high_; this->state_ = restore.state_; + this->state_mask_ = restore.state_mask_; return *this; } }; From ed3acd582e4a46c1fadcb2e01268ee5601dc3d28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 05:50:41 -0600 Subject: [PATCH 13/19] [scheduler] Make core timer ID collisions impossible with type-safe internal IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, PollingComponent used the string "update" and DelayAction used "delay" as scheduler names. If a component subclassing PollingComponent or using DelayAction also happened to use these same strings for its own timers, the core timer would be silently cancelled — a subtle bug that would be extremely hard to track down. This introduces InternalSchedulerID, a type-safe wrapper that routes through a new NUMERIC_ID_INTERNAL NameType. Since the scheduler matches by (component, name_type, id, type), internal IDs can never collide with component-level NUMERIC_ID or string-based names, even if the underlying uint32_t values overlap. Also migrates binary_sensor filters, MultiClickTrigger, and sensor filters from string-based to uint32_t scheduler IDs. Each filter is its own Component instance so IDs are scoped per-instance with no collision risk. --- .../components/binary_sensor/automation.cpp | 34 +++++++++++------- esphome/components/binary_sensor/filter.cpp | 36 ++++++++++++------- esphome/components/sensor/filter.cpp | 11 ++++-- esphome/core/base_automation.h | 11 +++--- esphome/core/component.cpp | 16 +++++++-- esphome/core/component.h | 21 +++++++++++ esphome/core/scheduler.cpp | 24 ++++++++++++- esphome/core/scheduler.h | 19 ++++++++-- 8 files changed, 132 insertions(+), 40 deletions(-) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index dfe911a2f8..faebe7e88f 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -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(); } diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 9c7238f6d7..9f7c5bab5f 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -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 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 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 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 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; } @@ -89,7 +97,8 @@ void AutorepeatFilter::next_timing_() { // 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_(); }); + 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 +113,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 +125,7 @@ optional LambdaFilter::new_value(bool value) { return this->f_(value); } optional 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 +133,7 @@ optional 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; } } diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 375505a557..ea0e2f0d7c 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -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 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 TimeoutFilterConfigured::new_value(float value) { // DebounceFilter optional 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 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_) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 19d0ccf972..8ecc9a70f1 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -191,15 +191,16 @@ template class DelayAction : public Action, 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]() { this->play_next_(); }, + this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, + scheduler_internal_id::DELAY_ACTION.id, 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::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, scheduler_internal_id::DELAY_ACTION.id, this->delay_.value(x...), + std::move(f), /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } } @@ -208,7 +209,7 @@ template class DelayAction : public Action, 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(scheduler_internal_id::DELAY_ACTION); } }; template class LambdaAction : public Action { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index f09a39d2bb..f7b4404ae3 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -195,12 +195,24 @@ void Component::set_timeout(uint32_t id, uint32_t timeout, std::function 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 &&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 &&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 &&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 &&f, float backoff_increase_factor) { // NOLINT App.scheduler.set_retry(this, id, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); @@ -516,12 +528,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(scheduler_internal_id::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(scheduler_internal_id::POLLING_UPDATE); } uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } diff --git a/esphome/core/component.h b/esphome/core/component.h index 97f2afe1a4..6f4dca9cf6 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -49,6 +49,21 @@ extern const float LATE; static const uint32_t SCHEDULER_DONT_RUN = 4294967295UL; +/// Type-safe wrapper for internal scheduler IDs used by 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. +struct InternalSchedulerID { + uint32_t id; +}; + +/// Reserved scheduler IDs for core base classes. +/// These use InternalSchedulerID which routes through NUMERIC_ID_INTERNAL, +/// a separate matching namespace from the NUMERIC_ID used by components. +namespace scheduler_internal_id { +constexpr InternalSchedulerID POLLING_UPDATE{0}; // PollingComponent interval +constexpr InternalSchedulerID DELAY_ACTION{1}; // DelayAction timeout +} // namespace scheduler_internal_id + // Forward declaration class PollingComponent; @@ -334,6 +349,8 @@ class Component { */ void set_interval(uint32_t id, uint32_t interval, std::function &&f); // NOLINT + void set_interval(InternalSchedulerID id, uint32_t interval, std::function &&f); // NOLINT + void set_interval(uint32_t interval, std::function &&f); // NOLINT /** Cancel an interval function. @@ -346,6 +363,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 /** Set an retry function with a unique name. Empty name means no cancelling possible. * @@ -452,6 +470,8 @@ class Component { */ void set_timeout(uint32_t id, uint32_t timeout, std::function &&f); // NOLINT + void set_timeout(InternalSchedulerID id, uint32_t timeout, std::function &&f); // NOLINT + void set_timeout(uint32_t timeout, std::function &&f); // NOLINT /** Cancel a timeout function. @@ -464,6 +484,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. * diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 047bf4ef17..173d0817c9 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -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); @@ -218,6 +224,11 @@ void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t time this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, std::move(func)); } +void HOT Scheduler::set_timeout(Component *component, InternalSchedulerID id, uint32_t timeout, + std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, timeout, + std::move(func)); +} bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); } @@ -227,6 +238,9 @@ bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); } +bool HOT Scheduler::cancel_timeout(Component *component, InternalSchedulerID id) { + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, SchedulerItem::TIMEOUT); +} void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, std::function func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), @@ -242,6 +256,11 @@ void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t int this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, std::move(func)); } +void HOT Scheduler::set_interval(Component *component, InternalSchedulerID id, uint32_t interval, + std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, interval, + std::move(func)); +} bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); } @@ -251,6 +270,9 @@ bool HOT Scheduler::cancel_interval(Component *component, const char *name) { bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); } +bool HOT Scheduler::cancel_interval(Component *component, InternalSchedulerID id) { + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, SchedulerItem::INTERVAL); +} struct RetryArgs { // Ordered to minimize padding on 32-bit systems diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7de1023e6d..8ab8adca49 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -46,11 +46,14 @@ class Scheduler { void set_timeout(Component *component, const char *name, uint32_t timeout, std::function func); /// Set a timeout with a numeric ID (zero heap allocation) void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function 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 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); 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 func); @@ -66,11 +69,14 @@ class Scheduler { void set_interval(Component *component, const char *name, uint32_t interval, std::function func); /// Set an interval with a numeric ID (zero heap allocation) void set_interval(Component *component, uint32_t id, uint32_t interval, std::function 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 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); ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, @@ -102,9 +108,10 @@ class Scheduler { // Name storage type discriminator for SchedulerItem // Used to distinguish between static strings, hashed strings, and 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: @@ -206,6 +213,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 &a, const std::unique_ptr &b); // Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility. From 3d2b9641a4fd3f9e66e2bb14917e1005f2abaef3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 06:05:13 -0600 Subject: [PATCH 14/19] [scheduler] Add integration test for internal vs numeric ID isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that NUMERIC_ID_INTERNAL and NUMERIC_ID are completely independent matching namespaces — same uint32_t value on the same component does not collide. Tests that cancelling one type does not affect the other, and that string names also don't cross-match. --- .../scheduler_internal_id_no_collision.yaml | 109 +++++++++++++++ ...test_scheduler_internal_id_no_collision.py | 124 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 tests/integration/fixtures/scheduler_internal_id_no_collision.yaml create mode 100644 tests/integration/test_scheduler_internal_id_no_collision.py diff --git a/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml new file mode 100644 index 0000000000..46dbb8e728 --- /dev/null +++ b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml @@ -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 diff --git a/tests/integration/test_scheduler_internal_id_no_collision.py b/tests/integration/test_scheduler_internal_id_no_collision.py new file mode 100644 index 0000000000..d30e725e00 --- /dev/null +++ b/tests/integration/test_scheduler_internal_id_no_collision.py @@ -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" + ) From 769d4e444440bdea96b4cd293b597c313b3e56bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 06:07:49 -0600 Subject: [PATCH 15/19] [scheduler] Inline InternalSchedulerID overloads to eliminate code size overhead Move the 4 Scheduler InternalSchedulerID methods from .cpp to inline in the header. They're trivial one-liners that extract .id and forward to set_timer_common_/cancel_item_, so the compiler can fold them into call sites instead of emitting separate function bodies. --- esphome/core/scheduler.cpp | 16 ---------------- esphome/core/scheduler.h | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 173d0817c9..513547c487 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -224,11 +224,6 @@ void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t time this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, std::move(func)); } -void HOT Scheduler::set_timeout(Component *component, InternalSchedulerID id, uint32_t timeout, - std::function func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, timeout, - std::move(func)); -} bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); } @@ -238,9 +233,6 @@ bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); } -bool HOT Scheduler::cancel_timeout(Component *component, InternalSchedulerID id) { - return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, SchedulerItem::TIMEOUT); -} void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, std::function func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), @@ -256,11 +248,6 @@ void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t int this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, std::move(func)); } -void HOT Scheduler::set_interval(Component *component, InternalSchedulerID id, uint32_t interval, - std::function func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, interval, - std::move(func)); -} bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); } @@ -270,9 +257,6 @@ bool HOT Scheduler::cancel_interval(Component *component, const char *name) { bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); } -bool HOT Scheduler::cancel_interval(Component *component, InternalSchedulerID id) { - return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, SchedulerItem::INTERVAL); -} struct RetryArgs { // Ordered to minimize padding on 32-bit systems diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 8ab8adca49..ed26eff27d 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -47,13 +47,18 @@ class Scheduler { /// Set a timeout with a numeric ID (zero heap allocation) void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function 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 func); + void set_timeout(Component *component, InternalSchedulerID id, uint32_t timeout, std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, id.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); + bool cancel_timeout(Component *component, InternalSchedulerID id) { + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, id.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 func); @@ -70,13 +75,18 @@ class Scheduler { /// Set an interval with a numeric ID (zero heap allocation) void set_interval(Component *component, uint32_t id, uint32_t interval, std::function 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 func); + void set_interval(Component *component, InternalSchedulerID id, uint32_t interval, std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, id.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); + bool cancel_interval(Component *component, InternalSchedulerID id) { + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, SchedulerItem::INTERVAL); + } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, From d2728f2ccdb8f6472d8d4b4f5426a357ea530697 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 06:16:49 -0600 Subject: [PATCH 16/19] [scheduler] Inline all trivial Scheduler forwarding methods Move all set_timeout, cancel_timeout, set_interval, cancel_interval overloads (const char*, uint32_t, std::string) from scheduler.cpp to inline in scheduler.h. These are all trivial one-liner forwarding calls to set_timer_common_ or cancel_item_ and benefit from inlining at call sites rather than being separate symbols. --- esphome/core/scheduler.cpp | 48 --------------------------------- esphome/core/scheduler.h | 54 +++++++++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 60 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 513547c487..1da67de68c 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -210,54 +210,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type this->to_add_.push_back(std::move(item)); } -void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, std::function func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::STATIC_STRING, name, 0, timeout, - std::move(func)); -} - -void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout, - std::function func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - timeout, std::move(func)); -} -void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, - std::move(func)); -} -bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); -} -bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { - return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); -} -bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { - return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); -} -void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, - std::function func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - interval, std::move(func)); -} - -void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval, - std::function func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, - std::move(func)); -} -void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t interval, std::function func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, - std::move(func)); -} -bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); -} -bool HOT Scheduler::cancel_interval(Component *component, const char *name) { - return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); -} -bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { - return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); -} - struct RetryArgs { // Ordered to minimize padding on 32-bit systems std::function func; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ed26eff27d..543d4ebb12 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -33,7 +33,10 @@ class Scheduler { // std::string overload - deprecated, use const char* or uint32_t instead // Remove before 2026.7.0 ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function func); + void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), + timeout, std::move(func)); + } /** Set a timeout with a const char* name. * @@ -43,9 +46,15 @@ class Scheduler { * - A static const char* variable * - A pointer with lifetime >= the scheduled task */ - void set_timeout(Component *component, const char *name, uint32_t timeout, std::function func); + void set_timeout(Component *component, const char *name, uint32_t timeout, std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::STATIC_STRING, name, 0, timeout, + std::move(func)); + } /// Set a timeout with a numeric ID (zero heap allocation) - void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func); + void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, + std::move(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 func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, timeout, @@ -53,15 +62,24 @@ class Scheduler { } 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, const std::string &name) { + return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); + } + bool cancel_timeout(Component *component, const char *name) { + return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); + } + bool cancel_timeout(Component *component, uint32_t id) { + return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); + } bool cancel_timeout(Component *component, InternalSchedulerID id) { return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, id.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 func); + void set_interval(Component *component, const std::string &name, uint32_t interval, std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), + interval, std::move(func)); + } /** Set an interval with a const char* name. * @@ -71,9 +89,15 @@ class Scheduler { * - A static const char* variable * - A pointer with lifetime >= the scheduled task */ - void set_interval(Component *component, const char *name, uint32_t interval, std::function func); + void set_interval(Component *component, const char *name, uint32_t interval, std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, + std::move(func)); + } /// Set an interval with a numeric ID (zero heap allocation) - void set_interval(Component *component, uint32_t id, uint32_t interval, std::function func); + void set_interval(Component *component, uint32_t id, uint32_t interval, std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, + std::move(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 func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, interval, @@ -81,9 +105,15 @@ class Scheduler { } 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, const std::string &name) { + return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); + } + bool cancel_interval(Component *component, const char *name) { + return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); + } + bool cancel_interval(Component *component, uint32_t id) { + return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); + } bool cancel_interval(Component *component, InternalSchedulerID id) { return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, SchedulerItem::INTERVAL); } From d9f7c26b167e87c4ec1a521bbd631b2e3e335d21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 06:24:49 -0600 Subject: [PATCH 17/19] Revert "[scheduler] Inline all trivial Scheduler forwarding methods" This reverts commit d2728f2ccdb8f6472d8d4b4f5426a357ea530697. --- esphome/core/scheduler.cpp | 48 +++++++++++++++++++++++++++++++++ esphome/core/scheduler.h | 54 +++++++++----------------------------- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 1da67de68c..513547c487 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -210,6 +210,54 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type this->to_add_.push_back(std::move(item)); } +void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::STATIC_STRING, name, 0, timeout, + std::move(func)); +} + +void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout, + std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), + timeout, std::move(func)); +} +void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, + std::move(func)); +} +bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { + return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); +} +bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { + return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); +} +bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { + return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); +} +void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, + std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), + interval, std::move(func)); +} + +void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval, + std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, + std::move(func)); +} +void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t interval, std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, + std::move(func)); +} +bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { + return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); +} +bool HOT Scheduler::cancel_interval(Component *component, const char *name) { + return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); +} +bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { + return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); +} + struct RetryArgs { // Ordered to minimize padding on 32-bit systems std::function func; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 543d4ebb12..ed26eff27d 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -33,10 +33,7 @@ class Scheduler { // std::string overload - deprecated, use const char* or uint32_t instead // Remove before 2026.7.0 ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - timeout, std::move(func)); - } + void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function func); /** Set a timeout with a const char* name. * @@ -46,15 +43,9 @@ class Scheduler { * - A static const char* variable * - A pointer with lifetime >= the scheduled task */ - void set_timeout(Component *component, const char *name, uint32_t timeout, std::function func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::STATIC_STRING, name, 0, timeout, - std::move(func)); - } + void set_timeout(Component *component, const char *name, uint32_t timeout, std::function func); /// Set a timeout with a numeric ID (zero heap allocation) - void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, - std::move(func)); - } + void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function 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 func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, timeout, @@ -62,24 +53,15 @@ class Scheduler { } 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) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); - } - bool cancel_timeout(Component *component, const char *name) { - return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); - } - bool cancel_timeout(Component *component, uint32_t id) { - return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); - } + 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, id.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 func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - interval, std::move(func)); - } + void set_interval(Component *component, const std::string &name, uint32_t interval, std::function func); /** Set an interval with a const char* name. * @@ -89,15 +71,9 @@ class Scheduler { * - A static const char* variable * - A pointer with lifetime >= the scheduled task */ - void set_interval(Component *component, const char *name, uint32_t interval, std::function func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, - std::move(func)); - } + void set_interval(Component *component, const char *name, uint32_t interval, std::function func); /// Set an interval with a numeric ID (zero heap allocation) - void set_interval(Component *component, uint32_t id, uint32_t interval, std::function func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, - std::move(func)); - } + void set_interval(Component *component, uint32_t id, uint32_t interval, std::function 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 func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, interval, @@ -105,15 +81,9 @@ class Scheduler { } 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) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); - } - bool cancel_interval(Component *component, const char *name) { - return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); - } - bool cancel_interval(Component *component, uint32_t id) { - return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); - } + 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, id.id, SchedulerItem::INTERVAL); } From dd2c5838e1c8d7560bc1f1f02e769fa904ebd388 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 06:25:55 -0600 Subject: [PATCH 18/19] [scheduler] Use enum class for InternalSchedulerID to avoid materialized constant The struct + constexpr namespace approach caused the compiler to materialize POLLING_UPDATE as a 4-byte symbol. An enum class with uint32_t underlying type gives the same type safety but enum values are true compile-time constants that never get materialized. --- esphome/core/base_automation.h | 9 +++++---- esphome/core/component.cpp | 4 ++-- esphome/core/component.h | 15 ++++----------- esphome/core/scheduler.h | 14 ++++++++------ 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 8ecc9a70f1..67e1755cc9 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -192,15 +192,16 @@ template class DelayAction : public Action, public Compon if constexpr (sizeof...(Ts) == 0) { App.scheduler.set_timer_common_( this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, - scheduler_internal_id::DELAY_ACTION.id, this->delay_.value(), [this]() { this->play_next_(); }, + static_cast(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::play_next_, this, x...); App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, - nullptr, scheduler_internal_id::DELAY_ACTION.id, this->delay_.value(x...), - std::move(f), + nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), + this->delay_.value(x...), std::move(f), /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } } @@ -209,7 +210,7 @@ template class DelayAction : public Action, public Compon void play(const Ts &...x) override { /* ignore - see play_complex */ } - void stop() override { this->cancel_timeout(scheduler_internal_id::DELAY_ACTION); } + void stop() override { this->cancel_timeout(InternalSchedulerID::DELAY_ACTION); } }; template class LambdaAction : public Action { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index f7b4404ae3..d43e50123c 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -528,12 +528,12 @@ void PollingComponent::call_setup() { void PollingComponent::start_poller() { // Register interval. - this->set_interval(scheduler_internal_id::POLLING_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(scheduler_internal_id::POLLING_UPDATE); + this->cancel_interval(InternalSchedulerID::POLLING_UPDATE); } uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } diff --git a/esphome/core/component.h b/esphome/core/component.h index 6f4dca9cf6..0adcef59e6 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -49,21 +49,14 @@ extern const float LATE; static const uint32_t SCHEDULER_DONT_RUN = 4294967295UL; -/// Type-safe wrapper for internal scheduler IDs used by core base classes. +/// 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. -struct InternalSchedulerID { - uint32_t id; +enum class InternalSchedulerID : uint32_t { + POLLING_UPDATE = 0, // PollingComponent interval + DELAY_ACTION = 1, // DelayAction timeout }; -/// Reserved scheduler IDs for core base classes. -/// These use InternalSchedulerID which routes through NUMERIC_ID_INTERNAL, -/// a separate matching namespace from the NUMERIC_ID used by components. -namespace scheduler_internal_id { -constexpr InternalSchedulerID POLLING_UPDATE{0}; // PollingComponent interval -constexpr InternalSchedulerID DELAY_ACTION{1}; // DelayAction timeout -} // namespace scheduler_internal_id - // Forward declaration class PollingComponent; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ed26eff27d..972c00705f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -48,8 +48,8 @@ class Scheduler { void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function 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 func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, timeout, - std::move(func)); + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, + static_cast(id), timeout, std::move(func)); } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") @@ -57,7 +57,8 @@ class Scheduler { 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, id.id, SchedulerItem::TIMEOUT); + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(id), + SchedulerItem::TIMEOUT); } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") @@ -76,8 +77,8 @@ class Scheduler { void set_interval(Component *component, uint32_t id, uint32_t interval, std::function 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 func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, id.id, interval, - std::move(func)); + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, + static_cast(id), interval, std::move(func)); } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") @@ -85,7 +86,8 @@ class Scheduler { 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, id.id, SchedulerItem::INTERVAL); + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(id), + SchedulerItem::INTERVAL); } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") From 78ddacd766e77269b3607203a031e7887f7492b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 06:26:53 -0600 Subject: [PATCH 19/19] [scheduler] Update NameType comment to mention internal numeric IDs --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 972c00705f..7c94953090 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -118,7 +118,7 @@ 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