From 28b9487b25043dcbecb12068d617bc1909acb22f Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 8 Feb 2026 18:52:05 +0100 Subject: [PATCH 01/10] [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 ef1702c5c1c..1fc0acd5733 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/10] [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 45d933c00e6..2aeeedbd100 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 00a8cd8c012..f84156c9d82 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/10] [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 2aa5956f24d..efc3d210b43 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 40e4fd61c15..935393b2da0 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 af0a2d0ca27..df66b6eb833 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 e2bc1609ed7..b8c9e4da6f6 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 4021a062ca4..5fbc1137a8f 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/10] [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 98608104527..39e6b7a790c 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 817f47da796..17605449632 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/10] [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 da502798649..00974bf4608 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 7642876ee57..9ea7a5164b7 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/10] [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 4b748479960..f83342cc6a5 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 eb44c1c20f4..0ce89230d80 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/10] [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 d8c941e76fe..d8abc8bafbc 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/10] [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 e4b80de50a1..8cf393e2df4 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 2cec6fda7ed..76375fb1060 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/10] [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 8cf393e2df4..09570106df9 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 76375fb1060..eb98158647e 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/10] [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 526286ba726..7fc5774b08c 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 549ea524ea9..95ee4ad4427 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